2 Commits

12 changed files with 829 additions and 467 deletions

View File

@@ -1,41 +0,0 @@
solana_user: solana
solana_group: solana
solana_home: /var/lib/solana
solana_install_script_url: https://release.anza.xyz/stable/install
solana_active_release_bin_dir: "{{ solana_home }}/.local/share/solana/install/active_release/bin"
solana_validator_bin: /opt/solana/bin/agave-validator
solana_keygen_bin: /opt/solana/bin/solana-keygen
solana_rpc_service_name: solana-rpc
agave_repo_url: https://github.com/anza-xyz/agave.git
agave_version_tag: v2.3.13
agave_src_dir: "{{ solana_home }}/src/agave"
agave_rust_toolchain: "1.86.0"
solana_identity_path: /var/lib/solana/identity.json
solana_ledger_dir: /var/lib/solana/ledger
solana_accounts_dir: /var/lib/solana/accounts
solana_log_dir: /var/log/solana
# Note: agave-validator expects all sockets to be bound to the same IP.
# For now we bind validator + RPC to 0.0.0.0 and rely on network hardening in a later etap.
solana_bind_address: 0.0.0.0
solana_rpc_port: 8899
solana_rpc_pubsub_port: 8900
solana_dynamic_port_range: "8000-8020"
solana_entrypoints:
- entrypoint.mainnet-beta.solana.com:8001
solana_known_validators: []
solana_geyser_enabled: false
solana_geyser_plugin_config_path: /etc/solana/yellowstone-geyser.json
solana_rpc_extra_args:
- --full-rpc-api
- --limit-ledger-size
solana_rpc_manage_service: true
solana_rpc_enable_on_boot: true
solana_rpc_start_now: true

View File

@@ -1,2 +1,2 @@
[sol_rpc] [sol_rpc]
mevnode ansible_host=n8c1e71.mevnode.com ansible_user=root ansible_ssh_private_key_file=/home/runner/.ssh/mevnode_baremetal mevnode ansible_host=n8c1e71.mevnode.com ansible_user=root

View File

