Compare commits

..

10 commits

Author SHA1 Message Date
d9a10f76ea fix: reuse the saved gateway registration on connect retry
Some checks failed
CI / lint (pull_request) Failing after 29s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 23s
CI / test (pull_request) Failing after 1m28s
run_connect() saved the registration before bringing the tunnel up, but
a retry after a tunnel failure registered again anyway, leaving the
first box and its wg-easy peer orphaned on the gateway. Reuse the saved
registration when it's for the same gateway, and refuse a different
gateway until the box is disconnected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:19:34 +02:00
e251eb831a feat: group installed-app actions into an overflow menu
With the internet toggle, a published app showed up to seven buttons and
squeezed its description. Only Open stays visible; Settings, the
expose/stop-exposing toggle, Update, Reinstall and Remove move into a
"⋯" menu, and the public URL shows as a link under the description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:13:39 +02:00
0490f49f85 fix: don't show gateway as disconnected when the session expired
Sessions are in-memory, so a furtka-api restart logs the user out and
/api/gateway/status returns 401. The settings page ignored that and kept
its default "not connected" panel visible. Both panels now start hidden
and a 401 redirects to /login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:13:39 +02:00
f95bfcb56b fix: install wireguard-tools from the webinstaller's package list
30ee044 added it to archinstall/user_configuration.json, but the
webinstaller builds its own package list in build_archinstall_config(),
so installed boxes had no wg-quick@.service and gateway connect failed
with "Unit wg-quick@furtka-gw.service does not exist".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:13:39 +02:00
905845187d fix: thread wg-easy v15's preshared key into the box's wg-quick config
furtka-gateway's wg-easy v15 client (wgeasy_v15.py) generates a
PresharedKey per WireGuard client by default, which the server enforces
even if the box's own conf omits it — dropping it here breaks the
handshake silently. Include it in [Peer] when present; v14 has no
preshared_key field at all, so this stays a no-op for that path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:45 +02:00
30ee044657 Add wireguard-tools to the installed-system package list
A freshly-installed box had no wg/wg-quick binary and no wg-quick@.service
unit, so wireguard.set_tunnel_enabled() would fail as soon as a box tried
to connect to a furtka-gateway. iso/overlay/packages.extra is unaffected —
that's the live-boot ISO's own package set, unrelated to what actually
lands on the installed disk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142mP6EqCkQKmR1xCp7zfmZ
2026-08-26 14:25:15 +02:00
f88412d9e8 Add gateway-connect UI and per-app internet-exposure toggle
Settings page gets a "Furtka Gateway" section: connect (gateway URL +
registration token + box name, polls /api/gateway/connect/status the same
way the update-apply flow polls /update-state.json) and disconnect, plus
a status readout (gateway URL, tunnel state). Removed "Remote access"
from the "Coming next" list now that it exists.

The Apps page's installed-app rows get an "Expose to Internet" control,
shown only when the manifest declares internet.viable — fetched lazily
per app (loadInternetSlots()) since the publish state isn't part of the
main /api/apps listing. Toggling calls the existing
GET/POST /api/apps/<name>/internet endpoints; the subdomain prompt uses a
plain window.prompt() rather than extending the settings modal, since
this needs a live network round-trip and a returned public URL that the
generic settings form has no way to express.

Verified end-to-end in a real browser against a running furtka serve +
a stub gateway server (login, publish, unpublish, connect-with-missing-
systemd-run, disconnect, and the client-side required-field validation),
not just by reading the generated HTML/JS.
2026-08-24 14:57:11 +02:00
91f2d6304c Fix wireguard.status()/set_tunnel_enabled() crash on unreadable conf path
Found by actually running furtka serve and clicking through the new
Settings UI: /api/gateway/status crashed the whole request with a raw
PermissionError traceback because conf_path.is_file() doesn't swallow
every OSError (confirmed directly, not assumed) when the parent directory
isn't accessible. A read-only status check must degrade to "not
configured" instead of 500ing; set_tunnel_enabled() now raises a clean
WireguardError instead of leaking an unhandled exception.

Also gives wireguard.py an env-var-overridable default path
(FURTKA_WIREGUARD_CONF / default_conf_path()), matching every other
runtime path in the codebase, instead of a single hardcoded constant.
2026-08-24 14:57:11 +02:00
e88b57f74d Add gateway connect/disconnect and per-app internet-toggle API endpoints
POST /api/gateway/connect dispatches registration + tunnel setup via
systemd-run (same async+state-file+poll idiom as install/update/catalog-
sync), backed by a new `furtka gateway connect-bg` internal CLI
subcommand and furtka.gateway.run_connect()/write_state()/read_state()/
acquire_lock(). GET /api/gateway/status and /api/gateway/connect/status
round out polling; POST /api/gateway/disconnect tears the tunnel down and
deregisters synchronously (a plain HTTP call plus one systemctl action,
same posture as _do_update_settings).

GET/POST /api/apps/<name>/internet is the per-app "expose to internet"
toggle: publishes/unpublishes a route via the connected gateway's
/v1/routes API (furtka.gateway.publish_route/unpublish_route, new) and
tracks published state in paths.internet_routes_file(). Deliberately its
own endpoint rather than a boolean settings[] type — it needs a live
network round-trip and returns a public URL, which the generic settings
modal has no way to express.

