Fény az alagút végén

A gépünkön futó alkalmazások megosztása az Interneten SSH segítségével

Előfordul néha, hogy szeretnénk hozzáférést biztosítani valakinek a gépünkön futó dolgokhoz. Mondjuk épp egy alkalmazást fejlesztünk és valakivel szeretnénk megosztani az aktuális állapotot anélkül, hogy minden apró változtatást push-olni kelljen, aztán megvárni a build-et, hogy kikerüljön az egész a staging környezetre.

A hálózati adottságoktól függően ez akár egy elég bonyolult művelet is lehet (tűzfalak, port forwarding, NAT traversal). Talán éppen ezért kész szolgáltatások is vannak már ennek a problémának a megoldására (nem is kevés, ami azt illeti), de nem lenne túl érdekes ez a bejegyzés, ha ilyen irányba indulnánk el.

Inkább azt fogjuk megkísérelni, hogy olyan, háztartásokban gyakran előforduló eszközöket keresünk, amivel meg tudjuk oldani ezt a problémát. Ilyen eszköz például az SSH kliens, ami szinte mindenkinek van otthon a fiók mélyén (főleg amióta már a Windows-nak is a része egy ideje). Ezen kívül szükségünk lesz még egy jól kivajazott tepsire publikusan elérhető szerverre (mondjuk egy olcsó VPS valamelyik szolgáltatónál) és kezdődhet is a buli.

Egyszerű mód

Van egy alkalmazásunk a helyi gépen, ami 127.0.0.1:8080-on várja a bejövő kapcsolatokat. Ezt egy egyszerű Python HTTP szerver fogja most szimulálni:

