Compare commits

...

15 commits

Author SHA1 Message Date
5830f8887a Merge pull request 'dev_gateway' (#22) from dev_gateway into main
All checks were successful
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m59s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 23s
Build ISO / build-iso (push) Successful in 20m15s
Reviewed-on: #22
2026-10-07 16:34:47 +02:00
9076b85304 test: simulate permission errors in wireguard tests instead of chmod 000
All checks were successful
CI / lint (pull_request) Successful in 29s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 21s
CI / test (pull_request) Successful in 1m47s
CI runs pytest as root, which ignores directory permissions, so the
0o000-directory tests didn't hit the error path: the enable test went on
to call the real systemctl and failed, and the status test passed only
by accident. Monkeypatch the PermissionError instead, and stub reload so
the enable test can never shell out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:24:51 +02:00
2f6c31a26e style: ruff format tests/test_wireguard.py
Some checks failed
CI / lint (pull_request) Successful in 27s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 23s
CI / test (pull_request) Failing after 1m42s
Fixes the CI format check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:14:24 +02:00
2f7a02f1d1 Merge remote-tracking branch 'origin/main' into dev_gateway
Some checks failed
CI / lint (pull_request) Failing after 28s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 21s
CI / test (pull_request) Failing after 1m45s
# Conflicts:
#	tests/test_api.py
2026-10-07 16:11:54 +02:00
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
43bcf3c4e5 Add internet.viable manifest field and its catalog-side privileged gate
New Manifest.internet field (viable: bool, port: int | None), parsed by
_parse_internet() following _parse_settings/_parse_requires's existing
shape. When viable, port must be one of the manifest's own declared ports
— resolves which entry is the exposable web UI for apps declaring several
(e.g. a DNS service exposing both a resolver port and an admin UI port).

catalog.py's release validation additionally rejects any internet.viable
app whose docker-compose.yaml sets privileged: true. Deliberately not a
network_mode check: a furtka-gateway reaches a host-networked container's
port exactly like a bridge-published one — the real risk is a compromised
internet-facing container also having host device/kernel access, which
privileged: true grants outright.
2026-08-24 13:08:55 +02:00
18 changed files with 2106 additions and 11 deletions

View file

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

View file

@ -81,6 +81,45 @@
</div> </div>
</section> </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> <section>
<h2>Appearance</h2> <h2>Appearance</h2>
<div class="card"> <div class="card">
@ -113,7 +152,6 @@
<a href="https://furtka.org/#planned">Change hostname</a> <a href="https://furtka.org/#planned">Change hostname</a>
<a href="https://furtka.org/#planned">Backup</a> <a href="https://furtka.org/#planned">Backup</a>
<a href="https://furtka.org/#planned">User accounts</a> <a href="https://furtka.org/#planned">User accounts</a>
<a href="https://furtka.org/#planned">Remote access</a>
</div> </div>
</section> </section>
@ -350,6 +388,127 @@
await refreshHttpsStatus(); 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() { async function pollUpdateState() {
try { try {
const r = await fetch('/update-state.json', { cache: 'no-store' }); const r = await fetch('/update-state.json', { cache: 'no-store' });

View file

@ -226,6 +226,59 @@ button.secondary, .btn.secondary {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
button.danger { background: var(--danger); color: #fff; } 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:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); } button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }

View file

@ -370,6 +370,20 @@ async function refresh() {
const openBtn = openHref const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>` ? `<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 ` return `
<div class="app"> <div class="app">
<div class="left"> <div class="left">
@ -377,15 +391,23 @@ async function refresh() {
<div class="meta"> <div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span> <span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span> <span class="desc">${esc(a.description || a.error || '')}</span>
${internetStatus}
</div> </div>
</div> </div>
<div class="buttons"> <div class="buttons">
${openBtn} ${openBtn}
<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>` : ''} ${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> ${internetItem}
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button> <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> <button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div> </div>
</details>
</div>
</div>`; </div>`;
}).join('') }).join('')
: '<div class="empty">No apps installed yet.</div>'; : '<div class="empty">No apps installed yet.</div>';
@ -405,6 +427,78 @@ async function refresh() {
for (const btn of document.querySelectorAll('button[data-op]')) { for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn)); 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) { async function handleButton(op, name, btn) {
@ -683,6 +777,7 @@ def _manifest_summary(m, app_dir=None):
{"app": req.app, "on_install": req.on_install, "on_start": req.on_start} {"app": req.app, "on_install": req.on_install, "on_start": req.on_start}
for req in m.requires for req in m.requires
], ],
"internet": {"viable": m.internet.viable, "port": m.internet.port},
} }
@ -1158,6 +1253,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 = { _POWER_ACTIONS = {
"reboot": "reboot", "reboot": "reboot",
"poweroff": "poweroff", "poweroff": "poweroff",
@ -1520,6 +1817,12 @@ class _Handler(BaseHTTPRequestHandler):
if self.path == "/api/apps/install/status": if self.path == "/api/apps/install/status":
status, body = _do_install_status() status, body = _do_install_status()
return self._json(status, body) 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 # /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
@ -1527,6 +1830,13 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name) status, body = _do_get_settings(name)
return self._json(status, body) 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"}) self._json(404, {"error": "not found"})
def _route_post(self): def _route_post(self):
@ -1585,6 +1895,22 @@ class _Handler(BaseHTTPRequestHandler):
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) 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). # Furtka itself: check + apply endpoints (Phase 2 self-update).
if self.path == "/api/furtka/update/check": if self.path == "/api/furtka/update/check":
status, body = _do_furtka_check() status, body = _do_furtka_check()

View file

@ -34,6 +34,7 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os import os
import re
import shutil import shutil
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -150,6 +151,9 @@ def acquire_lock():
return fh return fh
_PRIVILEGED_RE = re.compile(r"^\s*privileged:\s*true\s*$", re.MULTILINE)
def _validate_staging(staging: Path, expected_version: str) -> None: def _validate_staging(staging: Path, expected_version: str) -> None:
"""Fail hard if the staging tree isn't a well-formed catalog release.""" """Fail hard if the staging tree isn't a well-formed catalog release."""
version_file = staging / _VERSION_FILE version_file = staging / _VERSION_FILE
@ -170,10 +174,23 @@ def _validate_staging(staging: Path, expected_version: str) -> None:
if not manifest_path.exists(): if not manifest_path.exists():
raise CatalogError(f"catalog app {entry.name!r} has no manifest.json") raise CatalogError(f"catalog app {entry.name!r} has no manifest.json")
try: try:
load_manifest(manifest_path, expected_name=entry.name) manifest = load_manifest(manifest_path, expected_name=entry.name)
except ManifestError as e: except ManifestError as e:
raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e
if manifest.internet.viable:
compose_path = entry / "docker-compose.yaml"
if compose_path.exists() and _PRIVILEGED_RE.search(compose_path.read_text()):
# Not a network-mode check — Traefik reaches a host-networked
# container's port exactly like a bridge-published one. The
# real risk of `internet.viable: true` here is a compromised
# internet-facing container also having host device/kernel
# access, which `privileged: true` grants outright.
raise CatalogError(
f"catalog app {entry.name!r}: internet.viable apps must not run "
"privileged (docker-compose.yaml sets privileged: true)"
)
def _atomic_swap(staging: Path) -> None: def _atomic_swap(staging: Path) -> None:
"""Move staging → live catalog, keeping the previous tree as .prev until """Move staging → live catalog, keeping the previous tree as .prev until

View file

@ -46,6 +46,10 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
} }
for req in r.manifest.requires for req in r.manifest.requires
], ],
"internet": {
"viable": r.manifest.internet.viable,
"port": r.manifest.internet.port,
},
} }
if r.manifest if r.manifest
else None, else None,
@ -171,6 +175,25 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
return 0 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: def _cmd_serve(args: argparse.Namespace) -> int:
# Imported lazily so `furtka` startup stays cheap when the user only runs # Imported lazily so `furtka` startup stays cheap when the user only runs
# `app list` or `reconcile` (the common case during tests + scripts). # `app list` or `reconcile` (the common case during tests + scripts).
@ -399,6 +422,20 @@ def build_parser() -> argparse.ArgumentParser:
) )
catalog_status.set_defaults(func=_cmd_catalog_status) 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 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

@ -32,6 +32,21 @@ class Setting:
default: str | None default: str | None
@dataclass(frozen=True)
class Internet:
# Whether this app is safe to expose to the internet through a
# furtka-gateway at all. Only ever a catalog author's self-declaration —
# the gateway independently re-validates the port it's given at publish
# time regardless of what this says (see furtka-gateway's routes.py).
viable: bool
# Which declared `ports` entry is the exposable web UI. Required when
# viable is True — an app declaring several ports (e.g. a DNS service
# exposing both its resolver port and an admin UI port) must say which
# one is actually meant to be reachable from the internet; there is no
# way to infer that from `ports` alone.
port: int | None
@dataclass(frozen=True) @dataclass(frozen=True)
class Requirement: class Requirement:
app: str # name of the required app — must resolve in installed/catalog/bundled app: str # name of the required app — must resolve in installed/catalog/bundled
@ -62,6 +77,7 @@ class Manifest:
# no frontend (CLI-only, background workers) leave this empty. # no frontend (CLI-only, background workers) leave this empty.
open_url: str = "" open_url: str = ""
requires: tuple[Requirement, ...] = field(default_factory=tuple) requires: tuple[Requirement, ...] = field(default_factory=tuple)
internet: Internet = field(default_factory=lambda: Internet(viable=False, port=None))
def volume_name(self, short: str) -> str: def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data" # Namespace volume names so two apps can each declare e.g. "data"
@ -155,6 +171,27 @@ def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[R
return tuple(out) return tuple(out)
def _parse_internet(raw: object, manifest_path: Path, ports: list[int]) -> Internet:
if raw is None:
return Internet(viable=False, port=None)
if not isinstance(raw, dict):
raise ManifestError(f"{manifest_path}: internet must be an object")
viable = bool(raw.get("viable", False))
port = raw.get("port")
if not viable:
return Internet(viable=False, port=None)
if not isinstance(port, int) or isinstance(port, bool):
raise ManifestError(f"{manifest_path}: internet.port must be an integer when viable")
if port not in ports:
raise ManifestError(
f"{manifest_path}: internet.port {port} must be one of the declared ports {ports}"
)
return Internet(viable=True, port=port)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json. """Parse and validate a manifest.json.
@ -192,6 +229,7 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
settings = _parse_settings(raw.get("settings"), path) settings = _parse_settings(raw.get("settings"), path)
requires = _parse_requires(raw.get("requires"), path, name) requires = _parse_requires(raw.get("requires"), path, name)
internet = _parse_internet(raw.get("internet"), path, ports)
open_url_raw = raw.get("open_url", "") open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str): if not isinstance(open_url_raw, str):
@ -209,4 +247,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
settings=settings, settings=settings,
open_url=open_url_raw, open_url=open_url_raw,
requires=requires, requires=requires,
internet=internet,
) )

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 # /settings* so those pages pick up the auth-guard. Caddy also serves
# /style.css and other assets directly from here for the login page. # /style.css and other assets directly from here for the login page.
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www") 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: def apps_dir() -> Path:
@ -44,3 +56,11 @@ def users_file() -> Path:
def static_www_dir() -> Path: def static_www_dir() -> Path:
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW)) 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

@ -1426,6 +1426,328 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
assert "systemd-run" in body["error"] 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}
# --- hardening: names, body size, cross-site POSTs, crash handling ---------- # --- hardening: names, body size, cross-site POSTs, crash handling ----------

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"] assert pkg in cfg["packages"]
# Packaged units go in `services` (enabled before custom_commands runs); # 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 # our own units don't exist at that point, so they must be enabled from

View file

@ -56,23 +56,27 @@ def _make_catalog_tarball(
*, *,
apps: list[tuple[str, dict]] | None = None, apps: list[tuple[str, dict]] | None = None,
extra_entries: list[tuple[str, bytes]] | None = None, extra_entries: list[tuple[str, bytes]] | None = None,
compose_overrides: dict[str, bytes] | None = None,
) -> None: ) -> None:
"""Build a minimal valid catalog tarball. """Build a minimal valid catalog tarball.
`apps` is a list of (folder_name, manifest_dict). Each app folder gets `apps` is a list of (folder_name, manifest_dict). Each app folder gets
a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`. a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`.
`compose_overrides` (folder_name -> bytes) replaces that stub content
for specific apps — for testing catalog-side compose checks (e.g.
`internet.viable` + `privileged: true`) without needing a real image.
`extra_entries` lets tests inject malformed content (path-traversal, `extra_entries` lets tests inject malformed content (path-traversal,
missing VERSION, ...) without rebuilding the helper. missing VERSION, ...) without rebuilding the helper.
""" """
apps = apps if apps is not None else [("fileshare", _manifest())] apps = apps if apps is not None else [("fileshare", _manifest())]
compose_overrides = compose_overrides or {}
buf = io.BytesIO() buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf: with tarfile.open(fileobj=buf, mode="w:gz") as tf:
entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())] entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())]
for folder, m in apps: for folder, m in apps:
entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode())) entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode()))
entries.append( compose = compose_overrides.get(folder, b"services:\n app:\n image: scratch\n")
(f"apps/{folder}/docker-compose.yaml", b"services:\n app:\n image: scratch\n") entries.append((f"apps/{folder}/docker-compose.yaml", compose))
)
entries.append((f"apps/{folder}/icon.svg", b"<svg/>")) entries.append((f"apps/{folder}/icon.svg", b"<svg/>"))
if extra_entries: if extra_entries:
entries.extend(extra_entries) entries.extend(extra_entries)
@ -266,6 +270,76 @@ def test_sync_catalog_refuses_tarball_with_invalid_manifest(catalog, monkeypatch
assert not catalog.catalog_dir().exists() assert not catalog.catalog_dir().exists()
def test_sync_catalog_refuses_privileged_internet_viable_app(catalog, monkeypatch, tmp_path):
import hashlib
viable_manifest = dict(_manifest(), internet={"viable": True, "port": 445})
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(
tarball_path,
"26.6",
apps=[("fileshare", viable_manifest)],
compose_overrides={
"fileshare": b"services:\n app:\n image: scratch\n privileged: true\n"
},
)
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
with pytest.raises(catalog.CatalogError, match="must not run privileged"):
catalog.sync_catalog()
assert not catalog.catalog_dir().exists()
def test_sync_catalog_allows_privileged_app_that_is_not_internet_viable(
catalog, monkeypatch, tmp_path
):
"""privileged: true is only disqualifying for internet.viable apps —
home-assistant's real catalog entry runs privileged for USB/Bluetooth
access and is simply not marked viable."""
import hashlib
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(
tarball_path,
"26.6",
apps=[("fileshare", _manifest())],
compose_overrides={
"fileshare": b"services:\n app:\n image: scratch\n privileged: true\n"
},
)
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
catalog.sync_catalog()
assert catalog.catalog_dir().exists()
def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path): def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path):
"""A failed sync must leave the previous live catalog intact so boxes """A failed sync must leave the previous live catalog intact so boxes
keep working until the next successful sync.""" keep working until the next successful sync."""

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]["name"] == "SMB_USER"
assert m["settings"][0]["required"] is True assert m["settings"][0]["required"] is True
assert m["settings"][0]["default"] == "furtka" 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): 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

View file

@ -292,3 +292,57 @@ def test_requires_rejects_empty_hook_string(tmp_path):
path = _write_app(tmp_path, "z2m", payload) path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="non-empty string"): with pytest.raises(ManifestError, match="non-empty string"):
load_manifest(path) load_manifest(path)
def test_internet_optional_defaults_non_viable(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.internet.viable is False
assert m.internet.port is None
def test_internet_viable_parsed(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 445})
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.internet.viable is True
assert m.internet.port == 445
def test_internet_non_viable_ignores_port(tmp_path):
# A non-viable app doesn't need a port at all — and if one is present
# anyway (e.g. a stale field left behind), it's simply ignored rather
# than validated, since it has no effect either way.
payload = dict(VALID_MANIFEST, internet={"viable": False, "port": 9999})
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.internet.viable is False
assert m.internet.port is None
def test_internet_viable_requires_port(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet.port must be an integer"):
load_manifest(path)
def test_internet_port_must_be_declared(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 9999})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="must be one of the declared ports"):
load_manifest(path)
def test_internet_non_object_rejected(tmp_path):
payload = dict(VALID_MANIFEST, internet=True)
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet must be an object"):
load_manifest(path)
def test_internet_port_must_be_int_not_bool(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": True})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet.port must be an integer"):
load_manifest(path)

204
tests/test_wireguard.py Normal file
View file

@ -0,0 +1,204 @@
"""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
from pathlib import Path
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, monkeypatch):
# Simulated rather than via a 0o000 directory: CI runs as root, which
# ignores directory permissions.
def denied(self, *a, **kw):
raise PermissionError(13, "Permission denied", str(self))
monkeypatch.setattr(Path, "mkdir", denied)
def must_not_reload(action):
raise AssertionError("systemctl must not run when the conf can't be written")
conf_path = tmp_path / "restricted" / "furtka-gw.conf"
with pytest.raises(wireguard.WireguardError, match="could not access"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=must_not_reload)
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, monkeypatch):
"""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.
"""
# Simulated rather than via a 0o000 directory: CI runs as root, which
# ignores directory permissions.
def denied(self):
raise PermissionError(13, "Permission denied", str(self))
monkeypatch.setattr(Path, "is_file", denied)
s = wireguard.status(conf_path=tmp_path / "restricted" / "furtka-gw.conf")
assert s == {"configured": False, "latest_handshake": None}
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

@ -549,6 +549,9 @@ def build_archinstall_config(s):
"caddy", "caddy",
"avahi", "avahi",
"nss-mdns", "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 # Resource manager runtime — pure-stdlib Python, no pip needed
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka. # because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
"python", "python",