diff --git a/oci/catalog/apps/tailscale.json b/oci/catalog/apps/tailscale.json index f4306a7d..bbaf07a5 100644 --- a/oci/catalog/apps/tailscale.json +++ b/oci/catalog/apps/tailscale.json @@ -137,7 +137,7 @@ "NET_ADMIN", "NET_RAW" ], - "entrypoint": "/bin/sh -c \"tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 10; tailscale web --listen 0.0.0.0:5252\"" + "entrypoint": "/bin/sh -c 'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n \"s/.*inet \\([0-9.]*\\)\\/.*/\\1/p\" | head -n 1); [ -n \"$lan\" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen \"${lan:-0.0.0.0}:5252\"'" } } ], @@ -243,7 +243,7 @@ }, "installer_profile": { "runtime": { - "compose_entrypoint": "/bin/sh -c \"tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 10; tailscale web --listen 0.0.0.0:5252\"" + "compose_entrypoint": "/bin/sh -c 'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n \"s/.*inet \\([0-9.]*\\)\\/.*/\\1/p\" | head -n 1); [ -n \"$lan\" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen \"${lan:-0.0.0.0}:5252\"'" }, "network": { "compose_mode": "host" diff --git a/oci/catalog/overlays/tailscale.json b/oci/catalog/overlays/tailscale.json index a7f6e8fa..3cde5992 100644 --- a/oci/catalog/overlays/tailscale.json +++ b/oci/catalog/overlays/tailscale.json @@ -8,7 +8,10 @@ "owner_strategy": "mapped-root", "only_when_mount_type": "managed-volume" } - ] + ], + "runtime": { + "compose_entrypoint": "/bin/sh -c 'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n \"s/.*inet \\([0-9.]*\\)\\/.*/\\1/p\" | head -n 1); [ -n \"$lan\" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen \"${lan:-0.0.0.0}:5252\"'" + } } } } diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 8e2c44da..9edd5aed 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -472,7 +472,25 @@ def effective_healthcheck(template): return {'type': 'running', 'timeout_seconds': 90, 'stability_seconds': 20} +# The Tailscale recipe first served its web interface on 0.0.0.0:5252. Once the +# web client of Tailscale is turned on, tailscaled takes that port on its own +# address and the container stopped at every start; subnet routes also need +# forwarding inside the container. A saved recipe with that exact command gets +# the current one. A command the user set in Proxmox is kept by the record. +TAILSCALE_PREVIOUS_ENTRYPOINT = ('/bin/sh -c "tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 10; ' + 'tailscale web --listen 0.0.0.0:5252"') +TAILSCALE_ENTRYPOINT = ('/bin/sh -c \'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n "s/.*inet \\([0-9.]*\\)\\/.*/\\1/p" | head -n 1); [ -n "$lan" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen "${lan:-0.0.0.0}:5252"\'') + + +def current_recipe(template): + runtime = template.get('proxmox', {}).get('installer_profile', {}).get('runtime', {}) + if (template.get('id') == 'image-tailscale' + and runtime.get('compose_entrypoint') == TAILSCALE_PREVIOUS_ENTRYPOINT): + runtime['compose_entrypoint'] = TAILSCALE_ENTRYPOINT + + def with_default_healthcheck(contract): + current_recipe(contract['template']) check = effective_healthcheck(contract['template']) if check and check.get('type') != 'haos': contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check diff --git a/oci/tests/test_tailscale_entrypoint.py b/oci/tests/test_tailscale_entrypoint.py new file mode 100644 index 00000000..d1b0cb9e --- /dev/null +++ b/oci/tests/test_tailscale_entrypoint.py @@ -0,0 +1,80 @@ +"""Tailscale keeps starting once its web client is on: the LAN interface listens +on the address of eth0, not on 0.0.0.0, and subnet routes get forwarding.""" +import copy +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) + +import oci_instance_transaction as transaction # noqa: E402 + + +def recipe(): + return json.loads((ROOT / 'catalog/apps/tailscale.json').read_text()) + + +class RecipeTests(unittest.TestCase): + def test_the_recipe_and_its_overlay_carry_the_current_command(self): + template = recipe() + self.assertEqual(template['proxmox']['installer_profile']['runtime']['compose_entrypoint'], + transaction.TAILSCALE_ENTRYPOINT) + self.assertEqual(template['compose_stack']['services'][0]['compose']['entrypoint'], + transaction.TAILSCALE_ENTRYPOINT) + overlay = json.loads((ROOT / 'catalog/overlays/tailscale.json').read_text()) + self.assertEqual(overlay['proxmox']['installer_profile']['runtime']['compose_entrypoint'], + transaction.TAILSCALE_ENTRYPOINT) + + def test_a_saved_recipe_with_the_previous_command_gets_the_current_one(self): + template = recipe() + template['proxmox']['installer_profile']['runtime']['compose_entrypoint'] = \ + transaction.TAILSCALE_PREVIOUS_ENTRYPOINT + contract = transaction.with_default_healthcheck({'template': template}) + self.assertEqual(contract['template']['proxmox']['installer_profile']['runtime']['compose_entrypoint'], + transaction.TAILSCALE_ENTRYPOINT) + + def test_other_commands_and_applications_are_left_alone(self): + for template in ({'id': 'image-tailscale', 'proxmox': {'installer_profile': {'runtime': { + 'compose_entrypoint': '/bin/sh -c "tailscaled & tailscale web --listen 0.0.0.0:8080"'}}}}, + {'id': 'image-other', 'proxmox': {'installer_profile': {'runtime': { + 'compose_entrypoint': transaction.TAILSCALE_PREVIOUS_ENTRYPOINT}}}}): + before = copy.deepcopy(template) + transaction.current_recipe(template) + self.assertEqual(template, before) + + +class CommandTests(unittest.TestCase): + def run_command(self, address): + with tempfile.TemporaryDirectory() as temp: + stubs = Path(temp) + inet = f' inet {address}/24 brd 192.0.2.255 scope global eth0' if address else '' + for name, body in (('sysctl', 'echo "sysctl $*" >> "$TRACE"'), ('tailscaled', 'echo tailscaled >> "$TRACE"'), + ('sleep', ':'), ('ip', f"printf '%s\\n' '{inet}'"), + ('tailscale', 'echo "tailscale $*" >> "$TRACE"')): + path = stubs / name + path.write_text(f'#!/bin/sh\n{body}\n') + path.chmod(0o755) + trace = stubs / 'trace' + argv = shlex.split(transaction.TAILSCALE_ENTRYPOINT) + subprocess.run(argv, env=dict(os.environ, PATH=f'{stubs}:/usr/bin:/bin', TRACE=str(trace)), + check=True, timeout=30) + return trace.read_text().splitlines() + + def test_the_web_interface_listens_on_the_lan_address(self): + trace = self.run_command('192.0.2.5') + self.assertIn('sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1', trace) + self.assertIn('tailscaled', trace) + self.assertEqual(trace[-1], 'tailscale web --listen 192.0.2.5:5252') + + def test_without_an_address_it_listens_everywhere_as_before(self): + self.assertEqual(self.run_command('')[-1], 'tailscale web --listen 0.0.0.0:5252') + + +if __name__ == '__main__': + unittest.main()