diff --git a/docs/install.md b/docs/install.md index abd0499..4cae70a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -44,7 +44,9 @@ active afterward, the installer continues with a warning so you can fix reboot p service state before relying on the server. If `smbd` is not active, the installer stops and points you to `systemctl status smbd` and `journalctl -u smbd --no-pager`. -After the installer finishes, open the printed Web UI URL and complete the setup wizard. +After the installer finishes, open one of the printed Web UI URLs and complete the setup wizard. +The installer always tries to print local IPv4 URLs. If Tailscale is already installed and +connected, it also prints Tailscale MagicDNS and Tailscale IP URLs. For step-by-step installation without the automated installer, use the [Manual Installation Guide](manual_install.md). diff --git a/docs/tailscale.md b/docs/tailscale.md new file mode 100644 index 0000000..952ca33 --- /dev/null +++ b/docs/tailscale.md @@ -0,0 +1,31 @@ +# Tailscale + +The Tailscale page shows how this server looks from Tailscale. + +It is a read-only page. SimpleSaferServer does not install Tailscale, log in to a +tailnet, change ACLs, or edit Tailscale settings. + +## What The Page Shows + +- **Connection**: whether the `tailscale` command reports a connected server +- **Host**: the server name reported by Tailscale +- **MagicDNS Names**: Tailscale DNS names such as `server.tailnet.ts.net` +- **Access URLs**: Web UI URLs using MagicDNS names and Tailscale IP addresses +- **Details**: the raw DNS names and Tailscale IP addresses found in + `tailscale status --json` + +The Web UI still listens on port `5000`, so Tailscale URLs use that same port. + +## When Nothing Shows Up + +If the page says Tailscale is not installed, install and configure Tailscale on +the server outside SimpleSaferServer. + +If Tailscale is installed but not connected, log in with the normal Tailscale +tools and then refresh the page. + +## Installer Output + +At the end of installation, the installer still prints local IPv4 Web UI URLs. +When Tailscale is already installed and connected, it also prints Tailscale +MagicDNS and Tailscale IP Web UI URLs. diff --git a/index.html b/index.html index 538ee2f..b0ca208 100644 --- a/index.html +++ b/index.html @@ -165,6 +165,7 @@