_manifest_summary()/cli.py's `app list --json` both gain the internet
field, mechanically identical to how requires was added.
2026-08-24 13:35:44 +02:00
557d410eca Add gateway registration client and WireGuard tunnel management
furtka.gateway: register/rotate-token/deregister against a furtka-gateway's
/v1/boxes/* API (stdlib urllib.request, same convention as
_release_common.py/updater.py). No local keypair generation — the
gateway's registration response carries the full WireGuard interface
config including the private key, since stock wg-easy always generates
the keypair itself (see furtka-gateway's wgeasy.py). Config persists to
paths.gateway_config_file() (0600, atomic write, same pattern as
auth.save_users).

furtka.wireguard: direct structural mirror of https.py's
snippet-write-plus-reload pattern for the wg-quick@furtka-gw systemd unit
— write/remove the .conf, flip the unit, revert the conf on failure so a
bad config can't leave the tunnel wedged.

paths.py gains gateway_config_file() and internet_routes_file()
(FURTKA_GATEWAY_CONFIG / FURTKA_INTERNET_ROUTES_FILE), following the
existing env-var-override convention.
2026-08-24 13:13:25 +02:00
14 changed files with 1906 additions and 7 deletions

View file

@ -23,7 +23,8 @@
"git",
"htop",
"curl",
"openssh"
"openssh",
"wireguard-tools"
],
"profile": {

View file

@ -80,6 +80,45 @@
</div>
</section>
<section>
<h2>Furtka Gateway</h2>
<div class="card">
<p class="lede">
Connect this box to a furtka-gateway to expose individual apps to the
internet over a WireGuard tunnel. Apps stay private until you explicitly
publish one from the <a href="/apps">Apps</a> page — connecting here only
opens the tunnel, nothing is exposed automatically.
</p>
<div id="gateway-disconnected" hidden>
<div class="field">
<label for="gw-url">Gateway URL</label>
<input type="text" id="gw-url" placeholder="https://gw.example.com">
</div>
<div class="field">
<label for="gw-token">Registration token</label>
<input type="password" id="gw-token" placeholder="from the gateway operator">
</div>
<div class="field">
<label for="gw-name">Box name</label>
<input type="text" id="gw-name" placeholder="my-box">
</div>
<div class="update-actions">
<button id="gateway-connect-btn">Connect</button>
</div>
</div>
<div id="gateway-connected" hidden>
<dl class="kv">
<dt>Gateway</dt><dd id="gw-connected-url">—</dd>
<dt>Tunnel</dt><dd id="gw-tunnel-status">—</dd>
</dl>
<div class="update-actions">
<button class="danger" id="gateway-disconnect-btn">Disconnect</button>
</div>
</div>
<p id="gateway-status" class="hint"></p>
</div>
</section>
<section>
<h2>Appearance</h2>
<div class="card">
@ -112,7 +151,6 @@
<a href="https://furtka.org/#planned">Change hostname</a>
<a href="https://furtka.org/#planned">Backup</a>
<a href="https://furtka.org/#planned">User accounts</a>
<a href="https://furtka.org/#planned">Remote access</a>
</div>
</section>
@ -339,6 +377,127 @@
await refreshHttpsStatus();
})();
// --- Furtka Gateway -------------------------------------------------
const CONNECT_STAGE_LABELS = {
registering: 'Registering with the gateway…',
starting_tunnel: 'Starting the WireGuard tunnel…',
done: 'Connected.',
};
const gatewayDisconnectedEl = document.getElementById('gateway-disconnected');
const gatewayConnectedEl = document.getElementById('gateway-connected');
const gwConnectedUrlEl = document.getElementById('gw-connected-url');
const gwTunnelStatusEl = document.getElementById('gw-tunnel-status');
const gatewayStatusEl = document.getElementById('gateway-status');
const gatewayConnectBtn = document.getElementById('gateway-connect-btn');
const gatewayDisconnectBtn = document.getElementById('gateway-disconnect-btn');
let gatewayPollHandle = null;
function setGatewayStatus(msg, isError = false) {
gatewayStatusEl.textContent = msg;
gatewayStatusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
function describeTunnel(tunnel) {
if (!tunnel || !tunnel.configured) return 'not configured';
return tunnel.latest_handshake ? `up (last handshake ${tunnel.latest_handshake})` : 'configured, no handshake yet';
}
async function refreshGatewayStatus() {
try {
const r = await fetch('/api/gateway/status', { cache: 'no-store' });
// Sessions are in-memory, so a furtka-api restart logs us
// out. Both panels start hidden — never claim "not
// connected" when we simply couldn't ask.
if (r.status === 401) {
window.location.href = '/login';
return;
}
if (!r.ok) return;
const s = await r.json();
gatewayDisconnectedEl.hidden = !!s.connected;
gatewayConnectedEl.hidden = !s.connected;
if (s.connected) {
gwConnectedUrlEl.textContent = s.gateway_url;
gwTunnelStatusEl.textContent = describeTunnel(s.tunnel);
}
} catch (e) {
/* next refresh will retry */
}
}
refreshGatewayStatus();
gatewayConnectBtn.addEventListener('click', async () => {
const gateway_url = document.getElementById('gw-url').value.trim();
const registration_token = document.getElementById('gw-token').value;
const box_name = document.getElementById('gw-name').value.trim();
if (!gateway_url || !registration_token || !box_name) {
setGatewayStatus('Gateway URL, registration token, and box name are all required.', true);
return;
}
gatewayConnectBtn.disabled = true;
setGatewayStatus('Connecting…');
try {
const r = await fetch('/api/gateway/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway_url, registration_token, box_name }),
});
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
gatewayConnectBtn.disabled = false;
return;
}
gatewayPollHandle = setInterval(pollGatewayConnectState, 2000);
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
gatewayConnectBtn.disabled = false;
}
});
async function pollGatewayConnectState() {
try {
const r = await fetch('/api/gateway/connect/status', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
const label = CONNECT_STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setGatewayStatus(label, s.stage === 'error');
if (s.stage === 'done') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
await refreshGatewayStatus();
} else if (s.stage === 'error') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
if (s.error) setGatewayStatus(`${label} — ${s.error}`, true);
}
} catch (e) {
/* keep polling; transient blip expected */
}
}
gatewayDisconnectBtn.addEventListener('click', async () => {
if (!confirm('Disconnect from the gateway? Any apps currently exposed to the internet will stop being reachable.')) return;
gatewayDisconnectBtn.disabled = true;
setGatewayStatus('Disconnecting…');
try {
const r = await fetch('/api/gateway/disconnect', { method: 'POST' });
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
return;
}
setGatewayStatus('Disconnected.');
await refreshGatewayStatus();
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
} finally {
gatewayDisconnectBtn.disabled = false;
}
});
async function pollUpdateState() {
try {
const r = await fetch('/update-state.json', { cache: 'no-store' });

View file

@ -222,6 +222,59 @@ button.secondary, .btn.secondary {
border: 1px solid var(--border);
}
button.danger { background: var(--danger); color: #fff; }
/* Public-URL line under an app's description, shown while the app is
exposed through furtka-gateway. */
.public-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--accent);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.public-link:hover { text-decoration: underline; }
.public-link .dot {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--r-pill);
background: var(--accent);
flex-shrink: 0;
}
/* "⋯" overflow menu on an installed app row. */
details.menu { position: relative; }
details.menu > summary { list-style: none; padding: 0.5rem 0.75rem; }
details.menu > summary::-webkit-details-marker { display: none; }
.menu-list {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 10;
min-width: 11rem;
padding: 0.25rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
.menu-list button {
background: none;
color: var(--fg);
font-weight: 500;
width: 100%;
justify-content: flex-start;
}
.menu-list button:hover { background: var(--card-hover); }
.menu-list button.danger { background: none; color: var(--danger); }
.menu-list button.danger:hover { background: var(--card-hover); }
.menu-list hr { border: none; border-top: 1px solid var(--border); margin: 0.25rem 0; }
button:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }

View file

@ -358,6 +358,20 @@ async function refresh() {
const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>`
: '';
// Both filled in async by loadInternetSlots() after this markup
// lands — the current publish state needs its own fetch per app
// (/api/apps/<name>/internet), so it can't be known at row-build
// time. The status line sits under the description (public URL,
// when exposed); the toggle lives in the "⋯" menu.
const viable = a.internet && a.internet.viable;
const internetStatus = viable
? `<span class="internet-status" data-name="${esc(a.name)}"></span>`
: '';
const internetItem = viable
? `<span class="internet-item" data-name="${esc(a.name)}"></span>`
: '';
// Only Open stays visible; everything else goes in a <details>
// menu so the description isn't squeezed by a row of buttons.
return `
<div class="app">
<div class="left">
@ -365,14 +379,22 @@ async function refresh() {
<div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span>
${internetStatus}
</div>
</div>
<div class="buttons">
${openBtn}
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
<details class="menu">
<summary class="btn secondary" aria-label="More actions for ${esc(a.display_name || a.name)}">⋯</summary>
<div class="menu-list">
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
${internetItem}
<button data-op="update" data-name="${esc(a.name)}">Update</button>
<button data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<hr>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div>
</details>
</div>
</div>`;
}).join('')
@ -393,6 +415,78 @@ async function refresh() {
for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
}
loadInternetSlots();
}
// Populates each installed internet-viable app's public-URL status line
// and its "Expose to Internet" / "Stop exposing" menu item. Separate from
// the main refresh() render because the current publish state needs its
// own per-app fetch — the /api/apps list only carries whether an app is
// *capable* of being exposed, not whether it currently is.
async function loadInternetSlots() {
for (const item of document.querySelectorAll('.internet-item')) {
const name = item.dataset.name;
const status = document.querySelector(`.internet-status[data-name="${CSS.escape(name)}"]`);
try {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`);
if (!r.ok) continue;
renderInternetSlot(item, status, name, await r.json());
} catch (e) {
// Leave the slots empty — next refresh() retries.
}
}
}
function renderInternetSlot(item, status, name, data) {
if (data.enabled) {
const host = data.public_url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
if (status) {
status.innerHTML =
`<a class="public-link" href="${esc(data.public_url)}" target="_blank" rel="noopener">` +
`<span class="dot"></span>${esc(host)} ↗</a>`;
}
item.innerHTML = `<button data-internet="${esc(name)}">Stop exposing</button>`;
} else {
if (status) status.innerHTML = '';
item.innerHTML = `<button data-internet="${esc(name)}">Expose to Internet</button>`;
}
item.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled));
}
// <details> menus don't close on an outside click by themselves.
document.addEventListener('click', (e) => {
for (const menu of document.querySelectorAll('details.menu[open]')) {
if (!menu.contains(e.target)) menu.removeAttribute('open');
}
});
async function handleInternetToggle(name, currentlyEnabled) {
const logEl = document.getElementById('log');
if (currentlyEnabled) {
if (!confirm(`Stop exposing ${name} to the internet?`)) return;
}
let subdomain = null;
if (!currentlyEnabled) {
subdomain = prompt(
`Public hostname for ${name} — needs its own DNS record already pointing at your gateway:`
);
if (!subdomain) return;
}
try {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentlyEnabled ? { enabled: false } : { enabled: true, subdomain }),
});
const data = await r.json();
if (!r.ok) {
logEl.textContent = `[internet ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
return;
}
await refresh();
} catch (e) {
logEl.textContent = `[internet ${name}] Network error: ${e.message}`;
}
}
async function handleButton(op, name, btn) {
@ -665,6 +759,7 @@ def _manifest_summary(m, app_dir=None):
{"app": req.app, "on_install": req.on_install, "on_start": req.on_start}
for req in m.requires
],
"internet": {"viable": m.internet.viable, "port": m.internet.port},
}
@ -1138,6 +1233,208 @@ def _do_catalog_status():
}
def _do_gateway_status():
"""This box's furtka-gateway connection + tunnel state.
No connection at all is the common case (most boxes never connect to a
gateway) — always 200s with connected=False rather than 404ing.
"""
from furtka import gateway, wireguard
config = gateway.load_config()
if config is None:
return 200, {"connected": False}
return 200, {
"connected": True,
"gateway_url": config["gateway_url"],
"box_id": config["box_id"],
"tunnel": wireguard.status(),
}
def _do_gateway_connect(payload):
"""Kick off gateway registration + tunnel setup detached from this process.
Fast validation happens here so a bad request comes back as an
immediate 4xx. The slow part — a network call to a possibly slow or
unreachable gateway, then `systemctl enable --now wg-quick@...` — is
dispatched as a background systemd-run unit, the same idiom
_do_install uses for its docker-facing phase, so it can't tie up the
API thread. Client polls /api/gateway/connect/status.
Note: registration_token travels as a systemd-run argv entry, briefly
visible to anything on this box that can read `ps`/`systemctl show`
while the transient unit runs. Accepted here the same way furtka
already treats local-box access as a trusted boundary (see _do_power's
"anyone on the LAN can reboot" posture) — not something a home/single-
tenant box's threat model treats as a meaningful new exposure.
"""
import subprocess
from furtka import gateway
gateway_url = payload.get("gateway_url")
registration_token = payload.get("registration_token")
box_name = payload.get("box_name")
if not gateway_url or not registration_token or not box_name:
return 400, {"error": "gateway_url, registration_token, and box_name are all required"}
try:
fh = gateway.acquire_lock()
except gateway.GatewayError as e:
return 409, {"error": str(e)}
try:
# Initial state so the UI has something to show between this
# response and the background job's first write.
gateway.write_state("registering")
finally:
fh.close()
try:
subprocess.run(
[
"systemd-run",
"--unit=furtka-gateway-connect-api",
"--no-block",
"--collect",
"/usr/local/bin/furtka",
"gateway",
"connect-bg",
gateway_url,
registration_token,
box_name,
],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
gateway.write_state("error", error="systemd-run not available")
return 502, {"error": "systemd-run not available"}
except subprocess.CalledProcessError as e:
err = (e.stderr or e.stdout or "").strip()
gateway.write_state("error", error=f"dispatch failed: {err}")
return 502, {"error": f"systemd-run failed: {err}"}
return 202, {"status": "dispatched", "unit": "furtka-gateway-connect-api"}
def _do_gateway_connect_status():
from furtka import gateway
return 200, gateway.read_state()
def _do_gateway_disconnect():
"""Tear down the tunnel and deregister from the gateway.
Synchronous — deregistering is one HTTP call plus a systemctl disable,
not a slow docker pull, so this doesn't need the async+poll dance
(same posture as _do_update_settings).
"""
from furtka import gateway, wireguard
if gateway.load_config() is None:
return 200, {"status": "not connected"}
try:
gateway.deregister()
except gateway.GatewayError as e:
return 502, {"error": str(e)}
try:
wireguard.set_tunnel_enabled(False)
except wireguard.WireguardError as e:
return 502, {"error": str(e)}
return 200, {"status": "disconnected"}
def _internet_routes() -> dict:
from furtka.paths import internet_routes_file
path = internet_routes_file()
if not path.is_file():
return {}
try:
return json.loads(path.read_text())
except json.JSONDecodeError:
return {}
def _save_internet_routes(routes: dict) -> None:
from furtka.paths import internet_routes_file
path = internet_routes_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(routes, indent=2))
tmp.chmod(0o600)
tmp.replace(path)
def _do_get_internet(name):
"""Current internet-exposure state for one installed app.
404s for apps that aren't installed or aren't internet.viable — the UI
only ever renders the toggle for viable, installed apps, so either
case means this request shouldn't have been made.
"""
m, _values, installed = _load_manifest_for(name)
if m is None or not installed:
return 404, {"error": f"{name!r} is not installed"}
if not m.internet.viable:
return 404, {"error": f"{name!r} is not internet-viable"}
entry = _internet_routes().get(name)
if entry is None:
return 200, {"enabled": False}
return 200, {"enabled": True, "public_url": entry.get("public_url")}
def _do_set_internet(name, payload):
"""Publish or unpublish an app through the connected gateway.
Synchronous — a plain HTTP call to the gateway's control plane, no
image pull involved, same posture as _do_update_settings, not the
async+poll pattern _do_gateway_connect needs.
"""
from furtka import gateway
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
return 400, {"error": "'enabled' (bool) is required"}
m, _values, installed = _load_manifest_for(name)
if m is None or not installed:
return 404, {"error": f"{name!r} is not installed"}
if not m.internet.viable:
return 400, {"error": f"{name!r} is not internet-viable"}
if gateway.load_config() is None:
return 409, {"error": "this box is not connected to a gateway"}
routes = _internet_routes()
if enabled:
subdomain = payload.get("subdomain")
if not subdomain:
return 400, {"error": "'subdomain' is required to publish"}
try:
result = gateway.publish_route(name, subdomain, m.internet.port)
except gateway.GatewayError as e:
return 502, {"error": str(e)}
routes[name] = {"route_id": result["route_id"], "public_url": result["public_url"]}
_save_internet_routes(routes)
return 200, {"enabled": True, "public_url": result["public_url"]}
entry = routes.pop(name, None)
if entry is None:
return 200, {"enabled": False}
try:
gateway.unpublish_route(entry["route_id"])
except gateway.GatewayError as e:
return 502, {"error": str(e)}
_save_internet_routes(routes)
return 200, {"enabled": False}
_POWER_ACTIONS = {
"reboot": "reboot",
"poweroff": "poweroff",
@ -1440,6 +1737,12 @@ class _Handler(BaseHTTPRequestHandler):
if self.path == "/api/apps/install/status":
status, body = _do_install_status()
return self._json(status, body)
if self.path == "/api/gateway/status":
status, body = _do_gateway_status()
return self._json(status, body)
if self.path == "/api/gateway/connect/status":
status, body = _do_gateway_connect_status()
return self._json(status, body)
# /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
@ -1447,6 +1750,13 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name)
return self._json(status, body)
# /api/apps/<name>/internet
if self.path.startswith("/api/apps/") and self.path.endswith("/internet"):
name = self.path[len("/api/apps/") : -len("/internet")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_internet(name)
return self._json(status, body)
self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
@ -1488,6 +1798,22 @@ class _Handler(BaseHTTPRequestHandler):
status, body = _do_update(name)
return self._json(status, body)
# Per-app internet-exposure toggle: /api/apps/<name>/internet
if self.path.startswith("/api/apps/") and self.path.endswith("/internet"):
name = self.path[len("/api/apps/") : -len("/internet")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_set_internet(name, payload)
return self._json(status, body)
# This box's furtka-gateway connection: connect / disconnect.
if self.path == "/api/gateway/connect":
status, body = _do_gateway_connect(payload)
return self._json(status, body)
if self.path == "/api/gateway/disconnect":
status, body = _do_gateway_disconnect()
return self._json(status, body)
# Furtka itself: check + apply endpoints (Phase 2 self-update).
if self.path == "/api/furtka/update/check":
status, body = _do_furtka_check()

View file

@ -46,6 +46,10 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
}
for req in r.manifest.requires
],
"internet": {
"viable": r.manifest.internet.viable,
"port": r.manifest.internet.port,
},
}
if r.manifest
else None,
@ -146,6 +150,25 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
return 0
def _cmd_gateway_connect_bg(args: argparse.Namespace) -> int:
"""Register + bring up the tunnel — called by the API via systemd-run.
Internal subcommand; the UI drives this through
`POST /api/gateway/connect`, which does fast input validation
synchronously and dispatches this the same way `app install` dispatches
`app install-bg`.
"""
from furtka import gateway
try:
gateway.run_connect(args.gateway_url, args.registration_token, args.box_name)
except Exception as e:
# run_connect already wrote state="error"; echo for journald.
print(f"gateway connect-bg failed: {e}", file=sys.stderr)
return 1
return 0
def _cmd_serve(args: argparse.Namespace) -> int:
# Imported lazily so `furtka` startup stays cheap when the user only runs
# `app list` or `reconcile` (the common case during tests + scripts).
@ -374,6 +397,20 @@ def build_parser() -> argparse.ArgumentParser:
)
catalog_status.set_defaults(func=_cmd_catalog_status)
gateway = sub.add_parser("gateway", help="Manage this box's furtka-gateway connection")
gateway_sub = gateway.add_subparsers(dest="subcommand", required=True)
# Internal — called by the HTTP API via systemd-run. Deliberately
# omitted from the help listing, same as `app install-bg`.
gateway_connect_bg = gateway_sub.add_parser(
"connect-bg",
help=argparse.SUPPRESS,
)
gateway_connect_bg.add_argument("gateway_url")
gateway_connect_bg.add_argument("registration_token")
gateway_connect_bg.add_argument("box_name")
gateway_connect_bg.set_defaults(func=_cmd_gateway_connect_bg)
return p

246
furtka/gateway.py Normal file
View file

@ -0,0 +1,246 @@
"""Client for a Furtka box registering with (and talking to) a
furtka-gateway.
Talks to the gateway's `/v1/boxes/*` API (see the furtka-gateway repo's
`control_plane/api.py` for the server side) using the same stdlib-only
`urllib.request` convention as `_release_common.py`/`updater.py` — no new
dependency for this.
`register()` persists the result to `paths.gateway_config_file()` (0600,
atomic write — the same tmp-file-then-replace pattern `auth.save_users`
uses) so later calls don't need the box to re-register, and so
`/api/gateway/status` has something to read without holding a live
connection open.
`run_connect()` plus the state/lock helpers below follow the exact shape
of furtka.catalog's own state/lock functions — this module is the
"runner" `furtka gateway connect-bg` calls after `api.py`'s
`POST /api/gateway/connect` dispatches it via systemd-run, the same
async-with-state-file-polling idiom used for install/update/catalog-sync.
"""
from __future__ import annotations
import fcntl
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from furtka import wireguard
from furtka.paths import gateway_config_file
_CONNECT_STATE = Path(
os.environ.get("FURTKA_GATEWAY_CONNECT_STATE", "/var/lib/furtka/gateway-connect-state.json")
)
_CONNECT_LOCK = Path(
os.environ.get("FURTKA_GATEWAY_CONNECT_LOCK", "/run/furtka/gateway-connect.lock")
)
class GatewayError(Exception):
"""Recoverable failure talking to a furtka-gateway — the caller should 5xx."""
def _request(method: str, url: str, body: dict | None = None, *, token: str | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
headers = {}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
raise GatewayError(f"{method} {url} -> HTTP {e.code}: {detail}") from e
except urllib.error.URLError as e:
raise GatewayError(f"{method} {url}: {e}") from e
def _post(url: str, body: dict, *, token: str | None = None) -> dict:
return _request("POST", url, body, token=token)
def _delete(url: str, *, token: str | None = None) -> dict:
return _request("DELETE", url, token=token)
def _save_config(config: dict) -> None:
path = gateway_config_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(config, indent=2) + "\n")
tmp.chmod(0o600)
tmp.replace(path)
def load_config() -> dict | None:
"""Return the persisted {gateway_url, box_id, box_token, wg} config, or
None if this box has never registered with a gateway."""
path = gateway_config_file()
if not path.is_file():
return None
return json.loads(path.read_text())
def register(gateway_url: str, registration_token: str, box_name: str) -> dict:
"""Register this box with a furtka-gateway.
No local keypair is generated here — the gateway's response carries the
full WireGuard interface config, private key included (see
furtka-gateway's `wgeasy.py` module docstring for why stock wg-easy
makes that unavoidable). Persists and returns
{gateway_url, box_id, box_token, wg}.
"""
gateway_url = gateway_url.rstrip("/")
result = _post(
f"{gateway_url}/v1/boxes/register",
{"registration_token": registration_token, "box_name": box_name},
)
config = {
"gateway_url": gateway_url,
"box_id": result["box_id"],
"box_token": result["box_token"],
"wg": result["wg"],
}
_save_config(config)
return config
def rotate_token() -> str:
"""Ask the gateway for a new box token, replacing the stored one."""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
result = _post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/rotate-token",
{},
token=config["box_token"],
)
config["box_token"] = result["box_token"]
_save_config(config)
return config["box_token"]
def deregister() -> None:
"""Tell the gateway to forget this box, then drop the local config.
Raises on a failed gateway call rather than silently clearing local
state — a box that can't reach the gateway should stay "connected"
locally so the operation can be retried, instead of losing its
box_token while the gateway's own row (and the wg-easy peer / any
published routes) linger orphaned.
"""
config = load_config()
if config is None:
return
_post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/deregister",
{},
token=config["box_token"],
)
gateway_config_file().unlink(missing_ok=True)
def publish_route(app_name: str, subdomain: str, port: int) -> dict:
"""Publish `app_name` at `subdomain` through the connected gateway.
Returns {route_id, public_url}. The gateway independently re-validates
subdomain/port server-side regardless of what's sent here — see
furtka-gateway's routes.py — this call just carries the box owner's
request, it isn't itself the security boundary.
"""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
return _post(
f"{config['gateway_url']}/v1/routes",
{"app_name": app_name, "subdomain": subdomain, "port": port},
token=config["box_token"],
)
def unpublish_route(route_id: str) -> None:
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
_delete(f"{config['gateway_url']}/v1/routes/{route_id}", token=config["box_token"])
def state_path() -> Path:
return _CONNECT_STATE
def lock_path() -> Path:
return _CONNECT_LOCK
def write_state(stage: str, **extra) -> None:
"""Atomic JSON state write — same shape as catalog/update-state."""
state_path().parent.mkdir(parents=True, exist_ok=True)
tmp = state_path().with_suffix(".tmp")
payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra}
tmp.write_text(json.dumps(payload, indent=2))
tmp.replace(state_path())
def read_state() -> dict:
try:
return json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
def acquire_lock():
path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
fh = path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as e:
fh.close()
raise GatewayError("another gateway connect is already in progress") from e
return fh
def run_connect(gateway_url: str, registration_token: str, box_name: str) -> None:
"""Register with the gateway, then bring the tunnel up — the slow part
`furtka gateway connect-bg` runs in the background (dispatched via
systemd-run from `POST /api/gateway/connect`), writing state
transitions as it goes so the UI can poll instead of the request
thread blocking on a slow/unreachable gateway.
A registration saved by an earlier attempt whose tunnel failed to come
up is reused rather than registering again — re-registering would leave
the first box (and its wg-easy peer) orphaned on the gateway.
"""
existing = load_config()
if existing is not None:
if existing["gateway_url"] != gateway_url.rstrip("/"):
write_state(
"error",
error=f"already registered with {existing['gateway_url']} — disconnect first",
)
return
config = existing
else:
write_state("registering")
try:
config = register(gateway_url, registration_token, box_name)
except GatewayError as e:
write_state("error", error=str(e))
return
write_state("starting_tunnel")
try:
wireguard.set_tunnel_enabled(True, wg=config["wg"])
except wireguard.WireguardError as e:
write_state("error", error=str(e))
return
write_state("done")

View file

@ -20,6 +20,18 @@ DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
# /settings* so those pages pick up the auth-guard. Caddy also serves
# /style.css and other assets directly from here for the login page.
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
# This box's furtka-gateway registration: {gateway_url, box_id, box_token,
# wg: {...}}. Written once by furtka.gateway.register(), 0600 (it carries
# the box's WireGuard private key — see furtka-gateway's wgeasy.py for why
# that's unavoidable with stock wg-easy). Lives under /var/lib/furtka/ so
# self-updates don't stomp it, same as users.json.
DEFAULT_GATEWAY_CONFIG = Path("/var/lib/furtka/gateway.json")
# Which installed apps are currently published through the connected
# gateway, keyed by app name: {app_name: {route_id, subdomain, port}}.
# Separate from gateway.json since this changes per-app-toggle, not per
# gateway-connection — no reason to rewrite the (more sensitive) box
# credentials file on every publish/unpublish.
DEFAULT_INTERNET_ROUTES_FILE = Path("/var/lib/furtka/internet-routes.json")
def apps_dir() -> Path:
@ -44,3 +56,11 @@ def users_file() -> Path:
def static_www_dir() -> Path:
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
def gateway_config_file() -> Path:
return Path(os.environ.get("FURTKA_GATEWAY_CONFIG", DEFAULT_GATEWAY_CONFIG))
def internet_routes_file() -> Path:
return Path(os.environ.get("FURTKA_INTERNET_ROUTES_FILE", DEFAULT_INTERNET_ROUTES_FILE))

160
furtka/wireguard.py Normal file
View file

@ -0,0 +1,160 @@
"""WireGuard tunnel to a connected furtka-gateway.
Structural mirror of furtka.https's snippet-write-plus-reload pattern:
write/remove /etc/wireguard/furtka-gw.conf, then flip the wg-quick@furtka-gw
systemd unit, reverting the conf file if that call fails so a bad config
can't leave the tunnel half-configured. One deliberate difference from
https.py: enabling and disabling are genuinely different systemctl
subcommands here (Caddy only ever needs a single `reload` either way), so
the injectable `reload` callable takes the resolved action ("enable" or
"disable") rather than being a fixed zero-argument call.
This module never generates its own keypair or contacts furtka-gateway
directly — furtka.gateway.register() gets the full WireGuard interface
config (including the private key; see furtka-gateway's wgeasy.py for why
that's unavoidable with stock wg-easy) and hands it to
set_tunnel_enabled() here.
"""
import os
import subprocess
from pathlib import Path
DEFAULT_CONF_PATH = Path("/etc/wireguard/furtka-gw.conf")
INTERFACE = "furtka-gw"
def default_conf_path() -> Path:
return Path(os.environ.get("FURTKA_WIREGUARD_CONF", DEFAULT_CONF_PATH))
class WireguardError(Exception):
"""Recoverable failure from set_tunnel_enabled — the caller should 5xx."""
def _conf_content(wg: dict) -> str:
"""Render a wg-quick .conf from a furtka-gateway registration response's
`wg` block. AllowedIPs here governs what THIS box routes *through* the
tunnel — furtka-gateway hands back a split-tunnel value (its own peer
subnet only), which this module passes through verbatim rather than
widening it, so a compromised gateway can't pull the box's other
traffic through it.
`preshared_key` is only present when the gateway's wg-easy is v15
(which generates one per client by default and enforces it
server-side) — omitted (None/missing) for v14, which has none.
Dropping a present preshared key here would break the handshake
silently, since the server side would still expect it.
"""
preshared_key_line = ""
if wg.get("preshared_key"):
preshared_key_line = f"PresharedKey = {wg['preshared_key']}\n"
return (
"[Interface]\n"
f"PrivateKey = {wg['private_key']}\n"
f"Address = {wg['address']}\n"
"\n"
"[Peer]\n"
f"PublicKey = {wg['server_public_key']}\n"
f"{preshared_key_line}"
f"Endpoint = {wg['endpoint']}\n"
f"AllowedIPs = {wg['allowed_ips']}\n"
"PersistentKeepalive = 25\n"
)
def _default_reload(action: str) -> None:
subprocess.run(
["systemctl", action, "--now", f"wg-quick@{INTERFACE}"],
check=True,
capture_output=True,
text=True,
)
def set_tunnel_enabled(
enabled: bool,
wg: dict | None = None,
conf_path: Path | None = None,
reload=_default_reload,
) -> bool:
"""Bring the tunnel up (writing conf_path from `wg`) or down.
`wg` is required when enabling ({private_key, address, server_public_key,
endpoint, allowed_ips} — furtka.gateway.register()'s return shape),
ignored when disabling. Reverts conf_path to its pre-call state if the
systemctl action fails, so a bad config can't leave the tunnel wedged.
"""
if enabled and wg is None:
raise ValueError("wg config is required to enable the tunnel")
if conf_path is None:
conf_path = default_conf_path()
try:
conf_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
had_conf = conf_path.is_file()
previous = conf_path.read_text() if had_conf else None
if enabled:
conf_path.write_text(_conf_content(wg))
conf_path.chmod(0o600)
elif had_conf:
conf_path.unlink()
except OSError as e:
raise WireguardError(f"could not access {conf_path}: {e}") from e
try:
reload("enable" if enabled else "disable")
except subprocess.CalledProcessError as e:
_revert(conf_path, previous)
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
raise WireguardError(f"systemctl failed: {msg}") from e
except FileNotFoundError as e:
_revert(conf_path, previous)
raise WireguardError(f"systemctl not available: {e}") from e
return enabled
def _revert(conf_path: Path, previous: str | None) -> None:
if previous is None:
try:
conf_path.unlink()
except FileNotFoundError:
pass
else:
conf_path.write_text(previous)
def _default_wg_show() -> str:
result = subprocess.run(
["wg", "show", INTERFACE, "latest-handshakes"],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def status(conf_path: Path | None = None, wg_show=_default_wg_show) -> dict:
"""Whether the tunnel is configured, and its latest handshake if `wg`
tooling is available (None if not — e.g. wg-tools missing, or the
interface isn't actually up yet right after enabling).
Read-only and best-effort: any OSError stat-ing conf_path (missing
parent dir, an unexpected permission situation) is treated the same as
"not configured" rather than raised — a status check must never 500
the whole request over something this innocuous.
"""
if conf_path is None:
conf_path = default_conf_path()
try:
configured = conf_path.is_file()
except OSError:
configured = False
latest_handshake = None
if configured:
try:
latest_handshake = wg_show()
except (subprocess.CalledProcessError, FileNotFoundError):
latest_handshake = None
return {"configured": configured, "latest_handshake": latest_handshake}

View file

@ -1424,3 +1424,325 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
status, body = api._do_power({"action": "reboot"})
assert status == 502
assert "systemd-run" in body["error"]
# --- Gateway endpoints -------------------------------------------------------
@pytest.fixture
def gateway_paths(fake_dirs, monkeypatch):
"""Redirect furtka.gateway's own env-cached state/lock paths into
tmp_path — same "set env, reload module" idiom fake_dirs already uses
for install_runner. gateway_config_file()/internet_routes_file() read
FURTKA_GATEWAY_CONFIG/FURTKA_INTERNET_ROUTES_FILE fresh on every call
(no caching), so they don't need a reload — only gateway.py's module-
level connect-state/lock constants do.
"""
apps_dir, _ = fake_dirs
root = apps_dir.parent
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(root / "gateway.json"))
monkeypatch.setenv("FURTKA_INTERNET_ROUTES_FILE", str(root / "internet-routes.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(root / "gateway-connect-state.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(root / "gateway-connect.lock"))
import importlib
from furtka import gateway as g
importlib.reload(g)
return g
def test_gateway_status_not_connected(gateway_paths):
status, body = api._do_gateway_status()
assert status == 200
assert body == {"connected": False}
def test_gateway_status_connected(gateway_paths, monkeypatch):
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
from furtka import wireguard
monkeypatch.setattr(
wireguard, "status", lambda: {"configured": True, "latest_handshake": "123"}
)
status, body = api._do_gateway_status()
assert status == 200
assert body["connected"] is True
assert body["gateway_url"] == "https://gw.example.com"
assert body["box_id"] == "abc"
assert body["tunnel"] == {"configured": True, "latest_handshake": "123"}
def test_gateway_connect_requires_all_fields(gateway_paths):
status, body = api._do_gateway_connect({"gateway_url": "https://gw.example.com"})
assert status == 400
assert "required" in body["error"]
def test_gateway_connect_dispatches_systemd_run(gateway_paths, no_systemd_run):
status, body = api._do_gateway_connect(
{
"gateway_url": "https://gw.example.com",
"registration_token": "reg-tok",
"box_name": "my-box",
}
)
assert status == 202
assert body["status"] == "dispatched"
[cmd] = no_systemd_run
assert cmd[:1] == ["systemd-run"]
assert cmd[-5:] == [
"gateway",
"connect-bg",
"https://gw.example.com",
"reg-tok",
"my-box",
]
# Initial state written synchronously so the UI has something to poll
# before the background job's first write.
assert gateway_paths.read_state()["stage"] == "registering"
def test_gateway_connect_lock_contention(gateway_paths):
fh = gateway_paths.acquire_lock()
try:
status, body = api._do_gateway_connect(
{
"gateway_url": "https://gw.example.com",
"registration_token": "reg-tok",
"box_name": "my-box",
}
)
assert status == 409
assert "already in progress" in body["error"]
finally:
fh.close()
def test_gateway_connect_surfaces_systemd_run_missing(gateway_paths, monkeypatch):
def boom(*a, **kw):
raise FileNotFoundError(2, "No such file", "systemd-run")
monkeypatch.setattr("subprocess.run", boom)
status, body = api._do_gateway_connect(
{
"gateway_url": "https://gw.example.com",
"registration_token": "reg-tok",
"box_name": "my-box",
}
)
assert status == 502
assert "systemd-run" in body["error"]
assert gateway_paths.read_state()["stage"] == "error"
def test_gateway_connect_status_reads_state(gateway_paths):
gateway_paths.write_state("starting_tunnel")
status, body = api._do_gateway_connect_status()
assert status == 200
assert body["stage"] == "starting_tunnel"
def test_gateway_disconnect_when_not_connected(gateway_paths):
status, body = api._do_gateway_disconnect()
assert status == 200
assert body == {"status": "not connected"}
def test_gateway_disconnect_happy_path(gateway_paths, monkeypatch):
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
from furtka import wireguard
deregister_calls = []
tunnel_calls = []
monkeypatch.setattr(gateway_paths, "deregister", lambda: deregister_calls.append(1))
monkeypatch.setattr(
wireguard, "set_tunnel_enabled", lambda enabled: tunnel_calls.append(enabled)
)
status, body = api._do_gateway_disconnect()
assert status == 200
assert body == {"status": "disconnected"}
assert deregister_calls == [1]
assert tunnel_calls == [False]
def test_gateway_disconnect_surfaces_deregister_failure(gateway_paths, monkeypatch):
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
def boom():
raise gateway_paths.GatewayError("gateway unreachable")
monkeypatch.setattr(gateway_paths, "deregister", boom)
status, body = api._do_gateway_disconnect()
assert status == 502
assert "gateway unreachable" in body["error"]
# --- Internet exposure toggle ------------------------------------------------
VIABLE_MANIFEST = dict(
VALID_MANIFEST,
internet={"viable": True, "port": 445},
)
def test_get_internet_not_installed(gateway_paths):
status, body = api._do_get_internet("ghost")
assert status == 404
def _install_app(fake_dirs, manifest, no_docker, no_systemd_run):
apps, bundled = fake_dirs
_write_bundled(bundled, manifest["name"], manifest=manifest)
status, body = api._do_install(manifest["name"])
assert status == 202, body
return apps / manifest["name"]
def test_get_internet_not_viable_app(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VALID_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_get_internet("fileshare")
assert status == 404
assert "not internet-viable" in body["error"]
def test_get_internet_viable_app_not_published(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_get_internet("fileshare")
assert status == 200
assert body == {"enabled": False}
def test_get_internet_viable_app_published(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
api._save_internet_routes(
{"fileshare": {"route_id": "r1", "public_url": "https://fs.example.com/"}}
)
status, body = api._do_get_internet("fileshare")
assert status == 200
assert body == {"enabled": True, "public_url": "https://fs.example.com/"}
def test_set_internet_requires_enabled_bool(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_set_internet("fileshare", {})
assert status == 400
def test_set_internet_rejects_non_viable_app(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VALID_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "x.example.com"}
)
assert status == 400
assert "not internet-viable" in body["error"]
def test_set_internet_requires_gateway_connection(
gateway_paths, fake_dirs, no_docker, no_systemd_run
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "x.example.com"}
)
assert status == 409
assert "not connected" in body["error"]
def test_set_internet_enable_requires_subdomain(
gateway_paths, fake_dirs, no_docker, no_systemd_run
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
status, body = api._do_set_internet("fileshare", {"enabled": True})
assert status == 400
assert "subdomain" in body["error"]
def test_set_internet_enable_happy_path(
gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
calls = []
def fake_publish(app_name, subdomain, port):
calls.append((app_name, subdomain, port))
return {"route_id": "r1", "public_url": "https://fs.example.com/"}
monkeypatch.setattr(gateway_paths, "publish_route", fake_publish)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "fs.example.com"}
)
assert status == 200
assert body == {"enabled": True, "public_url": "https://fs.example.com/"}
assert calls == [("fileshare", "fs.example.com", 445)]
assert api._internet_routes()["fileshare"]["route_id"] == "r1"
def test_set_internet_enable_surfaces_gateway_error(
gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
def boom(app_name, subdomain, port):
raise gateway_paths.GatewayError("subdomain already in use")
monkeypatch.setattr(gateway_paths, "publish_route", boom)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "fs.example.com"}
)
assert status == 502
assert "subdomain already in use" in body["error"]
assert api._internet_routes() == {}
def test_set_internet_disable_happy_path(
gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
api._save_internet_routes(
{"fileshare": {"route_id": "r1", "public_url": "https://fs.example.com/"}}
)
calls = []
monkeypatch.setattr(gateway_paths, "unpublish_route", lambda route_id: calls.append(route_id))
status, body = api._do_set_internet("fileshare", {"enabled": False})
assert status == 200
assert body == {"enabled": False}
assert calls == ["r1"]
assert api._internet_routes() == {}
def test_set_internet_disable_when_not_published_is_a_noop(
gateway_paths, fake_dirs, no_docker, no_systemd_run
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
status, body = api._do_set_internet("fileshare", {"enabled": False})
assert status == 200
assert body == {"enabled": False}

View file

@ -87,7 +87,7 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch, t
}
)
for pkg in ("caddy", "avahi", "nss-mdns"):
for pkg in ("caddy", "avahi", "nss-mdns", "wireguard-tools"):
assert pkg in cfg["packages"]
# Packaged units go in `services` (enabled before custom_commands runs);
# our own units don't exist at that point, so they must be enabled from

View file

@ -63,6 +63,36 @@ def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
assert m["settings"][0]["name"] == "SMB_USER"
assert m["settings"][0]["required"] is True
assert m["settings"][0]["default"] == "furtka"
assert m["internet"] == {"viable": False, "port": None}
def test_gateway_connect_bg_dispatches_to_run_connect(tmp_path, monkeypatch):
"""CLI `gateway connect-bg <url> <token> <name>` must call
gateway.run_connect(url, token, name) — the entry point the HTTP API
fires via systemd-run for POST /api/gateway/connect."""
_set_env(monkeypatch, tmp_path)
from furtka import gateway
called = []
monkeypatch.setattr(
gateway, "run_connect", lambda url, token, name: called.append((url, token, name))
)
rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"])
assert rc == 0
assert called == [("https://gw.example.com", "reg-token", "my-box")]
def test_gateway_connect_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
from furtka import gateway
def boom(url, token, name):
raise RuntimeError("gateway unreachable")
monkeypatch.setattr(gateway, "run_connect", boom)
rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"])
assert rc == 1
assert "gateway connect-bg failed" in capsys.readouterr().err
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):

350
tests/test_gateway.py Normal file
View file

@ -0,0 +1,350 @@
"""Tests for furtka.gateway — the box-side register/rotate/deregister client.
Same shape as test_updater.py's network tests: monkeypatch
urllib.request.urlopen directly rather than standing up a real server.
"""
from __future__ import annotations
import json
import urllib.error
import pytest
from furtka import gateway
class _FakeResp:
def __init__(self, body: dict):
self._body = json.dumps(body).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
@pytest.fixture
def gw(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(tmp_path / "gateway-connect-state.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(tmp_path / "gateway-connect.lock"))
import importlib
from furtka import paths as p
importlib.reload(p)
importlib.reload(gateway)
return gateway
def test_load_config_returns_none_when_never_registered(gw):
assert gw.load_config() is None
def test_register_saves_config(gw, monkeypatch):
calls = []
def fake_urlopen(req, timeout):
calls.append((req.full_url, json.loads(req.data)))
return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
config = gw.register("https://gw.example.com/", "reg-token", "my-box")
assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped
assert config["box_id"] == "abc123"
assert config["box_token"] == "abc123.secret"
assert config["wg"] == WG
assert gw.load_config() == config
url, body = calls[0]
assert url == "https://gw.example.com/v1/boxes/register"
assert body == {"registration_token": "reg-token", "box_name": "my-box"}
def test_register_config_file_is_0600(gw, monkeypatch, tmp_path):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
from furtka.paths import gateway_config_file
assert oct(gateway_config_file().stat().st_mode)[-3:] == "600"
def test_register_raises_gateway_error_on_http_error(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError, match="HTTP 401"):
gw.register("https://gw.example.com", "bad-token", "my-box")
assert gw.load_config() is None
def test_rotate_token_updates_stored_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_rotate(req, timeout):
assert req.headers.get("Authorization") == "Bearer abc.old"
assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token"
return _FakeResp({"box_token": "abc.new"})
monkeypatch.setattr("urllib.request.urlopen", fake_rotate)
new_token = gw.rotate_token()
assert new_token == "abc.new"
assert gw.load_config()["box_token"] == "abc.new"
def test_rotate_token_without_prior_registration_raises(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.rotate_token()
def test_deregister_calls_gateway_and_clears_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_deregister(req, timeout):
calls.append(req.full_url)
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_deregister)
gw.deregister()
assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"]
assert gw.load_config() is None
def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch):
calls = []
monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1))
gw.deregister()
assert calls == []
def test_publish_route_requires_connection(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.publish_route("vaultwarden", "vault.example.com", 8081)
def test_publish_route_posts_to_routes_endpoint(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_publish(req, timeout):
assert req.full_url == "https://gw.example.com/v1/routes"
assert req.headers.get("Authorization") == "Bearer abc.x"
assert json.loads(req.data) == {
"app_name": "vaultwarden",
"subdomain": "vault.example.com",
"port": 8081,
}
return _FakeResp({"route_id": "r1", "public_url": "https://vault.example.com/"})
monkeypatch.setattr("urllib.request.urlopen", fake_publish)
result = gw.publish_route("vaultwarden", "vault.example.com", 8081)
assert result == {"route_id": "r1", "public_url": "https://vault.example.com/"}
def test_unpublish_route_requires_connection(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.unpublish_route("r1")
def test_unpublish_route_calls_delete_endpoint(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_delete(req, timeout):
calls.append((req.full_url, req.get_method(), req.headers.get("Authorization")))
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_delete)
gw.unpublish_route("r1")
assert calls == [("https://gw.example.com/v1/routes/r1", "DELETE", "Bearer abc.x")]
def test_write_and_read_state_round_trip(gw):
gw.write_state("registering")
s = gw.read_state()
assert s["stage"] == "registering"
assert "updated_at" in s
def test_read_state_missing_file_returns_empty_dict(gw):
assert gw.read_state() == {}
def test_acquire_lock_contention(gw):
first = gw.acquire_lock()
try:
with pytest.raises(gateway.GatewayError, match="already in progress"):
gw.acquire_lock()
finally:
first.close()
def test_run_connect_happy_path(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
tunnel_calls = []
monkeypatch.setattr(
gw.wireguard,
"set_tunnel_enabled",
lambda enabled, wg=None: tunnel_calls.append((enabled, wg)),
)
gw.run_connect("https://gw.example.com", "tok", "my-box")
assert tunnel_calls == [(True, WG)]
assert gw.read_state()["stage"] == "done"
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_registration_failure_writes_error_state(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
gw.run_connect("https://gw.example.com", "bad-tok", "my-box")
state = gw.read_state()
assert state["stage"] == "error"
assert "401" in state["error"]
assert gw.load_config() is None
def test_run_connect_tunnel_failure_writes_error_state(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
def failing_tunnel(enabled, wg=None):
raise gw.wireguard.WireguardError("systemctl failed: bad config")
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel)
gw.run_connect("https://gw.example.com", "tok", "my-box")
state = gw.read_state()
assert state["stage"] == "error"
assert "bad config" in state["error"]
# Registration itself succeeded — config is still saved even though the
# tunnel failed to come up, so a retry doesn't need to re-register.
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_retry_after_tunnel_failure_reuses_registration(gw, monkeypatch):
register_calls = []
def urlopen(req, timeout):
register_calls.append(req.full_url)
return _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", urlopen)
def failing_tunnel(enabled, wg=None):
raise gw.wireguard.WireguardError("systemctl failed: unit missing")
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel)
gw.run_connect("https://gw.example.com", "tok", "my-box")
assert gw.read_state()["stage"] == "error"
tunnel_calls = []
monkeypatch.setattr(
gw.wireguard,
"set_tunnel_enabled",
lambda enabled, wg=None: tunnel_calls.append((enabled, wg)),
)
gw.run_connect("https://gw.example.com/", "tok", "my-box")
# Registered exactly once; the retry only brought the tunnel up.
assert register_calls == ["https://gw.example.com/v1/boxes/register"]
assert tunnel_calls == [(True, WG)]
assert gw.read_state()["stage"] == "done"
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_refuses_a_different_gateway_while_registered(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def must_not_call(req, timeout):
raise AssertionError("must not register with a second gateway")
monkeypatch.setattr("urllib.request.urlopen", must_not_call)
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", lambda enabled, wg=None: None)
gw.run_connect("https://other.example.com", "tok", "box")
state = gw.read_state()
assert state["stage"] == "error"
assert "disconnect first" in state["error"]
assert gw.load_config()["gateway_url"] == "https://gw.example.com"
def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def raising(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError):
gw.deregister()
# Local config must survive so the caller can retry later.
assert gw.load_config() is not None

192
tests/test_wireguard.py Normal file
View file

@ -0,0 +1,192 @@
"""Tests for furtka.wireguard — tunnel-config write/remove + systemctl toggle.
Same shape as test_https.py: stub the systemctl call so we assert the conf
file is written/removed and that a reload failure rolls the conf file back
to its pre-call state.
"""
import subprocess
import pytest
from furtka import wireguard
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
def test_conf_content_shape():
content = wireguard._conf_content(WG)
assert "[Interface]" in content
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
assert "Address = 10.8.0.2/24" in content
assert "[Peer]" in content
assert "PublicKey = c2VydmVyLXB1YmxpYy1rZXk=" in content
assert "Endpoint = gateway.example.com:51820" in content
assert "AllowedIPs = 10.8.0.0/24" in content
assert "PersistentKeepalive = 25" in content
def test_conf_content_omits_preshared_key_when_absent():
# WG has no "preshared_key" key at all — the v14-gateway shape.
content = wireguard._conf_content(WG)
assert "PresharedKey" not in content
def test_conf_content_includes_preshared_key_when_present():
# v15-gateway shape (see furtka-gateway's wgeasy_v15.py) — the server
# enforces this even if the client's own conf omits it, so dropping it
# here would break the handshake silently.
wg = {**WG, "preshared_key": "cHJlc2hhcmVkLWtleQ=="}
content = wireguard._conf_content(wg)
assert "PresharedKey = cHJlc2hhcmVkLWtleQ==" in content
# Must sit between PublicKey and Endpoint, inside [Peer].
peer_section = content.split("[Peer]\n", 1)[1]
assert peer_section.index("PublicKey") < peer_section.index(
"PresharedKey"
) < peer_section.index("Endpoint")
def test_enable_requires_wg_config(tmp_path):
with pytest.raises(ValueError, match="wg config is required"):
wireguard.set_tunnel_enabled(True, conf_path=tmp_path / "furtka-gw.conf")
def test_enable_surfaces_clean_error_on_unreadable_conf_dir(tmp_path):
restricted = tmp_path / "restricted"
restricted.mkdir(mode=0o000)
try:
with pytest.raises(wireguard.WireguardError, match="could not access"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=restricted / "furtka-gw.conf")
finally:
restricted.chmod(0o700)
def test_default_conf_path_uses_env_override(monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_WIREGUARD_CONF", str(tmp_path / "custom.conf"))
assert wireguard.default_conf_path() == tmp_path / "custom.conf"
def test_default_conf_path_default_when_unset(monkeypatch):
monkeypatch.delenv("FURTKA_WIREGUARD_CONF", raising=False)
assert wireguard.default_conf_path() == wireguard.DEFAULT_CONF_PATH
def test_enable_writes_conf_and_calls_reload(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
calls = []
def fake_reload(action):
calls.append(action)
result = wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=fake_reload)
assert result is True
assert calls == ["enable"]
content = conf_path.read_text()
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
assert oct(conf_path.stat().st_mode)[-3:] == "600"
def test_disable_removes_conf_and_calls_reload(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
calls = []
result = wireguard.set_tunnel_enabled(
False, conf_path=conf_path, reload=lambda action: calls.append(action)
)
assert result is False
assert calls == ["disable"]
assert not conf_path.exists()
def test_disable_is_idempotent_when_already_off(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
result = wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=lambda action: None)
assert result is False
assert not conf_path.exists()
def test_reload_failure_rolls_back_enable(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
def failing_reload(action):
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(wireguard.WireguardError, match="systemctl failed: bad config"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=failing_reload)
# No conf existed before this call, so none should exist after rollback.
assert not conf_path.exists()
def test_reload_failure_rolls_back_disable(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
original = wireguard._conf_content(WG)
conf_path.write_text(original)
def failing_reload(action):
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(wireguard.WireguardError):
wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=failing_reload)
assert conf_path.read_text() == original
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
def missing_systemctl(action):
raise FileNotFoundError(2, "No such file", "systemctl")
with pytest.raises(wireguard.WireguardError, match="systemctl not available"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=missing_systemctl)
assert not conf_path.exists()
def test_status_not_configured(tmp_path):
s = wireguard.status(conf_path=tmp_path / "does-not-exist.conf")
assert s == {"configured": False, "latest_handshake": None}
def test_status_survives_unreadable_conf_path(tmp_path):
"""Regression: an inaccessible parent directory raises PermissionError
even for a path that doesn't exist yet — confirmed by actually hitting
this against a real box's /etc/wireguard/ as a non-root user, which
crashed the whole /api/gateway/status request with a raw traceback.
A read-only status check must degrade to "not configured", never 500.
"""
restricted = tmp_path / "restricted"
restricted.mkdir(mode=0o000)
try:
s = wireguard.status(conf_path=restricted / "furtka-gw.conf")
assert s == {"configured": False, "latest_handshake": None}
finally:
restricted.chmod(0o700) # so tmp_path cleanup can remove it
def test_status_configured_reports_handshake(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
s = wireguard.status(conf_path=conf_path, wg_show=lambda: "1234567890")
assert s == {"configured": True, "latest_handshake": "1234567890"}
def test_status_configured_but_wg_tools_missing(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
def missing_wg():
raise FileNotFoundError(2, "No such file", "wg")
s = wireguard.status(conf_path=conf_path, wg_show=missing_wg)
assert s == {"configured": True, "latest_handshake": None}

View file

@ -469,6 +469,9 @@ def build_archinstall_config(s):
"caddy",
"avahi",
"nss-mdns",
# furtka-gateway tunnel — furtka/wireguard.py drives
# wg-quick@furtka-gw.service, which ships in this package.
"wireguard-tools",
# Resource manager runtime — pure-stdlib Python, no pip needed
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
"python",