local:~$ mkdir fake-app
local:~$ cd fake-app
local:~/fake-app$ echo 'hello world' >index.html
local:~/fake-app$ python -m http.server -b 127.0.0.1 8080
Serving HTTP on 127.0.0.1 port 8080 (http://127.0.0.1:8080/) ...

Egy másik terminálban ki is próbálhatjuk, hogy működik-e:

local:~$ curl 127.0.0.1:8080
hello world

Van még ezen kívül egy VPS-ünk, aminek a tunnel.example.org nevet adtuk. Ezzel a felállással kiadhatjuk a következő parancsot a helyi gépünkön:

local:~$ ssh -R 8080:127.0.0.1:8080 user@tunnel.example.org

Ez annyit csinál, hogy a tunnel gépen a 8080-as porton a helyi gépünk 8080-as portján futó alkalmazást fogjuk elérni (SSH remote port forwarding). Ki is próbálhatjuk a tunnel gépen:

tunnel:~$ curl 127.0.0.1:8080
hello world

Az SSH (valószínűleg biztonsági okokból) 127.0.0.1-re fogja a 8080-on hallgatózó szerverét bind-olni, úgyhogy kívülről nem fogjuk tudni elérni az alkalmazást, akkor sem ha a tűzfal szabályaink egyébként megengednék.

local:~$ curl tunnel.example.org:8080
curl: (7) Failed to connect to tunnel.example.org port 8080 after 30 ms: Couldn't connect to server

Ezt kiküszöbölhetjük azzal, hogy a /etc/ssh/sshd_config fájlban a GatewayPorts értékét megváltoztatjuk clientspecified-ra és kicsit módosítunk az ssh parancsunkon:

local:~$ ssh -R 0.0.0.0:8080:127.0.0.1:8080 user@tunnel.example.org

Így már működik a dolog, ha a tűzfal beállításaink is rendben vannak:

local:~$ curl tunnel.example.org:8080
hello world

Saját használatra megfelelő megoldás lehet, de egy hajszállal elegánsabb, ha maradunk az első változatnál és elindítunk mellé egy nginx-et a tunnel gépen, ami továbbítja a kéréseket a 127.0.0.1:8080-ra:

/etc/nginx/sites-available/tunnel
server {
    listen 80;
    server_name tunnel.example.org;

    location / {
        proxy_pass http://127.0.0.1:8080/;
    }
}

Az oldal engedélyezése és az nginx konfiguráció újratöltése:

tunnel:~# ln -s /etc/nginx/sites-available/tunnel /etc/nginx/sites-enabled/
tunnel:~# systemctl reload nginx

És kész is vagyunk:

local:~$ curl tunnel.example.org
hello world

Talán túlzásnak tűnhet az nginx, de ha már itt van, akkor egyéb dolgokra is felhasználhatjuk:

  • egyedi hiba oldal, ha a helyi alkalmazás épp nem fut, vagy nem vagyunk ssh-val felcsatlakozva
  • logolás
  • HTTPS az nginx és a külső kliensek között
  • mTLS a helyi alkalmazás és az nginx között (valószínűleg ehhez a helyi gépen is kelleni fog egy nginx)
  • basic autentikáció a külső klienseknek

El is készült az egyszerű megoldásunk, egy nem túl bonyolult SSH parancs segítségével megoszthatjuk másokkal a helyi alkalmazásunkat. Egyetlen hátránya, hogy ezt csak egy ember tudja használni egy alkalmazás megosztására egy fix címen. Valószínűleg az esetleg 99%-ában ez is elég, de azért nézzünk meg egy kicsit bonyolultabb rendszert is a móka kedvéért.

Haladó mód

Az SSH remote port forwarding tud olyat, hogy ha portnak nullát adunk meg, akkor egy random porton fog figyelni a tunnel gépen:

local:~$ ssh -R 0:127.0.0.1:8080 user@tunnel.example.org
Allocated port 41025 for remote forward to 127.0.0.1:8080

[...]

Szóval csinálhatnánk valami olyasmit, hogy felcsatlakozásnál generálunk egy random host-ot (<random>.tunnel.example.org) és az erre a random host-ra érkező kéréseket az nginx a megfelelő portra irányítaná tovább. Ezzel megoldódna az egy ember/egy alkalmazás probléma.

Amennyire tudom, az nginx nem annyira jeleskedik a dinamikus konfigurációk terén. Generálhatnánk fájlokat és reload-olhatnánk az nginx-et, de ez a megoldás nem nyerte el a tetszésemet. Aztán eszembe jutott a Traefik, hogy az kellemesen dinamikus, van is egy provider benne, ami Redis kulcs-értékek alapján tudja beállítani a dolgokat, úgyhogy elindultam ebbe az irányba.

A Traefik telepítése nem túl barátságos, ha nem akar az ember Docker-t használni. A Docker (Swarm) viszont nem túl barátságos a host gépen 127.0.0.1-en figyelő szolgáltatások elérésében, úgyhogy ezzel még mindig jobban járunk.

Szóval nincs más hátra, mint letölteni a binárist, amit aztán valahogy megfuttatunk.

tunnel:~# mkdir -p /opt/traefik
tunnel:~# cd /opt/traefik
tunnel:/opt/traefik# wget https://github.com/traefik/traefik/releases/download/v2.10.5/traefik_v2.10.5_linux_amd64.tar.gz
tunnel:/opt/traefik# tar -xf traefik_v2.10.5_linux_amd64.tar.gz
tunnel:/opt/traefik# rm traefik_v2.10.5_linux_amd64.tar.gz

Első gondolatom az volt, hogy csak úgy jó igénytelen módon tolok neki egy ./traefik --providers.redis.endpoints=127.0.0.1:6379 --entrypoints.web.address=:80 & parancsot, had fusson a háttérben, az is bőven elég ahhoz, hogy kipróbáljam a dolgokat, de végül csak összeraktam hozzá egy systemd service fájlt.

/etc/systemd/system/traefik.service
[Unit]
Description=traefik
After=network-online.target
Wants=network-online.target systemd-networkd-wait-online.service

[Service]
Restart=on-abnormal
User=traefik
Group=traefik
ExecStart=/opt/traefik/traefik --providers.redis.endpoints=127.0.0.1:6379 --entrypoints.web.address=:80
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true

Hogy működjön, szükségünk lesz egy traefik felhasználóra és csoportra, valamint egy Redis szerverre is.

tunnel:~# adduser --disabled-login --disabled-password --no-create-home traefik
tunnel:~# apt-get install redis-server

Aztán már csak a systemd dolgait kell újratölteni.

tunnel:~# systemctl daemon-reload
tunnel:~# systemctl start traefik.service

Első körben az eredeti működést szerettem volna reprodukálni, mielőtt még belekezdenék a dinamikus dolgok kitalálásába, úgyhogy a következő kulcs-érték párokat pakoltam be a Redis-be:

tunnel:~# redis-cli
127.0.0.1:6379> SET traefik/http/services/tunnel-service/loadbalancer/servers/0/url http://127.0.0.1:8080/
127.0.0.1:6379> SET traefik/http/routers/tunnel-router/rule Host(`tunnel.example.org`)
127.0.0.1:6379> SET traefik/http/routers/tunnel-router/entrypoints/0 web
127.0.0.1:6379> SET traefik/http/routers/tunnel-router/service tunnel-service

Van egy service-ünk, ami a 127.0.0.1:8080-on figyel és egy router szabály, ami a tunnel.example.org-ra, 80-as porton (web entrypoint, amit a Traefik indításánál definiáltunk) érkező kéréseket a service-ünk felé irányítja. Szerencsére ez ugyanolyan jól működött, mint az eredeti nginx-es megoldás, úgyhogy jöhet a dinamizálás.

Az ötletem azon alapult, hogy az authorized_keys fájlban meg lehet adni egy saját parancsot, ami SSH csatlakozáskor lefut (így működik például a Git pull/push is SSH-n keresztül). Itt megadhatnánk egy kis shell scriptet, ami felvenné a megfelelő kulcs-érték párokat Redis-be, aztán pedig csak várna, amíg a felhasználó le nem zárja a kapcsolatot. Bezárásnál pedig feltakarítaná a létrehozott Redis kulcsokat.

Ehhez érdemes lehet egy külön felhasználót felvenni, hogy továbbra is tudjuk használni hagyományos módon is az SSH-t az eredeti felhasználónkkal:

tunnel:~# adduser --disabled-password mole

Az authorized_keys fájlba pedig felveszünk egy ilyen sort az SSH kulcsunkkal:

/home/mole/.ssh/authorized_keys
command="/home/mole/tunnel.sh",no-X11-forwarding,no-agent-forwarding <SSH kulcs>

A parancsnak valami ilyesmi felépítése lenne:

/home/mole/tunnel.sh
#!/bin/bash -e

setup() {
    # setup
}

cleanup() {
    # cleanup
    exit 0
}

trap 'cleanup' INT

setup
tail -f /dev/null

Egy fontos mozzanat itt a trap 'cleanup' INT, amivel elkapjuk a Ctrl+C-vel való bezárást, hogy lefuttathassuk a takarítást. A tail -f /dev/null pedig tulajdonképpen nem csinál semmit, csak vár az idők végezetéig.

Természetesen futtathatóvá is kell tennünk ezt a fájlt:

tunnel:~# chmod +x /home/mole/tunnel.sh

Már csak meg kellene találnunk azokat a portokat, amiket az aktuális SSH kapcsolat nyitott. Ehhez szükségünk lesz az sshd process ID-jára, ami pont a futó scriptünk szülője, úgyhogy a következő paranccsal meg is kapjuk:

tunnel:~$ grep PPid /proc/$$/status | awk '{ print $2 }'
123263

Bash-ben a $$ az aktuális process ID-ja, a /proc könyvtárban pedig sok érdekes dolgot találhatunk, ha már tudjuk a process ID-t.

Megvan a szülő process ID, már csak a socketekről kellene információt találni. Az lsof remek eszköz erre, az egyetlen baj vele, hogy csak root-ként adja vissza azt az információt, amire nekünk szükségünk van.

A próba kedvéért felvettem a sudoers beállításai közé a mole felhasználót, hogy tudjon root-ként lsof-ot futtatni, de nem tudom, hogy ezt biztonsági szempontból teljesen rendben van-e (például van-e az lsof-nak valamilyen kevésbé ismert kapcsolója, amivel ki lehetne belőle csalni egy root shell-t).

/etc/sudoers.d/10-mole-lsof
mole ALL=(root) NOPASSWD: /usr/bin/lsof

Így már meg tudjuk szerezni a szükséges információt:

tunnel:~$ sudo lsof -a -nPi4 -sTCP:LISTEN -p 123263
COMMAND    PID USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
sshd    123263 mole    9u  IPv4 1569356      0t0  TCP 127.0.0.1:45991 (LISTEN)
sshd    123263 mole   11u  IPv4 1569360      0t0  TCP 127.0.0.1:39421 (LISTEN)

Jó sok kapcsolója van, a -a azt jelzi, hogy a szűrők között AND kapcsolatot szeretnénk, a -n mondja meg, hogy ne csináljon ip címekből hostneveket, a -P azt, hogy ne csináljon port számokból port neveket, a -i szűr az IPv4 kapcsolatokra, a -s a TCP-n figyelő szerverekre szűr, a -p-vel pedig a process id-t adjuk meg. Egy kis awk mágiával gyorsan megvannak ebből már az ip:port párok:

tunnel:~$ sudo lsof -nPi4 -sTCP:LISTEN -p 123263 -a | awk '/127.0.0.1:/ { print $9 }'
127.0.0.1:45991
127.0.0.1:39421

Ezzel megvan minden szükséges részlet ahhoz, hogy össze tudjuk rakni a scriptünket:

/home/mole/tunnel.sh
#!/bin/bash -e
PID=$(grep PPid /proc/$$/status | awk '{ print $2 }')

declare -A mapping
for app in $(sudo lsof -a -nPi4 -sTCP:LISTEN -p $PID | awk '/127.0.0.1:/ { print $9 }'); do
  mapping[$(pwgen -A0sBv 10 1)]="$app"
done

setup() {
    for key in "${!mapping[@]}"; do
        redis-cli <<EOF >/dev/null
MULTI
SET traefik/http/services/${key}-service/loadbalancer/servers/0/url http://${mapping[$key]}/
SET traefik/http/routers/${key}-router/rule Host(\`${key}.tunnel.example.org\`)
SET traefik/http/routers/${key}-router/entrypoints/0 web
SET traefik/http/routers/${key}-router/service ${key}-service
EXEC
EOF
        echo "http://${key}.tunnel.example.org/ -> ${mapping[$key]}"
    done
}

cleanup() {
    for key in "${!mapping[@]}"; do
        redis-cli <<EOF >/dev/null
MULTI
DEL traefik/http/routers/${key}-router/rule
DEL traefik/http/routers/${key}-router/entrypoints/0
DEL traefik/http/routers/${key}-router/service
DEL traefik/http/services/${key}-service/loadbalancer/servers/0/url
EXEC
EOF
    done
    exit 0
}

trap 'cleanup' INT

setup
tail -f /dev/null

Nincs más hátra, mint kipróbálni:

local:~$ ssh -R 0:127.0.0.1:8080 -R 0:127.0.0.1:8081 mole@tunnel.example.org
Allocated port 34021 for remote forward to 127.0.0.1:8080
Allocated port 39097 for remote forward to 127.0.0.1:8081
http://dkchdfskxz.tunnel.example.org/ -> 127.0.0.1:34021
http://kzhrwsmgqk.tunnel.example.org/ -> 127.0.0.1:39097

És egy másik terminálban a HTTP kérést is:

local:~$ curl http://dkchdfskxz.tunnel.example.org/
hello world

Mint az látszik, mi a tunnel oldalán már csak azt tudjuk megmondani, hogy mi az a random port, amit az sshd kiosztott nekünk, azt nem tudjuk, hogy a local gépen ez milyen portnak felel meg. Szerencsére az SSH kliens kiírja, úgyhogy össze lehet rakni a teljes láncolatot, de több remote forward esetén egy kicsit kényelmetlen lehet.

Természetesen itt is rengeteg lehetőség van még a továbbfejlesztésre, mint például:

  • meggyőződni róla, hogy a pwgen által generált random még nem létezik a Redis-ben
  • periodikus takarító script, ami a véletlenül beragadt Redis kulcsokat törli
  • HTTPS, mTLS, autentikáció

Összegzés

Mint általában, valószínűleg ebben az esetben sem érdemes saját megoldást építeni a nulláról egy napi szinten, több ember által használt rendszer esetén, ha ennyi kész megoldás áll a rendelkezésünkre. Az viszont sosem árt, ha tudjuk hogyan működhet egy ilyen rendszer a motorháztető alatt.

Érdemes lehet az egyszerű mód létezését észben tartani, akár még hasznosnak bizonyulhat valamikor a jövőben. Az SSH egy fantasztikus dolog.

This post is also available in english: Light at the end of the tunnel

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha kedveled a régi jó dolgokat.