Documentati
  • Cloud Backup
  • System Updates
  • Dynamic DNS
  • +
  • Tailscale
  • Network File Sharing
  • Users
  • Alerts
  • diff --git a/install.sh b/install.sh index c454c6d..014f152 100755 --- a/install.sh +++ b/install.sh @@ -570,6 +570,83 @@ configure_samba_discovery_services() { echo -e "${GREEN}✔ Samba service setup complete.${NC}\n" } +print_tailscale_access_urls() { + local python_bin="${SSS_INSTALLER_TEST_PYTHON:-$VENV_DIR/bin/python3}" + local status_json="" + local tailscale_urls="" + local url="" + + if ! command -v tailscale >/dev/null 2>&1; then + return 0 + fi + if [ ! -x "$python_bin" ]; then + return 0 + fi + + # Tailscale status does not include auth keys, and the installer only prints + # browser URLs that help the admin reach this server after setup. + status_json="$(tailscale status --json 2>/dev/null || true)" + if [ -z "$status_json" ]; then + return 0 + fi + + tailscale_urls="$(TAILSCALE_STATUS_JSON="$status_json" "$python_bin" <<'PY' || true +import ipaddress +import json +import os + +WEB_UI_PORT = 5000 + + +def clean_dns_name(value): + return str(value or "").strip().rstrip(".") + + +def append_unique(items, value): + if value and value not in items: + items.append(value) + + +try: + payload = json.loads(os.environ.get("TAILSCALE_STATUS_JSON", "{}")) +except json.JSONDecodeError: + raise SystemExit(0) + +self_node = payload.get("Self") if isinstance(payload.get("Self"), dict) else {} +dns_names = [] +append_unique(dns_names, clean_dns_name(self_node.get("DNSName"))) + +hostname = clean_dns_name(self_node.get("HostName")) +suffix = clean_dns_name(payload.get("MagicDNSSuffix")) +if hostname and suffix: + append_unique(dns_names, f"{hostname}.{suffix}") + +urls = [f"http://{dns_name}:{WEB_UI_PORT}" for dns_name in dns_names] +for raw_ip in self_node.get("TailscaleIPs") or []: + ip = str(raw_ip).strip() + try: + parsed = ipaddress.ip_address(ip) + except ValueError: + continue + host = f"[{ip}]" if parsed.version == 6 else ip + urls.append(f"http://{host}:{WEB_UI_PORT}") + +print("\n".join(urls)) +PY +)" + + if [ -z "$tailscale_urls" ]; then + return 0 + fi + + echo -e "${GREEN}Tailscale:${NC}" + while IFS= read -r url; do + if [ -n "$url" ]; then + echo " $url" + fi + done <<<"$tailscale_urls" +} + # 1. Install system dependencies. Python application dependencies are resolved # by uv into /opt/SimpleSaferServer/.venv so distro Python packages do not # decide the app runtime or dependency versions. @@ -778,6 +855,7 @@ else fi done fi +print_tailscale_access_urls echo echo -e "${GREEN}✔ Installation/update complete!${NC}" diff --git a/simple_safer_server/app_factory.py b/simple_safer_server/app_factory.py index 79a6e9d..2709729 100644 --- a/simple_safer_server/app_factory.py +++ b/simple_safer_server/app_factory.py @@ -26,6 +26,7 @@ from simple_safer_server.routes.smb import smb as smb_routes from simple_safer_server.routes.storage import storage as storage_routes from simple_safer_server.routes.system_updates import system_updates as system_updates_routes +from simple_safer_server.routes.tailscale import tailscale as tailscale_routes from simple_safer_server.routes.tasks import tasks as task_routes from simple_safer_server.routes.users import users as users_routes from simple_safer_server.services.alert_notifications import AlertNotifier @@ -43,6 +44,7 @@ from simple_safer_server.services.storage_service import StorageService from simple_safer_server.services.system_updates import SystemUpdatesManager from simple_safer_server.services.system_utils import SystemUtils +from simple_safer_server.services.tailscale import TailscaleService from simple_safer_server.services.task_service import TaskService from simple_safer_server.services.user_manager import UserManager, admin_required from simple_safer_server.web.api import json_data, json_problem @@ -156,6 +158,7 @@ def create_app() -> Flask: command_adapter=storage_command_adapter, ) drive_health_summary_service = DriveHealthSummaryService() + tailscale_service = TailscaleService(command_runner) app.extensions["simple_safer_server"] = AppServices( runtime=runtime, fake_state=fake_state, @@ -173,6 +176,7 @@ def create_app() -> Flask: server_identity_service=server_identity_service, storage_service=storage_service, drive_health_summary_service=drive_health_summary_service, + tailscale_service=tailscale_service, ) app.register_blueprint(setup) @@ -186,6 +190,7 @@ def create_app() -> Flask: app.register_blueprint(users_routes) app.register_blueprint(storage_routes) app.register_blueprint(drive_health_routes) + app.register_blueprint(tailscale_routes) @app.route("/") def index(): diff --git a/simple_safer_server/routes/tailscale.py b/simple_safer_server/routes/tailscale.py new file mode 100644 index 0000000..d89581d --- /dev/null +++ b/simple_safer_server/routes/tailscale.py @@ -0,0 +1,23 @@ +from typing import Any + +from flask import Blueprint, current_app, render_template, session + +from simple_safer_server.services.user_manager import admin_required + +tailscale = Blueprint("tailscale_routes", __name__) + + +def _get_services() -> Any: + """Return app-level services registered during Flask startup.""" + return current_app.extensions["simple_safer_server"] + + +@tailscale.route("/tailscale") +@admin_required +def tailscale_page(): + summary = _get_services().tailscale_service.get_summary() + return render_template( + "tailscale.html", + username=session.get("username"), + summary=summary, + ) diff --git a/simple_safer_server/services/container.py b/simple_safer_server/services/container.py index 8d400f3..0f7bae5 100644 --- a/simple_safer_server/services/container.py +++ b/simple_safer_server/services/container.py @@ -9,6 +9,7 @@ from simple_safer_server.services.drive_health import DriveHealthSummaryService from simple_safer_server.services.server_identity import ServerIdentityService from simple_safer_server.services.storage_service import StorageService +from simple_safer_server.services.tailscale import TailscaleService from simple_safer_server.services.task_service import TaskService @@ -32,3 +33,4 @@ class AppServices: server_identity_service: ServerIdentityService storage_service: StorageService drive_health_summary_service: DriveHealthSummaryService + tailscale_service: TailscaleService diff --git a/simple_safer_server/services/tailscale.py b/simple_safer_server/services/tailscale.py new file mode 100644 index 0000000..59492bc --- /dev/null +++ b/simple_safer_server/services/tailscale.py @@ -0,0 +1,168 @@ +import json +import shutil +from dataclasses import dataclass +from ipaddress import ip_address +from typing import Any + +from simple_safer_server.adapters.command_runner import CommandRunner, SubprocessError + +WEB_UI_PORT = 5000 + + +@dataclass(frozen=True) +class TailscaleSummary: + """Read-only Tailscale state shown to local administrators.""" + + status: str + status_label: str + message: str + backend_state: str + hostname: str + dns_names: list[str] + ips: list[str] + access_urls: list[str] + + +class TailscaleService: + """Collects Tailscale access details without configuring the tailnet.""" + + def __init__(self, command_runner: CommandRunner) -> None: + self._command_runner = command_runner + + def get_summary(self) -> TailscaleSummary: + if shutil.which("tailscale") is None: + return TailscaleSummary( + status="not_installed", + status_label="Not Installed", + message="Tailscale is not installed on this server.", + backend_state="", + hostname="", + dns_names=[], + ips=[], + access_urls=[], + ) + + try: + result = self._command_runner.run( + ["tailscale", "status", "--json"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, SubprocessError) as exc: + return TailscaleSummary( + status="unavailable", + status_label="Unavailable", + message=f"Could not run tailscale: {exc}", + backend_state="", + hostname="", + dns_names=[], + ips=[], + access_urls=[], + ) + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "tailscale status failed").strip() + return TailscaleSummary( + status="unavailable", + status_label="Unavailable", + message=detail, + backend_state="", + hostname="", + dns_names=[], + ips=[], + access_urls=[], + ) + + try: + payload = json.loads(result.stdout or "{}") + except json.JSONDecodeError: + return TailscaleSummary( + status="unavailable", + status_label="Unavailable", + message="Tailscale returned status data that could not be read.", + backend_state="", + hostname="", + dns_names=[], + ips=[], + access_urls=[], + ) + + return self._summary_from_status(payload) + + def _summary_from_status(self, payload: dict[str, Any]) -> TailscaleSummary: + backend_state = str(payload.get("BackendState") or "") + self_node = payload.get("Self") if isinstance(payload.get("Self"), dict) else {} + hostname = str(self_node.get("HostName") or "").strip() + dns_names = _tailscale_dns_names(payload) + ips = _tailscale_ips(self_node) + access_urls = _access_urls(dns_names, ips) + connected = backend_state.lower() == "running" and bool(dns_names or ips) + + if connected: + return TailscaleSummary( + status="connected", + status_label="Connected", + message="Tailscale is installed and logged in.", + backend_state=backend_state, + hostname=hostname, + dns_names=dns_names, + ips=ips, + access_urls=access_urls, + ) + + return TailscaleSummary( + status="not_connected", + status_label="Not Connected", + message="Tailscale is installed, but this server is not connected to a tailnet.", + backend_state=backend_state, + hostname=hostname, + dns_names=dns_names, + ips=ips, + access_urls=access_urls, + ) + + +def _clean_dns_name(value: Any) -> str: + return str(value or "").strip().rstrip(".") + + +def _append_unique(items: list[str], value: str) -> None: + if value and value not in items: + items.append(value) + + +def _tailscale_dns_names(payload: dict[str, Any]) -> list[str]: + self_node = payload.get("Self") if isinstance(payload.get("Self"), dict) else {} + dns_names: list[str] = [] + _append_unique(dns_names, _clean_dns_name(self_node.get("DNSName"))) + + hostname = _clean_dns_name(self_node.get("HostName")) + suffix = _clean_dns_name(payload.get("MagicDNSSuffix")) + if hostname and suffix: + # Some Tailscale versions include DNSName, others only expose the + # MagicDNS suffix. Build the same FQDN when both pieces are present. + _append_unique(dns_names, f"{hostname}.{suffix}") + + return dns_names + + +def _tailscale_ips(self_node: dict[str, Any]) -> list[str]: + raw_ips = self_node.get("TailscaleIPs") + if not isinstance(raw_ips, list): + return [] + return [str(raw_ip).strip() for raw_ip in raw_ips if str(raw_ip).strip()] + + +def _access_urls(dns_names: list[str], ips: list[str]) -> list[str]: + urls: list[str] = [] + for dns_name in dns_names: + urls.append(f"http://{dns_name}:{WEB_UI_PORT}") + for ip in ips: + try: + parsed = ip_address(ip) + except ValueError: + continue + host = f"[{ip}]" if parsed.version == 6 else ip + urls.append(f"http://{host}:{WEB_UI_PORT}") + return urls diff --git a/templates/base.html b/templates/base.html index e60dc9d..df1b71d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -62,6 +62,9 @@ DDNS + + Tailscale + {% endif %} Cloud Backup diff --git a/templates/tailscale.html b/templates/tailscale.html new file mode 100644 index 0000000..c77a9f6 --- /dev/null +++ b/templates/tailscale.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} + +{% block title %}{{ browser_title('Tailscale') }}{% endblock %} +{% block header %}Tailscale{% endblock %} + +{% block content %} +{% set badge_class = { + 'connected': 'badge-success', + 'not_connected': 'badge-warning', + 'not_installed': 'badge-neutral', + 'unavailable': 'badge-danger', +}.get(summary.status, 'badge-neutral') %} + +
    +
    +
    + Connection + {{ summary.status_label }} +
    +
    {{ summary.backend_state or '—' }}
    +
    {{ summary.message }}
    +
    + +
    +
    + Host +
    +
    {{ summary.hostname or '—' }}
    +
    Name reported by Tailscale for this server.
    +
    + +
    +
    + MagicDNS Names +
    +
    {{ summary.dns_names | length }}
    +
    + {% if summary.dns_names %} + {{ summary.dns_names[0] }} + {% else %} + No Tailscale DNS name found. + {% endif %} +
    +
    +
    + +
    + +
    +

    + These URLs work only from devices that can reach this server through your Tailscale tailnet. + SimpleSaferServer reads this status but does not install, log in, or configure Tailscale. +

    + + {% if summary.access_urls %} +
    + + + + + + + + + {% for dns_name in summary.dns_names %} + + {% set url = 'http://' ~ dns_name ~ ':5000' %} + + + + {% endfor %} + {% for ip in summary.ips %} + + {% set url = 'http://[' ~ ip ~ ']:5000' if ':' in ip else 'http://' ~ ip ~ ':5000' %} + + + + {% endfor %} + +
    URLType
    {{ url }}MagicDNS
    {{ url }}Tailscale IP
    +
    + {% else %} +
    + No Tailscale access URL is available. +

    + Install Tailscale and connect this server to your tailnet, then refresh this page. +

    +
    + {% endif %} +
    +
    + +
    +
    +

    Details

    +
    +
    +
    + + + + + + + + + + + + + + + +
    DNS names{{ summary.dns_names | join(', ') if summary.dns_names else '—' }}
    Tailscale IP addresses{{ summary.ips | join(', ') if summary.ips else '—' }}
    Managed bythe tailscale command line tool and Tailscale system service
    +
    +
    +
    +{% endblock %} diff --git a/tests/test_app_factory_routes.py b/tests/test_app_factory_routes.py index b8b568e..bf0e958 100644 --- a/tests/test_app_factory_routes.py +++ b/tests/test_app_factory_routes.py @@ -176,6 +176,7 @@ def test_browser_titles_use_configured_hostname_after_setup(): dashboard_response = client.get("/dashboard") task_response = client.get("/task/App%20Update") ddns_response = client.get("/ddns") + tailscale_response = client.get("/tailscale") assert dashboard_response.status_code == 200 assert "Overview - family-nas" in dashboard_response.get_data( @@ -185,6 +186,31 @@ def test_browser_titles_use_configured_hostname_after_setup(): assert "App Update - family-nas" in task_response.get_data(as_text=True) assert ddns_response.status_code == 200 assert "DDNS - family-nas" in ddns_response.get_data(as_text=True) + assert tailscale_response.status_code == 200 + assert "Tailscale - family-nas" in tailscale_response.get_data( + as_text=True + ) + finally: + runtime._runtime = previous_runtime + runtime._fake_state = previous_fake_state + + +def test_tailscale_page_renders_read_only_status(): + previous_runtime = runtime._runtime + previous_fake_state = runtime._fake_state + try: + with TemporaryDirectory() as temp_dir: + app = _create_fake_app(temp_dir) + _finish_fake_setup(app) + + with app.test_client() as client: + page_response = client.get("/tailscale") + + assert page_response.status_code == 200 + page = page_response.get_data(as_text=True) + assert "Tailscale" in page + assert "SimpleSaferServer reads this status but does not install" in page + assert "Managed by" in page finally: runtime._runtime = previous_runtime runtime._fake_state = previous_fake_state diff --git a/tests/test_install_preflight.py b/tests/test_install_preflight.py index 7e4906d..b98152b 100644 --- a/tests/test_install_preflight.py +++ b/tests/test_install_preflight.py @@ -1,5 +1,6 @@ import os import subprocess +import sys import tempfile import textwrap import unittest @@ -733,6 +734,36 @@ def test_samba_services_summary_reports_unavailable_when_unit_missing(self): self.assertIn("nmbd: active", result.stdout) self.assertIn("wsdd2: unavailable", result.stdout) + def test_tailscale_access_urls_include_magicdns_and_tailscale_ips(self): + snippet = textwrap.dedent( + f"""\ + set -e + {self.installer_function("print_tailscale_access_urls")} + GREEN=""; NC="" + VENV_DIR="/missing" + SSS_INSTALLER_TEST_PYTHON="{sys.executable}" + tailscale() {{ + if [ "$*" = "status --json" ]; then + printf '%s\\n' '{{"BackendState":"Running","MagicDNSSuffix":"tailnet.ts.net.","Self":{{"HostName":"family-nas","DNSName":"family-nas.tailnet.ts.net.","TailscaleIPs":["100.64.0.8","fd7a:115c:a1e0::8"]}}}}' + fi + }} + print_tailscale_access_urls + """ + ) + + result = subprocess.run( + ["bash", "-lc", snippet], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("Tailscale:", result.stdout) + self.assertIn("http://family-nas.tailnet.ts.net:5000", result.stdout) + self.assertIn("http://100.64.0.8:5000", result.stdout) + self.assertIn("http://[fd7a:115c:a1e0::8]:5000", result.stdout) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tailscale_service.py b/tests/test_tailscale_service.py new file mode 100644 index 0000000..46555a6 --- /dev/null +++ b/tests/test_tailscale_service.py @@ -0,0 +1,103 @@ +import json +import subprocess +from unittest.mock import patch + +from simple_safer_server.services.tailscale import TailscaleService + + +class FakeCommandRunner: + def __init__(self, result): + self.result = result + self.calls = [] + + def run(self, command, **kwargs): + self.calls.append((command, kwargs)) + return self.result + + +class RaisingCommandRunner: + def run(self, command, **kwargs): + raise subprocess.TimeoutExpired(command, timeout=kwargs["timeout"]) + + +def completed_status(payload): + return subprocess.CompletedProcess( + ["tailscale", "status", "--json"], + 0, + stdout=json.dumps(payload), + stderr="", + ) + + +def test_summary_uses_magicdns_name_and_tailscale_ips(): + runner = FakeCommandRunner( + completed_status( + { + "BackendState": "Running", + "MagicDNSSuffix": "tailnet.ts.net.", + "Self": { + "HostName": "family-nas", + "DNSName": "family-nas.tailnet.ts.net.", + "TailscaleIPs": ["100.64.0.8", "fd7a:115c:a1e0::8"], + }, + } + ) + ) + + with patch( + "simple_safer_server.services.tailscale.shutil.which", return_value="/usr/bin/tailscale" + ): + summary = TailscaleService(runner).get_summary() + + assert summary.status == "connected" + assert summary.dns_names == ["family-nas.tailnet.ts.net"] + assert summary.ips == ["100.64.0.8", "fd7a:115c:a1e0::8"] + assert summary.access_urls == [ + "http://family-nas.tailnet.ts.net:5000", + "http://100.64.0.8:5000", + "http://[fd7a:115c:a1e0::8]:5000", + ] + assert runner.calls[0][1]["timeout"] == 5 + + +def test_summary_builds_dns_name_from_magicdns_suffix_when_dnsname_is_missing(): + runner = FakeCommandRunner( + completed_status( + { + "BackendState": "Running", + "MagicDNSSuffix": "tailnet.ts.net", + "Self": { + "HostName": "family-nas", + "TailscaleIPs": ["100.64.0.8"], + }, + } + ) + ) + + with patch( + "simple_safer_server.services.tailscale.shutil.which", return_value="/usr/bin/tailscale" + ): + summary = TailscaleService(runner).get_summary() + + assert summary.dns_names == ["family-nas.tailnet.ts.net"] + assert summary.access_urls[0] == "http://family-nas.tailnet.ts.net:5000" + + +def test_summary_reports_not_installed_without_running_tailscale(): + runner = FakeCommandRunner(completed_status({})) + + with patch("simple_safer_server.services.tailscale.shutil.which", return_value=None): + summary = TailscaleService(runner).get_summary() + + assert summary.status == "not_installed" + assert runner.calls == [] + + +def test_summary_reports_unavailable_when_tailscale_command_times_out(): + with patch( + "simple_safer_server.services.tailscale.shutil.which", return_value="/usr/bin/tailscale" + ): + summary = TailscaleService(RaisingCommandRunner()).get_summary() + + assert summary.status == "unavailable" + assert "timed out" in summary.message