Fény az alagút végén
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.