fix(oci): keep Tailscale starting once its web client is on

This commit is contained in:
MacRimi
2026-11-05 20:09:40 +01:00
parent eea782e4d1
commit ecd94b45ec
4 changed files with 104 additions and 3 deletions
+2 -2
View File
@@ -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"
+4 -1
View File
@@ -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\"'"
}
}
}
}
+18
View File
@@ -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
+80
View File
@@ -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()