@@ -3,8 +3,6 @@
hosts: sol_rpc hosts: sol_rpc
gather_facts: true gather_facts: true
become: true become: true
vars_files:
- ../group_vars/sol_rpc.yml
tasks: tasks:
- name: Install operator packages (Debian/Ubuntu) - name: Install operator packages (Debian/Ubuntu)
@@ -18,32 +16,6 @@
update_cache: true update_cache: true
when: ansible_facts.os_family == "Debian" when: ansible_facts.os_family == "Debian"
- name: Install Solana host base packages (Debian/Ubuntu)
ansible.builtin.apt:
name:
- chrony
- curl
- jq
- smartmontools
- nvme-cli
- prometheus-node-exporter
state: present
update_cache: true
when: ansible_facts.os_family == "Debian"
- name: Ensure solana group exists
ansible.builtin.group:
name: "{{ solana_group }}"
system: true
- name: Ensure solana user exists
ansible.builtin.user:
name: "{{ solana_user }}"
group: "{{ solana_group }}"
home: "{{ solana_home }}"
system: true
create_home: true
- name: Ensure root config directories exist - name: Ensure root config directories exist
ansible.builtin.file: ansible.builtin.file:
path: "{{ item }}" path: "{{ item }}"
@@ -57,38 +29,6 @@
- /root/.config/nvim - /root/.config/nvim
- /root/.config/nvim/lua - /root/.config/nvim/lua
- name: Ensure Solana directories exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "/etc/solana", owner: "root", group: "root", mode: "0755" }
- { path: "{{ solana_home }}", owner: "{{ solana_user }}", group: "{{ solana_group }}", mode: "0750" }
- { path: "{{ solana_ledger_dir }}", owner: "{{ solana_user }}", group: "{{ solana_group }}", mode: "0750" }
- { path: "{{ solana_accounts_dir }}", owner: "{{ solana_user }}", group: "{{ solana_group }}", mode: "0750" }
- { path: "{{ solana_log_dir }}", owner: "{{ solana_user }}", group: "{{ solana_group }}", mode: "0750" }
- { path: "/opt/solana/bin", owner: "root", group: "root", mode: "0755" }
- name: Deploy Solana sysctl tuning (network buffers)
ansible.builtin.copy:
dest: /etc/sysctl.d/99-solana-rpc.conf
owner: root
group: root
mode: "0644"
content: |
# Required by agave-validator startup checks
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
register: solana_sysctl_conf
- name: Apply sysctl tuning when changed
ansible.builtin.command: sysctl --system
changed_when: true
when: solana_sysctl_conf.changed
- name: Deploy tmux config (Ctrl+a prefix) - name: Deploy tmux config (Ctrl+a prefix)
ansible.builtin.copy: ansible.builtin.copy:
src: ../files/operator-dotfiles/tmux.conf src: ../files/operator-dotfiles/tmux.conf
@@ -124,197 +64,6 @@
- { src: "lua/utils.lua", dest: "lua/utils.lua" } - { src: "lua/utils.lua", dest: "lua/utils.lua" }
- { src: "lua/hazard3_dap.lua", dest: "lua/hazard3_dap.lua" } - { src: "lua/hazard3_dap.lua", dest: "lua/hazard3_dap.lua" }
- name: Deploy solana-rpc systemd unit (runs as solana user)
ansible.builtin.template:
src: ../templates/solana-rpc.service.j2
dest: /etc/systemd/system/{{ solana_rpc_service_name }}.service
owner: root
group: root
mode: "0644"
register: solana_rpc_unit
- name: Reload systemd after unit change
ansible.builtin.systemd:
daemon_reload: true
when: solana_rpc_unit.changed
- name: Check validator binary exists
ansible.builtin.stat:
path: "{{ solana_validator_bin }}"
register: solana_validator_bin_stat
- name: Install Agave toolchain for solana user when validator missing
ansible.builtin.shell: |
set -euo pipefail
sh -c "$(curl -sSfL {{ solana_install_script_url }})"
become_user: "{{ solana_user }}"
environment:
HOME: "{{ solana_home }}"
args:
executable: /bin/bash
when: not solana_validator_bin_stat.stat.exists
- name: Link Agave binaries into /opt/solana/bin
ansible.builtin.shell: |
set -euo pipefail
if [ ! -d "{{ solana_active_release_bin_dir }}" ]; then
echo "Active release bin dir missing: {{ solana_active_release_bin_dir }}" >&2
exit 1
fi
for bin in "{{ solana_active_release_bin_dir }}"/*; do
name="$(basename "$bin")"
ln -sfn "$bin" "/opt/solana/bin/$name"
done
args:
executable: /bin/bash
when: not solana_validator_bin_stat.stat.exists
- name: Re-check validator binary after install
ansible.builtin.stat:
path: "{{ solana_validator_bin }}"
register: solana_validator_bin_stat
- name: Install Agave build dependencies (Debian/Ubuntu) when validator missing
ansible.builtin.apt:
name:
- build-essential
- pkg-config
- libssl-dev
- clang
- llvm-dev
- libclang-dev
- cmake
- protobuf-compiler
- libudev-dev
- zlib1g-dev
state: present
update_cache: true
when:
- ansible_facts.os_family == "Debian"
- not solana_validator_bin_stat.stat.exists
- name: Ensure Agave source parent directory exists
ansible.builtin.file:
path: "{{ agave_src_dir | dirname }}"
state: directory
owner: "{{ solana_user }}"
group: "{{ solana_group }}"
mode: "0755"
when: not solana_validator_bin_stat.stat.exists
- name: Install rustup (Agave build) for solana user when validator missing
ansible.builtin.shell: |
set -euo pipefail
curl -sSfL https://sh.rustup.rs | sh -s -- -y --default-toolchain "{{ agave_rust_toolchain }}"
become_user: "{{ solana_user }}"
environment:
HOME: "{{ solana_home }}"
args:
executable: /bin/bash
creates: "{{ solana_home }}/.cargo/bin/cargo"
when: not solana_validator_bin_stat.stat.exists
- name: Ensure Rust toolchain is installed for solana user when validator missing
ansible.builtin.shell: |
set -euo pipefail
export PATH="{{ solana_home }}/.cargo/bin:$PATH"
rustup toolchain install "{{ agave_rust_toolchain }}"
become_user: "{{ solana_user }}"
environment:
HOME: "{{ solana_home }}"
args:
executable: /bin/bash
when: not solana_validator_bin_stat.stat.exists
- name: Clone Agave sources when validator missing
ansible.builtin.git:
repo: "{{ agave_repo_url }}"
dest: "{{ agave_src_dir }}"
version: "{{ agave_version_tag }}"
depth: 1
update: true
become_user: "{{ solana_user }}"
environment:
HOME: "{{ solana_home }}"
when: not solana_validator_bin_stat.stat.exists
- name: Build agave-validator from sources when validator missing
ansible.builtin.shell: |
set -euo pipefail
export PATH="{{ solana_home }}/.cargo/bin:$PATH"
cd "{{ agave_src_dir }}"
cargo +{{ agave_rust_toolchain }} build --release -p agave-validator
become_user: "{{ solana_user }}"
environment:
HOME: "{{ solana_home }}"
args:
executable: /bin/bash
creates: "{{ agave_src_dir }}/target/release/agave-validator"
when: not solana_validator_bin_stat.stat.exists
- name: Install agave-validator to /opt/solana/bin when built
ansible.builtin.copy:
remote_src: true
src: "{{ agave_src_dir }}/target/release/agave-validator"
dest: "{{ solana_validator_bin }}"
owner: root
group: root
mode: "0755"
when: not solana_validator_bin_stat.stat.exists
- name: Re-check validator binary after source build
ansible.builtin.stat:
path: "{{ solana_validator_bin }}"
register: solana_validator_bin_stat
- name: Ensure identity key exists
ansible.builtin.shell: |
set -euo pipefail
"{{ solana_keygen_bin }}" new --no-passphrase -o "{{ solana_identity_path }}"
become_user: "{{ solana_user }}"
environment:
HOME: "{{ solana_home }}"
args:
executable: /bin/bash
creates: "{{ solana_identity_path }}"
- name: Check identity key exists
ansible.builtin.stat:
path: "{{ solana_identity_path }}"
register: solana_identity_stat
- name: Ensure solana-rpc service state when prerequisites exist
ansible.builtin.systemd:
name: "{{ solana_rpc_service_name }}"
enabled: "{{ solana_rpc_enable_on_boot | bool }}"
state: "{{ 'started' if (solana_rpc_start_now | bool) else 'stopped' }}"
when:
- solana_rpc_manage_service | bool
- solana_validator_bin_stat.stat.exists
- solana_identity_stat.stat.exists
- name: Verify solana-rpc is active
ansible.builtin.command: systemctl is-active {{ solana_rpc_service_name }}
register: solana_rpc_is_active
changed_when: false
retries: 10
delay: 3
until: solana_rpc_is_active.stdout.strip() == "active"
when:
- solana_rpc_manage_service | bool
- solana_validator_bin_stat.stat.exists
- solana_identity_stat.stat.exists
- name: Report skipped solana-rpc start due to missing prerequisites
ansible.builtin.debug:
msg:
- "solana-rpc start skipped: missing prerequisites"
- "validator_bin={{ solana_validator_bin }} exists={{ solana_validator_bin_stat.stat.exists }}"
- "identity={{ solana_identity_path }} exists={{ solana_identity_stat.stat.exists }}"
when:
- solana_rpc_manage_service | bool
- not (solana_validator_bin_stat.stat.exists and solana_identity_stat.stat.exists)
- name: Validate Ansible transport - name: Validate Ansible transport
ansible.builtin.ping: ansible.builtin.ping:

View File

@@ -1,35 +0,0 @@
[Unit]
Description=Solana RPC node (Agave)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ solana_user }}
Group={{ solana_group }}
WorkingDirectory={{ solana_home }}
Environment=RUST_LOG=info
LimitNOFILE=1048576
Restart=always
RestartSec=5
TimeoutStopSec=120
ExecStart={{ solana_validator_bin }} \
--identity {{ solana_identity_path }} \
--ledger {{ solana_ledger_dir }} \
--accounts {{ solana_accounts_dir }} \
--bind-address {{ solana_bind_address }} \
--rpc-bind-address {{ solana_bind_address }} \
--rpc-port {{ solana_rpc_port }} \
--dynamic-port-range {{ solana_dynamic_port_range }}{% for ep in solana_entrypoints %} \
--entrypoint {{ ep }}{% endfor %}{% for kv in solana_known_validators %} \
--known-validator {{ kv }}{% endfor %}{% if solana_geyser_enabled | bool %} \
--geyser-plugin-config {{ solana_geyser_plugin_config_path }}{% endif %}{% for arg in solana_rpc_extra_args %} \
{{ arg }}{% endfor %} \
--log {{ solana_log_dir }}/validator.log
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths={{ solana_ledger_dir }} {{ solana_accounts_dir }} {{ solana_log_dir }} /var/lib/solana
[Install]
WantedBy=multi-user.target

View File

@@ -4,7 +4,7 @@ Cel etapu: uruchomić pierwszy, bezpieczny playbook Ansible dla hosta RPC (`mevn
## Zakres ## Zakres
1. Uruchamiać Ansible na VPS jako runner w Dockerze (bez instalacji Ansible na hoście), jeśli to jest preferowany model operacyjny. 1. Sprawdzić i zainstalować Ansible na VPS (control node), jeśli brak.
2. Dodać minimalną strukturę Ansible w `trade-iac`: 2. Dodać minimalną strukturę Ansible w `trade-iac`:
- inventory dla `mevnode`, - inventory dla `mevnode`,
- minimalny playbook testowy (bez zmian destrukcyjnych). - minimalny playbook testowy (bez zmian destrukcyjnych).
@@ -18,23 +18,6 @@ Cel etapu: uruchomić pierwszy, bezpieczny playbook Ansible dla hosta RPC (`mevn
## Kryteria akceptacji ## Kryteria akceptacji
- `ansible-playbook --version` działa w kontenerze na VPS. - `ansible-playbook --version` działa na VPS.
- Playbook kończy się statusem success dla grupy `sol_rpc`. - Playbook kończy się statusem success dla grupy `sol_rpc`.
- Wynik zawiera podstawowe fakty hosta i potwierdzenie łączności Ansible. - Wynik zawiera podstawowe fakty hosta i potwierdzenie łączności Ansible.
## Jak uruchomić na VPS (Docker)
Przykład (image: `quay.io/ansible/ansible-runner:latest`):
```bash
cd /opt/trade-iac
docker run --rm -t \
-v "$PWD/ansible:/ansible" \
-v "$HOME/.ssh:/home/runner/.ssh:ro" \
-w /ansible \
quay.io/ansible/ansible-runner:latest \
ansible-playbook -i inventory/hosts.ini playbooks/doc-rpc-sol-min.yml
```
Ważne: inventory w `ansible/inventory/hosts.ini` używa jawnie pliku klucza
`/home/runner/.ssh/mevnode_baremetal` (czyli wymaga montowania `~/.ssh` do `/home/runner/.ssh` w kontenerze).

View File

@@ -1,22 +0,0 @@
# Etap 005: Solana RPC jako usługa użytkownika `solana`
Cel etapu: przygotować i wdrożyć baseline pod `solana-rpc`, uruchamiany jako dedykowany użytkownik systemowy `solana` (nie `root`).
## Zakres
1. Rozszerzyć playbook o:
- pakiety bazowe dla hosta RPC,
- utworzenie użytkownika i katalogów `solana`,
- deployment unitu `systemd` `solana-rpc.service` z `User=solana`.
2. Dodać zmienne grupowe `sol_rpc` (ścieżki, porty, opcje startu).
3. Dodać bezpieczną logikę startu:
- unit jest wdrażany zawsze,
- start usługi tylko gdy istnieją wymagane artefakty (`agave-validator`, identity keypair).
4. Wdrożyć na `mevnode` i zweryfikować stan.
## Kryteria akceptacji
- `id solana` istnieje na `mevnode`.
- `/etc/systemd/system/solana-rpc.service` istnieje i zawiera `User=solana`.
- Playbook kończy się bez błędów.
- Jeśli binarka/identity nie istnieją, playbook raportuje to jawnie i nie wymusza startu.

View File

@@ -1,31 +0,0 @@
# Etap 006: Instalacja Agave + identity + start `solana-rpc`
Cel etapu: domknąć bootstrap uruchomienia `solana-rpc` jako `solana` przez:
1. instalację binarki `agave-validator`,
2. wygenerowanie `identity.json` (jeśli brak),
3. start usługi `solana-rpc` i test endpointu RPC.
## Zakres
- Rozszerzyć playbook o zadania instalacyjne Agave (idempotentnie).
- Dodać provisioning `identity` jako użytkownik `solana`.
- Dodać minimalny tuning OS wymagany przez startup check (`sysctl`).
- Utrzymać bezpieczny start: usługa uruchamiana tylko przy komplecie prereq.
- Wykonać testy powdrożeniowe (`systemd`, port, JSON-RPC).
## Założenia
- W tej wersji `agave-validator` wszystkie sockety (gossip/TPU/RPC) muszą być zbindowane do tego samego IP.
- Na czas bootstrapu bind jest na `0.0.0.0` (żeby validator przeszedł check reachability i wystartował).
- Produkcyjny bind na WG IP i hardening sieciowy będzie osobnym etapem.
- Release tar z `agave-install` nie zawiera `agave-validator`, więc `agave-validator` budujemy ze źródeł (tag `v2.x`) i instalujemy do `/opt/solana/bin`.
- Build wymaga pakietów dev, w tym `libclang`/`llvm` (Ansible instaluje je w playbooku).
## Kryteria akceptacji
- `agave-validator` istnieje pod `/opt/solana/bin/agave-validator`.
- `identity` istnieje pod `/var/lib/solana/identity.json` (owner `solana`).
- `systemctl is-active solana-rpc` zwraca `active`.
- Endpoint `http://127.0.0.1:8899` odpowiada na JSON-RPC.
- Websocket (pubsub) jest na porcie `solana_rpc_port + 1` (czyli domyślnie `8900` przy `8899`).

View File

@@ -0,0 +1,116 @@
# Step 001A: WireGuard tools (server/client) + instrukcje reset
Ten step dodaje dwa skrypty pomocnicze do zarzadzania WireGuard:
- server: `scripts/wireguard/wg_serverctl.py`
- client: `scripts/wireguard/wg_client.py`
Skrypty trzymaja stan na dysku, zgodnie z konwencja:
- server keys: `/etc/wireguard/server.key`, `/etc/wireguard/server.pub`
- clients: `/etc/wireguard/clients/<name>.key|.pub|.ip|.conf`
- generated server config: `/etc/wireguard/wg0.conf`
Wymagania: `wireguard-tools` (czyli `wg`, `wg-quick`) + `systemd`.
## Instalacja (server)
Na bare metalu:
```bash
apt-get update
apt-get install -y wireguard
```
Z repo (na hoście) zainstaluj skrypt jako root-only:
```bash
install -m 700 scripts/wireguard/wg_serverctl.py /usr/local/sbin/wg_serverctl.py
```
## Instalacja (client)
Na kliencie (laptop/VPS):
```bash
apt-get update
apt-get install -y wireguard
install -m 700 scripts/wireguard/wg_client.py /usr/local/sbin/wg_client.py
```
## Typowy flow (server)
```bash
# 1) wygeneruj klucze serwera
wg_serverctl.py gen-server --force
# 2) wygeneruj klucze klienta (trzymane na serwerze w /etc/wireguard/clients)
wg_serverctl.py gen-client client1
# 3) dodaj klienta do puli, wygeneruj wg0.conf i zrestartuj wg-quick@wg0
wg_serverctl.py add client1
# 4) wyeksportuj client config (do przekazania na klienta)
wg_serverctl.py export client1
# 5) podglad
wg_serverctl.py list
wg_serverctl.py show client1
```
Plik konfiguracyjny klienta powstaje jako:
- `/etc/wireguard/clients/client1.conf`
## Typowy flow (client)
Skopiuj `client1.conf` na klienta, a potem:
```bash
wg_client.py install ./client1.conf
wg_client.py up
wg_client.py status
```
## Stop / disable / reset wg0 (Ubuntu 24.04)
Stop (zostaw config, tylko down):
```bash
systemctl stop wg-quick@wg0
```
Disable autostart:
```bash
systemctl disable wg-quick@wg0
```
Flush (pewny down + usuniecie interfejsu):
```bash
systemctl stop wg-quick@wg0
ip link del wg0 2>/dev/null || true
ip a show wg0 2>/dev/null || echo "wg0 not present"
```
Hard reset (usuniecie kluczy + konfiguracji):
```bash
systemctl stop wg-quick@wg0
systemctl disable wg-quick@wg0
ip link del wg0 2>/dev/null || true
rm -f /etc/wireguard/wg0.conf
rm -f /etc/wireguard/server.key /etc/wireguard/server.pub
rm -rf /etc/wireguard/clients
```
Reset peerow bez kasowania server key:
```bash
systemctl stop wg-quick@wg0
rm -f /etc/wireguard/clients/*.pub /etc/wireguard/clients/*.ip
systemctl start wg-quick@wg0
```

View File

@@ -0,0 +1,198 @@
# Step 001: WireGuard + UFW + SSH tylko po WG (Ubuntu 24.04, 1 NIC)
Cel: na świeżym Ubuntu 24.04 na bare metalu ustawić:
- **WireGuard** jako prywatny kanał administracyjny,
- **SSH dostępne tylko przez WireGuard**,
- przygotować grunt pod **Agave RPC dostępne tylko przez WireGuard** (8899/8900 prywatnie),
- zostawić porty klastra Agave na publicznym NIC, żeby node mógł się zsynchronizować.
Kolejność jest krytyczna: najpierw uruchom WireGuard i sprawdź, że działa, dopiero potem ograniczaj SSH/firewall.
## Założenia
- Host ma **1 publiczny interfejs** (np. `enp1s0`).
- WireGuard interfejs to `wg0`.
- Masz dostęp awaryjny do konsoli (IPMI/KVM) na wypadek pomyłki.
## Parametry (dostosuj)
```bash
export PUBLIC_IFACE="enp1s0"
export WG_IFACE="wg0"
export WG_SERVER_IP="10.66.66.1/24"
export WG_SERVER_IP_ONLY="10.66.66.1"
export WG_PORT="51820"
export WG_CLIENT_IP="10.66.66.2/32"
export WG_CLIENT_PUBKEY="<PASTE_CLIENT_PUBLIC_KEY>"
# Agave/validator ports
export RPC_PORT="8899"
export WS_PORT="8900"
export DYNAMIC_PORT_RANGE="8000:8020"
```
## 1) WireGuard (nie ruszaj jeszcze SSH)
### Instalacja
```bash
apt-get update
apt-get install -y wireguard
```
### Klucze serwera
```bash
umask 077
wg genkey | tee /etc/wireguard/server.key >/dev/null
cat /etc/wireguard/server.key | wg pubkey | tee /etc/wireguard/server.pub >/dev/null
cat /etc/wireguard/server.pub
```
### Konfiguracja `/etc/wireguard/wg0.conf`
```bash
cat >/etc/wireguard/${WG_IFACE}.conf <<EOF
[Interface]
Address = ${WG_SERVER_IP}
ListenPort = ${WG_PORT}
PrivateKey = $(cat /etc/wireguard/server.key)
[Peer]
PublicKey = ${WG_CLIENT_PUBKEY}
AllowedIPs = ${WG_CLIENT_IP}
EOF
```
Start:
```bash
systemctl enable --now wg-quick@${WG_IFACE}
wg show
ip a show ${WG_IFACE}
```
Test z klienta (po zestawieniu tunelu): `ping 10.66.66.1`.
## 2) UFW: public tylko WG + porty klastra; prywatnie RPC/SSH po wg0
Instalacja:
```bash
apt-get install -y ufw
```
Public: pozwól na WireGuard:
```bash
ufw allow ${WG_PORT}/udp
```
Public: pozwól na porty klastra Agave (żeby node mógł syncować).
Uwaga: Solana/Agave używa głównie **UDP** w tym zakresie; jeśli będziesz miał problemy z reachability, rozważ dopuszczenie też TCP w tym samym zakresie.
```bash
ufw allow in on "${PUBLIC_IFACE}" to any port ${DYNAMIC_PORT_RANGE}/udp
```
Prywatnie (wg0): RPC/WS:
```bash
ufw allow in on "${WG_IFACE}" to any port ${RPC_PORT} proto tcp
ufw allow in on "${WG_IFACE}" to any port ${WS_PORT} proto tcp
```
Na tym etapie NIE blokuj jeszcze publicznego SSH (żeby się nie zablokować).
Włącz UFW:
```bash
ufw enable
ufw status verbose
```
## 3) SSH tylko po WireGuard (dopiero gdy WG działa)
### 3.1) UFW: pozwól na SSH po wg0
```bash
ufw allow in on "${WG_IFACE}" to any port 22 proto tcp
```
### 3.2) sshd: nasłuch tylko na WG IP
Edytuj `sshd_config`:
```bash
sed -n '1,220p' /etc/ssh/sshd_config
```
Dodaj (lub ustaw) na końcu pliku:
```conf
PasswordAuthentication no
PermitRootLogin no
ListenAddress 10.66.66.1
```
Zanim zrestartujesz SSH:
- miej otwartą obecną sesję SSH,
- zestaw WG na kliencie,
- przygotuj drugi terminal do testu po `10.66.66.1`.
Restart:
```bash
systemctl restart ssh
ss -lntp | grep -E ':22\\b' || true
```
Test z klienta po WG:
```bash
ssh <user>@10.66.66.1
```
Jeśli działa: zablokuj publiczny SSH:
```bash
ufw deny 22/tcp
ufw status verbose
```
## 4) Kernel/sysctl baseline pod Agave (na tym etapie bez uruchamiania Agave)
Minimalne wartości, które już mamy w Ansible (i kilka dodatkowych bezpiecznych):
```bash
cat >/etc/sysctl.d/90-agave.conf <<'EOF'
fs.file-max = 2000000
vm.max_map_count = 1000000
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
EOF
sysctl --system
```
Limity `nofile`:
```bash
cat >/etc/security/limits.d/90-solana.conf <<'EOF'
* soft nofile 1000000
* hard nofile 1000000
EOF
```
## 5) Co dalej
- Instalacja i uruchomienie Agave: patrz `doc/etap-007-manual-baremetal-rpc-commands.md`.
- Narzedzia do generowania konfiguracji WireGuard (server/client) + reset wg0: patrz `doc/step-001-wireguard-tools.md`.
- Docelowo: bind RPC/WS do WG IP (zamiast `0.0.0.0`) i spójne, minimalne reguły firewall.

View File

@@ -1,67 +0,0 @@
# Workflow `trade-iac`
Cel: utrzymywać konfigurację IaC w Git i wdrażać ją kontrolowanie na `mevnode`.
## 1. Dodanie klucza SSH na hoście (operator)
Host operatora musi mieć klucz do dostępu do repo `trade/trade-iac`.
Przykład:
```bash
ssh-keygen -t ed25519 -C "trade-iac-host" -f ~/.ssh/trade_iac
cat ~/.ssh/trade_iac.pub
```
Publiczny klucz dodajemy w Gitei (`Settings -> SSH Keys`), a potem test:
```bash
ssh -T git@gitea.mpabi.pl
```
## 2. Dodanie klucza SSH na VPS
VPS (runner/deployer) musi mieć osobny klucz do klonowania/pull z `trade/trade-iac`.
Przykład:
```bash
ssh-keygen -t ed25519 -C "trade-iac-vps" -f ~/.ssh/trade_iac
cat ~/.ssh/trade_iac.pub
ssh -T git@gitea.mpabi.pl
```
Ten publiczny klucz też dodajemy do Gitei.
## 3. Wdrożenie na `mevnode` (runner na VPS)
Po zmianach w repo:
1. `doc/` (opis etapu),
2. implementacja (playbook/vars),
3. test/syntax-check,
4. commit + push,
5. wdrożenie z VPS na `mevnode`,
6. testy powdrożeniowe.
Minimalny przepływ:
```bash
# VPS
git -C /opt/trade-iac pull --ff-only origin main
cd /opt/trade-iac
# uruchom Ansible w kontenerze (bez instalacji Ansible na hoście)
docker run --rm -t \
-v "$PWD/ansible:/ansible" \
-v "$HOME/.ssh:/home/runner/.ssh:ro" \
-w /ansible \
quay.io/ansible/ansible-runner:latest \
ansible-playbook -i inventory/hosts.ini playbooks/doc-rpc-sol-min.yml
```
## Zasada pracy
- Zmiany zawsze przez PR/commit (bez ręcznych zmian na `mevnode` poza awarią).
- `mevnode` traktujemy jako target deploymentu, nie źródło prawdy.
- Źródłem prawdy jest repo `trade/trade-iac`.

93
scripts/wireguard/wg_client.py Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from datetime import datetime
WG_DIR = Path("/etc/wireguard")
WG0 = WG_DIR / "wg0.conf"
def require_root():
if os.geteuid() != 0:
print("ERROR: run as root (sudo).", file=sys.stderr)
sys.exit(1)
def run(cmd, check=True, capture=False):
return subprocess.run(
cmd,
check=check,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
def backup_if_exists(p: Path):
if p.exists():
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
b = p.with_name(p.name + f".bak.{ts}")
shutil.copy2(p, b)
def cmd_install(src: str):
srcp = Path(src)
if not srcp.exists():
raise RuntimeError(f"Missing source config: {srcp}")
WG_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(WG_DIR, 0o700)
backup_if_exists(WG0)
shutil.copy2(srcp, WG0)
os.chmod(WG0, 0o600)
os.chown(WG0, 0, 0)
print(f"OK: installed {WG0}")
def cmd_up():
run(["wg-quick", "up", "wg0"], check=True)
def cmd_down():
run(["wg-quick", "down", "wg0"], check=False)
def cmd_status():
run(["wg", "show"], check=False)
run(["ip", "a", "show", "wg0"], check=False)
def main():
require_root()
p = argparse.ArgumentParser(prog="wg_client.py")
sub = p.add_subparsers(dest="cmd", required=True)
i = sub.add_parser("install")
i.add_argument("config", help="Path to clientX.conf from server export")
sub.add_parser("up")
sub.add_parser("down")
sub.add_parser("status")
args = p.parse_args()
try:
if args.cmd == "install":
cmd_install(args.config)
elif args.cmd == "up":
cmd_up()
elif args.cmd == "down":
cmd_down()
elif args.cmd == "status":
cmd_status()
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

419
scripts/wireguard/wg_serverctl.py Executable file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional, List
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{43}=$")
@dataclass
class Cfg:
wg_dir: Path = Path("/etc/wireguard")
clients_dir: Path = Path("/etc/wireguard/clients")
server_key: Path = Path("/etc/wireguard/server.key")
server_pub: Path = Path("/etc/wireguard/server.pub")
wg0_conf: Path = Path("/etc/wireguard/wg0.conf")
wg_addr: str = "10.66.66.1/24"
wg_port: int = 51820
keepalive: int = 25
client_net_prefix: str = "10.66.66."
client_ip_start: int = 2
client_ip_end: int = 254
def require_root():
if os.geteuid() != 0:
print("ERROR: run as root (sudo).", file=sys.stderr)
sys.exit(1)
def run(
cmd: List[str],
capture: bool = False,
check: bool = True,
input_text: Optional[str] = None,
) -> subprocess.CompletedProcess:
return subprocess.run(
cmd,
text=True,
input=input_text,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
check=check,
)
def ensure_dirs(cfg: Cfg):
cfg.wg_dir.mkdir(parents=True, exist_ok=True)
cfg.clients_dir.mkdir(parents=True, exist_ok=True)
os.chmod(cfg.wg_dir, 0o700)
os.chmod(cfg.clients_dir, 0o700)
def read_first_nonempty_line(p: Path) -> str:
if not p.exists():
return ""
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip().replace("\r", "")
if line:
return line
return ""
def validate_key(k: str, what: str):
if not WG_KEY_RE.match(k):
raise ValueError(f"{what} has invalid WireGuard key format: {k!r}")
def chmod600_root(p: Path):
os.chmod(p, 0o600)
os.chown(p, 0, 0)
def backup_if_exists(p: Path):
if p.exists():
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
b = p.with_name(p.name + f".bak.{ts}")
shutil.copy2(p, b)
def gen_priv_key_to(path: Path):
priv = run(["wg", "genkey"], capture=True).stdout.strip()
validate_key(priv, "generated private key")
path.write_text(priv + "\n", encoding="utf-8")
chmod600_root(path)
def pubkey_from_priv(priv: str) -> str:
validate_key(priv, "private key")
pub = run(["wg", "pubkey"], capture=True, input_text=priv + "\n").stdout.strip()
validate_key(pub, "generated public key")
return pub
def detect_public_ip() -> str:
# Works on Ubuntu/Debian in typical setups
out = run(
[
"bash",
"-lc",
"ip route get 1.1.1.1 | awk '{for (i=1;i<=NF;i++) if ($i==\"src\") {print $(i+1); exit}}'",
],
capture=True,
).stdout.strip()
if not out:
raise RuntimeError("Could not detect server public IP.")
return out
def restart_wg(unit: str = "wg-quick@wg0"):
run(["systemctl", "restart", unit], capture=False, check=True)
# optional status (non-fatal)
try:
run(["systemctl", "--no-pager", "-l", "status", unit], capture=False, check=False)
except Exception:
pass
def list_clients(cfg: Cfg) -> List[str]:
pubs = sorted(cfg.clients_dir.glob("*.pub"))
return [p.stem for p in pubs]
def used_octets(cfg: Cfg) -> set:
s = set()
for f in cfg.clients_dir.glob("*.ip"):
v = read_first_nonempty_line(f)
if v.isdigit():
s.add(int(v))
return s
def alloc_octet(cfg: Cfg) -> int:
used = used_octets(cfg)
for i in range(cfg.client_ip_start, cfg.client_ip_end + 1):
if i not in used:
return i
raise RuntimeError("No free IPs left in pool.")
def gen_server(cfg: Cfg, force: bool):
ensure_dirs(cfg)
if not force and (cfg.server_key.exists() or cfg.server_pub.exists()):
raise RuntimeError("Server key already exists. Use --force to overwrite.")
gen_priv_key_to(cfg.server_key)
priv = read_first_nonempty_line(cfg.server_key)
pub = pubkey_from_priv(priv)
cfg.server_pub.write_text(pub + "\n", encoding="utf-8")
chmod600_root(cfg.server_pub)
print("OK: generated server keys:")
print(f" - {cfg.server_key} (PRIVATE)")
print(f" - {cfg.server_pub} (PUBLIC)")
print("Server public key:")
print(pub)
def gen_client(cfg: Cfg, name: str, force: bool):
ensure_dirs(cfg)
keyf = cfg.clients_dir / f"{name}.key"
pubf = cfg.clients_dir / f"{name}.pub"
if not force and (keyf.exists() or pubf.exists()):
raise RuntimeError(f"Client {name} exists. Use --force to overwrite.")
gen_priv_key_to(keyf)
priv = read_first_nonempty_line(keyf)
pub = pubkey_from_priv(priv)
pubf.write_text(pub + "\n", encoding="utf-8")
chmod600_root(pubf)
print("OK: generated client keys:")
print(f" - {keyf} (PRIVATE)")
print(f" - {pubf} (PUBLIC)")
print("Client public key:")
print(pub)
def generate_wg0_conf(cfg: Cfg):
ensure_dirs(cfg)
if not cfg.server_key.exists():
raise RuntimeError("Missing server.key. Run gen-server first.")
server_priv = read_first_nonempty_line(cfg.server_key)
validate_key(server_priv, "server private key")
backup_if_exists(cfg.wg0_conf)
lines: List[str] = []
lines += [
"[Interface]",
f"Address = {cfg.wg_addr}",
f"ListenPort = {cfg.wg_port}",
f"PrivateKey = {server_priv}",
]
for name in sorted(list_clients(cfg)):
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
if not ipf.exists():
raise RuntimeError(f"Client {name} has no .ip file (run add {name}).")
pub = read_first_nonempty_line(pubf)
validate_key(pub, f"client {name} pubkey")
octet = read_first_nonempty_line(ipf)
if not octet.isdigit():
raise RuntimeError(f"Invalid ip octet in {ipf}: {octet!r}")
o = int(octet)
if o < 2 or o > 254:
raise RuntimeError(f"Invalid ip octet in {ipf}: {o}")
lines += [
"",
"[Peer]",
f"# {name}",
f"PublicKey = {pub}",
f"AllowedIPs = {cfg.client_net_prefix}{o}/32",
f"PersistentKeepalive = {cfg.keepalive}",
]
cfg.wg0_conf.write_text("\n".join(lines) + "\n", encoding="utf-8")
chmod600_root(cfg.wg0_conf)
print(f"OK: generated {cfg.wg0_conf}")
def add_client(cfg: Cfg, name: str, pubkey: Optional[str], ip: Optional[str], no_restart: bool):
ensure_dirs(cfg)
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
# resolve pubkey
if pubkey:
pubkey = pubkey.strip()
validate_key(pubkey, "provided client public key")
pubf.write_text(pubkey + "\n", encoding="utf-8")
chmod600_root(pubf)
else:
if not pubf.exists():
raise RuntimeError(f"Missing {pubf}. Run gen-client {name} or provide --pubkey.")
pubkey = read_first_nonempty_line(pubf)
validate_key(pubkey, f"client {name} pubkey")
# resolve ip octet
if ip is None:
octet = alloc_octet(cfg)
else:
ip = ip.strip()
if ip.startswith(cfg.client_net_prefix):
ip = ip[len(cfg.client_net_prefix) :]
if not ip.isdigit():
raise RuntimeError("--ip must be an octet (2..254) or full IP like 10.66.66.X")
octet = int(ip)
if octet < 2 or octet > 254:
raise RuntimeError("--ip must be 2..254")
if octet in used_octets(cfg):
raise RuntimeError(f"IP octet {octet} already used")
ipf.write_text(f"{octet}\n", encoding="utf-8")
chmod600_root(ipf)
print(f"OK: added peer {name} -> {cfg.client_net_prefix}{octet}/32")
generate_wg0_conf(cfg)
if not no_restart:
restart_wg()
def del_client(cfg: Cfg, name: str, no_restart: bool):
ensure_dirs(cfg)
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
# keep .key on purpose
if pubf.exists():
pubf.unlink()
if ipf.exists():
ipf.unlink()
print(f"OK: removed peer {name} (pub/ip). Private key kept if existed.")
generate_wg0_conf(cfg)
if not no_restart:
restart_wg()
def export_client_conf(cfg: Cfg, name: str, endpoint: Optional[str]):
ensure_dirs(cfg)
keyf = cfg.clients_dir / f"{name}.key"
ipf = cfg.clients_dir / f"{name}.ip"
if not keyf.exists():
raise RuntimeError(f"Missing {keyf}. Run gen-client {name} on server or create key on client.")
if not ipf.exists():
raise RuntimeError(f"Missing {ipf}. Run add {name} first.")
if not cfg.server_pub.exists():
raise RuntimeError("Missing server.pub. Run gen-server first.")
client_priv = read_first_nonempty_line(keyf)
validate_key(client_priv, f"client {name} private key")
server_pub = read_first_nonempty_line(cfg.server_pub)
validate_key(server_pub, "server public key")
octet = int(read_first_nonempty_line(ipf))
if endpoint is None:
endpoint = f"{detect_public_ip()}:{cfg.wg_port}"
out = cfg.clients_dir / f"{name}.conf"
content = "\n".join(
[
"[Interface]",
f"Address = {cfg.client_net_prefix}{octet}/32",
f"PrivateKey = {client_priv}",
"",
"[Peer]",
f"PublicKey = {server_pub}",
f"Endpoint = {endpoint}",
# Default: only reach the server's WG IP via tunnel.
"AllowedIPs = 10.66.66.1/32",
f"PersistentKeepalive = {cfg.keepalive}",
"",
]
)
out.write_text(content, encoding="utf-8")
chmod600_root(out)
print(f"OK: wrote {out}")
def cmd_list(cfg: Cfg):
ensure_dirs(cfg)
names = list_clients(cfg)
if not names:
print("(no clients)")
return
for n in names:
ipf = cfg.clients_dir / f"{n}.ip"
octet = read_first_nonempty_line(ipf) if ipf.exists() else "?"
print(f"{n}\t{cfg.client_net_prefix}{octet}/32")
def cmd_show(cfg: Cfg, name: str):
ensure_dirs(cfg)
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
keyf = cfg.clients_dir / f"{name}.key"
print(f"Name: {name}")
if ipf.exists():
print(f"IP: {cfg.client_net_prefix}{read_first_nonempty_line(ipf)}/32")
else:
print("IP: (not assigned)")
if pubf.exists():
print(f"PUB: {read_first_nonempty_line(pubf)}")
else:
print("PUB: (missing)")
print(f"KEY: {'exists' if keyf.exists() else 'missing'} ({keyf})")
def main():
require_root()
cfg = Cfg()
p = argparse.ArgumentParser(prog="wg_serverctl.py")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("gen-server")
s.add_argument("--force", action="store_true")
g = sub.add_parser("gen-client")
g.add_argument("name")
g.add_argument("--force", action="store_true")
a = sub.add_parser("add")
a.add_argument("name")
a.add_argument("--pubkey", help="Client public key (base64, ends with =)")
a.add_argument("--ip", help="IP octet (2..254) or full 10.66.66.X")
a.add_argument("--no-restart", action="store_true")
d = sub.add_parser("del")
d.add_argument("name")
d.add_argument("--no-restart", action="store_true")
ap = sub.add_parser("apply")
ap.add_argument("--no-restart", action="store_true")
sub.add_parser("list")
sh = sub.add_parser("show")
sh.add_argument("name")
ex = sub.add_parser("export")
ex.add_argument("name")
ex.add_argument("--endpoint", help="Override endpoint ip:port (default: auto-detect public ip)")
args = p.parse_args()
try:
if args.cmd == "gen-server":
gen_server(cfg, args.force)
elif args.cmd == "gen-client":
gen_client(cfg, args.name, args.force)
elif args.cmd == "add":
add_client(cfg, args.name, args.pubkey, args.ip, args.no_restart)
elif args.cmd == "del":
del_client(cfg, args.name, args.no_restart)
elif args.cmd == "apply":
generate_wg0_conf(cfg)
if not args.no_restart:
restart_wg()
elif args.cmd == "list":
cmd_list(cfg)
elif args.cmd == "show":
cmd_show(cfg, args.name)
elif args.cmd == "export":
export_client_conf(cfg, args.name, args.endpoint)
else:
p.print_help()
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()