Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
31 changes: 31 additions & 0 deletions docs/tailscale.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ <h2 id="documentation" class="mb-3"><i class="fa-solid fa-book"></i> Documentati
<li><i class="fa-solid fa-cloud-arrow-up"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/cloud_backup.md" target="_blank">Cloud Backup</a></li>
<li><i class="fa-solid fa-download"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/system_updates.md" target="_blank">System Updates</a></li>
<li><i class="fa-solid fa-globe"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/ddns.md" target="_blank">Dynamic DNS</a></li>
<li><i class="fa-solid fa-diagram-project"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/tailscale.md" target="_blank">Tailscale</a></li>
<li><i class="fa-solid fa-network-wired"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/network_file_sharing.md" target="_blank">Network File Sharing</a></li>
<li><i class="fa-solid fa-users"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/users.md" target="_blank">Users</a></li>
<li><i class="fa-solid fa-bell"></i> <a href="https://github.com/chrismin13/SimpleSaferServer/blob/main/docs/alerts.md" target="_blank">Alerts</a></li>
Expand Down
78 changes: 78 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -778,6 +855,7 @@ else
fi
done
fi
print_tailscale_access_urls
echo

echo -e "${GREEN}✔ Installation/update complete!${NC}"
Expand Down
5 changes: 5 additions & 0 deletions simple_safer_server/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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():
Expand Down
23 changes: 23 additions & 0 deletions simple_safer_server/routes/tailscale.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 2 additions & 0 deletions simple_safer_server/services/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -32,3 +33,4 @@ class AppServices:
server_identity_service: ServerIdentityService
storage_service: StorageService
drive_health_summary_service: DriveHealthSummaryService
tailscale_service: TailscaleService
168 changes: 168 additions & 0 deletions simple_safer_server/services/tailscale.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
<a href="{{ url_for('ddns_routes.ddns_page') }}" class="nav-item {% if request.endpoint == 'ddns_routes.ddns_page' %}active{% endif %}">
<i class="fas fa-globe fa-fw"></i> DDNS
</a>
<a href="{{ url_for('tailscale_routes.tailscale_page') }}" class="nav-item {% if request.endpoint == 'tailscale_routes.tailscale_page' %}active{% endif %}">
<i class="fas fa-diagram-project fa-fw"></i> Tailscale
</a>
{% endif %}
<a href="{{ url_for('cloud_backup_routes.cloud_backup_page') }}" class="nav-item {% if request.endpoint == 'cloud_backup_routes.cloud_backup_page' %}active{% endif %}">
<i class="fas fa-cloud-arrow-up fa-fw"></i> Cloud Backup
Expand Down
Loading
Loading