Skip to content
This repository was archived by the owner on May 18, 2026. It is now read-only.
Merged
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
66 changes: 61 additions & 5 deletions api/tailscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,13 @@ def get_status() -> dict[str, Any]:
# trailing dot for display, build an HTTPS URL the UI can show.
dns = (self_node.get("DNSName") or "").rstrip(".")
https_url = f"https://{dns}/" if dns else ""
# FITB#122 #2: include the webui's view of Serve state so the panel
# can render a "Configure HTTPS" affordance steadily — works whether
# the user authed via Connect (up_state populated) or via the desktop
# Tailscale app (up_state stays idle; user clicks the button manually).
with _up_lock:
serve_snap = (_up_state.get("serve_state", "idle"),
_up_state.get("serve_error", ""))
return {
"available": True,
"backend_state": s.get("BackendState") or "Unknown",
Expand All @@ -279,6 +286,8 @@ def get_status() -> dict[str, Any]:
"magic_dns_suffix": s.get("MagicDNSSuffix") or "",
"tailnet_url": https_url,
"peers_count": len(s.get("Peer") or {}),
"serve_state": serve_snap[0],
"serve_error": serve_snap[1],
# Persisted power-user flags (#96 phase 2). UI pre-populates the
# advanced accordion from these and sends edits back via the
# standard /api/settings POST.
Expand All @@ -297,6 +306,14 @@ def get_status() -> dict[str, Any]:
"ended_at": 0.0,
"error": "",
"attempt_id": 0,
# Serve auto-config state, populated after the daemon transitions to
# Running. "ok" = HTTPS bound to localhost:8787; "error" = the CLI
# rejected the call (most often "Serve is not enabled on your tailnet"
# — needs HTTPS toggled in admin console). Surfacing it lets the UI
# show a "Configure HTTPS" retry button instead of silently failing.
# FITB#122 #2.
"serve_state": "idle",
"serve_error": "",
}
# QA fix: previously _up_proc and _up_log were module-level globals
# mutated outside _up_lock, with the daemon thread reading the *current*
Expand Down Expand Up @@ -544,10 +561,7 @@ def _up_subprocess(argv: list[str], env: dict | None, attempt_id: int) -> None:
still_current = (_up_state.get("attempt_id") == attempt_id and
_up_state.get("state") == "running")
if still_current:
try:
configure_serve()
except Exception:
logger.debug("Auto-configure Serve after Running failed", exc_info=True)
_attempt_configure_serve(attempt_id)
else:
# Edge case: rc=0 but daemon not Running (e.g. login-only mode).
# Treat as failed so UI re-prompts.
Expand Down Expand Up @@ -672,6 +686,11 @@ def get_up_progress() -> dict[str, Any]:
# state="running" over freshly-cleared idle, bringing back
# the "Connected" badge after the user explicitly disconnected.
_set_up_state(snap.get("attempt_id"), state="running", ended_at=time.time())
# FITB#122 #2: also drive Serve auto-config from this path —
# users who auth via the desktop Tailscale app (not webui's
# Connect button) hit this branch first, so without it Serve
# would never get configured for them.
_attempt_configure_serve(snap.get("attempt_id"))
with _up_lock:
snap = dict(_up_state)
return {
Expand All @@ -680,6 +699,8 @@ def get_up_progress() -> dict[str, Any]:
"error": snap["error"],
"started_at": snap["started_at"],
"ended_at": snap["ended_at"],
"serve_state": snap.get("serve_state", "idle"),
"serve_error": snap.get("serve_error", ""),
}


Expand Down Expand Up @@ -718,6 +739,8 @@ def logout() -> dict[str, Any]:
"error": "",
"started_at": 0.0,
"ended_at": 0.0,
"serve_state": "idle",
"serve_error": "",
})

# Reset Tailscale Serve config best-effort. `tailscale serve reset`
Expand Down Expand Up @@ -762,6 +785,36 @@ def configure_serve() -> dict[str, Any]:
return {"ok": True}


def _attempt_configure_serve(attempt_id: int | None = None) -> dict[str, Any]:
"""Run configure_serve() and record the outcome on _up_state so the UI
can show success / surface the error / offer a retry. Single channel
that every code path uses (start_up after rc=0, get_up_progress on
mid-poll Running detection, the explicit POST /api/tailscale/serve
retry endpoint) — keeps configure_serve itself pure (CLI only) and
centralises the state-write so it's gated by attempt_id consistently.

The optional attempt_id is forwarded to _set_up_state so a stale daemon
thread can't clobber the active attempt's serve_state, mirroring the
existing guard for state= updates.

Returns the configure_serve() result so callers (e.g. handle_post_serve)
can pass it back over the wire.
"""
try:
result = configure_serve()
except Exception as exc: # pragma: no cover — defensive
logger.debug("configure_serve raised", exc_info=True)
err_msg = f"unexpected: {exc}"
_set_up_state(attempt_id, serve_state="error", serve_error=err_msg)
return {"ok": False, "error": err_msg}
if not result.get("ok"):
_set_up_state(attempt_id, serve_state="error",
serve_error=result.get("error", "tailscale serve failed"))
else:
_set_up_state(attempt_id, serve_state="ok", serve_error="")
return result


def get_serve_status() -> dict[str, Any]:
"""GET /api/tailscale/serve — current `tailscale serve status`."""
rc, out, err = _run_tailscale(["serve", "status", "--json"], timeout=5.0)
Expand Down Expand Up @@ -793,7 +846,10 @@ def handle_post_logout(handler, body: dict) -> dict[str, Any]:


def handle_post_serve(handler, body: dict) -> dict[str, Any]:
return configure_serve()
# Route through _attempt_configure_serve so the manual retry (this
# endpoint) writes the same up_state the auto-config paths do — UI
# polls /api/tailscale/status and renders from there. FITB#122 #2.
return _attempt_configure_serve()


def handle_get_serve(handler) -> dict[str, Any]:
Expand Down
2 changes: 2 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,8 @@ <h2 data-i18n="empty_title">Think less. Start here.</h2>
</div>
</details>
<div id="tsMessage" style="font-size:11px;color:var(--muted);margin-top:8px;min-height:14px"></div>
<!-- Tailscale Serve (HTTPS) state — FITB#122 #2 -->
<div id="tsServeState" style="font-size:11px;color:var(--muted);margin-top:8px;display:none"></div>
</div>
<!-- Tailscale device hostname — issue #44 -->
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
Expand Down
68 changes: 68 additions & 0 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -3293,6 +3293,74 @@ function _tsRender(state){
}
if(connectBtn) connectBtn.style.display = ui === 'connected' ? 'none' : '';
if(disconnectBtn) disconnectBtn.style.display = ui === 'connected' ? '' : 'none';

// FITB#122 #2: surface Serve state + manual retry. Only meaningful when
// connected — pre-Running there's no tunnel to bind HTTPS against.
_tsRenderServeState(ui === 'connected' ? state : null);
}

function _tsRenderServeState(state){
// Renders into the dedicated #tsServeState block. If the block doesn't
// exist in the DOM (older HTML, not yet redeployed), bail silently.
const el = document.getElementById('tsServeState');
if(!el) return;
if(!state){
el.innerHTML = '';
el.style.display = 'none';
return;
}
el.style.display = '';
const ss = state.serve_state || 'idle';
const err = state.serve_error || '';
if(ss === 'ok'){
el.innerHTML = '<span class="ts-serve-ok">HTTPS configured ✓</span>';
return;
}
// Both 'idle' (never tried in this session) and 'error' (last try failed)
// get the same affordance: a button that runs configure_serve. The error
// text only renders for 'error', so users authing via the desktop app
// see a neutral "Configure HTTPS" prompt rather than a fake error.
const errLine = ss === 'error' && err
? `<div class="ts-serve-error-msg">${escapeHtmlOrPassthrough(err)}</div>`
: '';
el.innerHTML = `
<div class="ts-serve-row">
<button id="tsConfigureServeBtn" class="btn btn-link" type="button" onclick="tsConfigureServe()">Configure HTTPS</button>
<span class="ts-serve-hint">Required for the tailnet HTTPS URL. Toggle "Enable HTTPS" in your tailnet admin console first.</span>
</div>
${errLine}
`;
}

// escapeHtml may live in another file (i18n.js / ui.js). Use whichever is
// in scope; fall back to a minimal local escape so this module stays self-
// contained against whichever bundle order the page uses.
function escapeHtmlOrPassthrough(s){
if(typeof window !== 'undefined' && typeof window.escapeHtml === 'function'){
return window.escapeHtml(s);
}
return String(s || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

async function tsConfigureServe(){
const btn = document.getElementById('tsConfigureServeBtn');
if(btn){ btn.disabled = true; btn.textContent = 'Configuring…'; }
try{
const r = await fetch('/api/tailscale/serve', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
const result = await r.json();
if(!result.ok){
showToast('Could not configure HTTPS: ' + (result.error || 'unknown error'));
}else{
showToast('Tailnet HTTPS configured.');
}
}catch(e){
showToast('Network error configuring HTTPS.');
}
// Re-fetch status so _tsRender picks up the new serve_state on the next
// render. loadTailscaleConnection rebinds the badge + serve row.
loadTailscaleConnection();
}

function _tsMessage(msg){
Expand Down
Loading