feat(furtka): in-browser app settings + ISO recovery-path fixes
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Build ISO / build-iso (push) Successful in 16m54s

End-to-end VM test today (2026-04-15) validated the resource manager
golden path but exposed four things blocking "dein-Vater-tauglich":
no way to configure an app without SSH+editor, no openssh, no nano,
keyboard stuck on US, and a samba healthcheck that cried wolf.

Resource-manager side:
- Manifest schema gains optional `settings` list (name/label/
  description/type/required/default) and `description_long`.
- Bundled-app install opens a form rendered from the manifest;
  submit carries values to `POST /api/apps/install` which writes
  them into the new app's `.env` before the placeholder check runs.
- Installed apps grow an "Einstellungen" button that merges a
  partial settings dict into the existing `.env` (unsubmitted
  password fields = keep current), then reconciles to restart.
- New endpoints: `GET/POST /api/apps/<name>/settings`. Passwords
  are never returned to the client.
- Fileshare manifest declares its SMB_USER/SMB_PASSWORD settings
  in German with help text.

ISO side (so the next build is actually usable on the TTY):
- Add `openssh` to the package list + `sshd` to enabled services.
  `archinstall: true` in 4.x did not install openssh-server.
- Add `nano` — `vim` was the only editor pitched at users, which
  is brutal for first-timers (and was missing anyway).
- Keyboard layout follows the installer language (`de→de`, `pl→pl`,
  `en→us`) instead of hardcoded `us`. A German user couldn't type
  `/` or `-` at the console, making even `sudo nano` painful.
- Disable the dperson/samba healthcheck in the compose override —
  it timed out on every probe while the share itself worked fine.

19 new tests (manifest parsing + settings-merge + two new API
endpoints over live HTTP); 94 total, format + lint clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-15 13:00:02 +02:00
parent 0af2134b7e
commit 61c7ee232c
11 changed files with 820 additions and 33 deletions

View file

@ -7,8 +7,16 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased]
### Added
- **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow an "Einstellungen" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server.
- `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too).
- `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed.
### Changed
- Keyboard layout at the TTY now follows the chosen installer language (`de``de`, `pl``pl`, `en``us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console.
- `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`.
- **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13.
- Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`.
- Python package renamed from `homebase``furtka` in `pyproject.toml`.

View file

@ -17,6 +17,12 @@ services:
image: dperson/samba:latest
restart: unless-stopped
network_mode: host
# The upstream image's HEALTHCHECK times out under normal operation on
# our setup (2026-04-15 VM test — all 6 probes failed while the share
# was reachable from clients). Disable to avoid a permanently-"unhealthy"
# container that scares users reading `docker ps`.
healthcheck:
disable: true
environment:
- USERID=1000
- GROUPID=1000

View file

@ -3,7 +3,25 @@
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
"volumes": ["files"],
"ports": [445, 139],
"icon": "icon.svg"
"icon": "icon.svg",
"settings": [
{
"name": "SMB_USER",
"label": "Benutzername",
"description": "Der Name, mit dem sich Geräte am Share anmelden.",
"type": "text",
"default": "furtka",
"required": true
},
{
"name": "SMB_PASSWORD",
"label": "Passwort",
"description": "Mindestens 8 Zeichen. Wird nie angezeigt — auch dir nicht.",
"type": "password",
"required": true
}
]
}

View file

@ -18,10 +18,12 @@
"packages": [
"docker",
"docker-compose",
"nano",
"vim",
"git",
"htop",
"curl"
"curl",
"openssh"
],
"profile": {
@ -29,7 +31,8 @@
},
"services": [
"docker"
"docker",
"sshd"
],
"network_config": {

View file

@ -26,7 +26,7 @@ _HTML = """<!DOCTYPE html>
<title>Furtka Apps</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; }
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; --border:#2a2d34; }
* { box-sizing:border-box; }
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; background: var(--bg); color: var(--fg); line-height:1.5; }
h1 { font-size: 2rem; margin: 0; }
@ -38,11 +38,28 @@ _HTML = """<!DOCTYPE html>
.name { font-weight: 600; font-size: 1.05rem; }
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
.desc { color: var(--muted); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.buttons { display:flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; }
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; font-size: 0.9rem; }
button.secondary { background: var(--card); color: var(--fg); border: 1px solid var(--border); }
button.danger { background: var(--danger); }
button:disabled { opacity: 0.5; cursor: wait; }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
pre { background: var(--card); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: flex-start; justify-content: center; padding: 2rem 1rem; overflow-y: auto; z-index: 10; }
.modal-backdrop.open { display: flex; }
.modal { background: var(--card); border-radius: 8px; padding: 1.5rem; max-width: 520px; width: 100%; }
.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; }
.modal .long { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; white-space: pre-wrap; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.95rem; }
.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; }
.field input { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.6rem; font-size: 0.95rem; font-family: inherit; }
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.field .req { color: var(--danger); margin-left: 0.25rem; }
.modal .error { background: var(--warn); color: #fed; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; display: none; }
.modal .error.show { display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem; }
</style>
</head>
<body>
@ -59,6 +76,19 @@ _HTML = """<!DOCTYPE html>
<h2>Last action</h2>
<pre id="log">(none yet)</pre>
<div id="modal-backdrop" class="modal-backdrop" role="dialog" aria-modal="true">
<div class="modal">
<h3 id="modal-title"></h3>
<div id="modal-long" class="long"></div>
<div id="modal-error" class="error"></div>
<form id="modal-form" onsubmit="return false;"></form>
<div class="modal-actions">
<button type="button" class="secondary" id="modal-cancel">Cancel</button>
<button type="button" id="modal-submit">Install</button>
</div>
</div>
</div>
<script>
function esc(s) {
const d = document.createElement('div');
@ -66,23 +96,138 @@ function esc(s) {
return d.innerHTML;
}
const modal = {
backdrop: document.getElementById('modal-backdrop'),
title: document.getElementById('modal-title'),
long: document.getElementById('modal-long'),
form: document.getElementById('modal-form'),
error: document.getElementById('modal-error'),
submit: document.getElementById('modal-submit'),
cancel: document.getElementById('modal-cancel'),
current: null, // { name, action: 'install' | 'edit' }
};
modal.cancel.addEventListener('click', () => closeModal());
modal.backdrop.addEventListener('click', (e) => { if (e.target === modal.backdrop) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
function closeModal() {
modal.backdrop.classList.remove('open');
modal.form.innerHTML = '';
modal.error.classList.remove('show');
modal.error.textContent = '';
modal.current = null;
}
async function openSettingsDialog(name, action) {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`);
if (!r.ok) {
document.getElementById('log').textContent =
`[settings ${name}] HTTP ${r.status}\\n` + await r.text();
return;
}
const data = await r.json();
modal.current = { name, action };
modal.title.textContent = data.display_name || data.name;
modal.long.textContent = data.description_long || data.description || '';
modal.long.style.display = modal.long.textContent ? '' : 'none';
modal.submit.textContent = action === 'install' ? 'Install' : 'Save and restart';
if (!data.settings.length) {
// No form fields treat as simple confirm.
modal.form.innerHTML = '<p class="hint">No settings to configure.</p>';
} else {
modal.form.innerHTML = data.settings.map(s => {
const id = `field-${esc(s.name)}`;
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' : '';
return `
<div class="field">
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
${s.description ? `<div class="hint">${esc(s.description)}</div>` : ''}
<input
id="${id}"
name="${esc(s.name)}"
type="${s.type === 'password' ? 'password' : s.type === 'number' ? 'number' : 'text'}"
value="${value}"
placeholder="${esc(placeholder)}"
${s.required && action === 'install' ? 'required' : ''}
autocomplete="off"
spellcheck="false">
</div>`;
}).join('');
}
modal.backdrop.classList.add('open');
const first = modal.form.querySelector('input');
if (first) first.focus();
}
modal.submit.addEventListener('click', submitModal);
async function submitModal() {
if (!modal.current) return;
const { name, action } = modal.current;
const values = {};
for (const input of modal.form.querySelectorAll('input')) {
// In edit mode, skip password fields left blank server keeps existing.
if (action === 'edit' && input.type === 'password' && input.value === '') continue;
values[input.name] = input.value;
}
modal.submit.disabled = true;
const original = modal.submit.textContent;
modal.submit.textContent = action === 'install' ? 'Installing…' : 'Saving…';
modal.error.classList.remove('show');
try {
const url = action === 'install'
? '/api/apps/install'
: `/api/apps/${encodeURIComponent(name)}/settings`;
const body = action === 'install' ? { name, settings: values } : { settings: values };
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await r.json();
document.getElementById('log').textContent =
`[${action} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
if (!r.ok) {
modal.error.textContent = data.error || `HTTP ${r.status}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
return;
}
closeModal();
await refresh();
} catch (e) {
modal.error.textContent = `Network error: ${e.message}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
}
}
async function refresh() {
const [installed, available] = await Promise.all([
fetch('/api/apps').then(r => r.json()),
fetch('/api/bundled').then(r => r.json()),
]);
document.getElementById('installed').innerHTML = installed.length
? installed.map(a => `
? installed.map(a => {
const hasSettings = a.has_settings;
return `
<div class="app">
<div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span>
</div>
<div>
<button data-op="install" data-name="${esc(a.name)}">Reinstall</button>
<div class="buttons">
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Einstellungen</button>` : ''}
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div>
</div>`).join('')
</div>`;
}).join('')
: '<div class="empty">No apps installed yet.</div>';
document.getElementById('available').innerHTML = available.length
? available.map(a => `
@ -95,16 +240,22 @@ async function refresh() {
</div>`).join('')
: '<div class="empty">No bundled apps left to install.</div>';
for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => act(btn.dataset.op, btn.dataset.name, btn));
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
}
}
async function act(op, name, btn) {
async function handleButton(op, name, btn) {
if (op === 'install' || op === 'edit') {
openSettingsDialog(name, op === 'install' ? 'install' : 'edit');
return;
}
// Reinstall + remove are direct actions, no form.
btn.disabled = true;
const original = btn.textContent;
btn.textContent = op === 'install' ? 'Installing…' : 'Removing…';
btn.textContent = op === 'reinstall' ? 'Reinstalling…' : 'Removing…';
try {
const r = await fetch(`/api/apps/${op}`, {
const url = op === 'reinstall' ? '/api/apps/install' : '/api/apps/remove';
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
@ -132,8 +283,10 @@ def _manifest_summary(m):
"display_name": m.display_name,
"version": m.version,
"description": m.description,
"description_long": m.description_long,
"ports": list(m.ports),
"icon": m.icon,
"has_settings": bool(m.settings),
}
@ -169,10 +322,68 @@ def _list_bundled():
return out
def _do_install(name):
def _load_manifest_for(name):
"""Return (manifest, env_values, installed_bool) for an installed or bundled app.
Returns (None, None, False) if the name doesn't resolve anywhere.
"""
target = apps_dir() / name
if target.exists() and (target / "manifest.json").exists():
try:
m = load_manifest(target / "manifest.json")
except ManifestError:
return None, None, False
values = installer.read_env_values(target / ".env")
return m, values, True
bundled = bundled_apps_dir() / name
if bundled.exists() and (bundled / "manifest.json").exists():
try:
m = load_manifest(bundled / "manifest.json")
except ManifestError:
return None, None, False
env_example = bundled / ".env.example"
values = installer.read_env_values(env_example) if env_example.exists() else {}
return m, values, False
return None, None, False
def _do_get_settings(name):
m, values, installed = _load_manifest_for(name)
if m is None:
return 404, {"error": f"{name!r} not found"}
settings_out = []
for s in m.settings:
# Never return password values back to the client — user either keeps
# the current value (blank input means "don't change") or types a new one.
if s.type == "password":
current = ""
else:
current = values.get(s.name, s.default if s.default is not None else "")
settings_out.append(
{
"name": s.name,
"label": s.label,
"description": s.description,
"type": s.type,
"required": s.required,
"default": s.default,
"value": current,
}
)
return 200, {
"name": m.name,
"display_name": m.display_name,
"description": m.description,
"description_long": m.description_long,
"installed": installed,
"settings": settings_out,
}
def _do_install(name, settings=None):
try:
src = installer.resolve_source(name)
target = installer.install_from(src)
target = installer.install_from(src, settings=settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
@ -184,6 +395,29 @@ def _do_install(name):
return (207 if reconciler.has_errors(actions) else 200, payload)
def _do_update_settings(name, settings):
"""Write settings into an installed app's .env and kick off a reinstall.
Only works for already-installed apps use /api/apps/install for fresh
installs (since bundled-app folders under /opt/... are read-only).
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
installer.update_env(name, settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
return (
207 if reconciler.has_errors(actions) else 200,
{
"updated": name,
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
},
)
def _do_remove(name):
target = apps_dir() / name
if not target.exists():
@ -200,6 +434,21 @@ def _do_remove(name):
return 200, {"removed": name, "compose_warning": compose_warning}
def _parse_settings_body(payload):
"""Extract and coerce the settings dict from a JSON body. Returns dict or None."""
s = payload.get("settings")
if s is None:
return None
if not isinstance(s, dict):
return False # sentinel — caller should reject
out = {}
for k, v in s.items():
if not isinstance(k, str):
return False
out[k] = "" if v is None else str(v)
return out
class _Handler(BaseHTTPRequestHandler):
def _json(self, status, payload):
body = json.dumps(payload).encode()
@ -224,6 +473,13 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(200, _list_installed())
if self.path == "/api/bundled":
return self._json(200, _list_bundled())
# /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name)
return self._json(status, body)
self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
@ -233,12 +489,29 @@ class _Handler(BaseHTTPRequestHandler):
payload = json.loads(raw.decode()) if raw else {}
except (UnicodeDecodeError, json.JSONDecodeError):
return self._json(400, {"error": "invalid JSON body"})
if not isinstance(payload, dict):
return self._json(400, {"error": "body must be a JSON object"})
# Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload)
if settings is False or settings is None:
return self._json(400, {"error": "missing or invalid 'settings' object"})
status, body = _do_update_settings(name, settings)
return self._json(status, body)
name = payload.get("name")
if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"})
if self.path == "/api/apps/install":
status, body = _do_install(name)
settings = _parse_settings_body(payload)
if settings is False:
return self._json(400, {"error": "'settings' must be an object"})
status, body = _do_install(name, settings=settings)
elif self.path == "/api/apps/remove":
status, body = _do_remove(name)
else:

View file

@ -30,6 +30,30 @@ def _placeholder_keys(env_path: Path) -> list[str]:
return bad
def _format_env_value(v: str) -> str:
# Quote values that contain whitespace, quotes, or shell metacharacters so
# docker-compose's env substitution reads them back intact. Simple values
# stay unquoted to keep the file readable when a user SSHes in.
if v == "" or any(c in v for c in " \t\"'$`\\#"):
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return v
def write_env(env_path: Path, values: dict[str, str]) -> None:
"""Write a KEY=VALUE .env file atomically with 0600 perms.
Preserves insertion order of `values` so the file reads in the same order
the user filled in the form.
"""
lines = [f"{k}={_format_env_value(v)}" for k, v in values.items()]
body = "\n".join(lines) + ("\n" if lines else "")
tmp = env_path.with_suffix(env_path.suffix + ".tmp")
tmp.write_text(body)
tmp.chmod(0o600)
tmp.replace(env_path)
def resolve_source(source: str) -> Path:
"""Resolve a `furtka app install <source>` arg to a real source folder.
@ -47,13 +71,17 @@ def resolve_source(source: str) -> Path:
raise InstallError(f"{source!r} not found as a path or bundled app")
def install_from(src: Path) -> Path:
def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
"""Copy a validated app folder into /var/lib/furtka/apps/<name>/.
Preserves an existing .env on upgrade. Bootstraps .env from .env.example
on first install if .env wasn't shipped. Refuses to finish (raises
InstallError) if the resulting .env still has placeholder secrets the
target folder is left in place so the user can edit and re-run.
If `settings` is provided, the .env is written from those values (this is
what the Web UI / API does user fills in a form, values land here).
Otherwise, preserves an existing .env on upgrade and bootstraps from
.env.example on first install.
Refuses to finish (raises InstallError) if the resulting .env still has
placeholder secrets the target folder is left in place so the user can
edit and re-run.
Returns the target folder on success.
"""
@ -65,22 +93,56 @@ def install_from(src: Path) -> Path:
except ManifestError as e:
raise InstallError(str(e)) from e
if settings is not None:
declared = {s.name for s in m.settings}
for key in settings:
if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}")
target = apps_dir() / m.name
target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
if not item.is_file():
continue
# Never overwrite an existing user .env.
# Never overwrite an existing user .env — either settings-driven write
# or previous manual edit has authority.
if item.name == ".env" and (target / ".env").exists():
continue
shutil.copy2(item, target / item.name)
# First install with no .env shipped: bootstrap from .env.example so the
# user has something to edit and compose has values to substitute.
env = target / ".env"
env_example = target / ".env.example"
if not env.exists() and env_example.exists():
if settings is not None:
# Merge: start from existing .env (to preserve values the user didn't
# change — e.g. when editing a single password), overlay the submitted
# settings. Manifest-declared fields always appear in the final file.
existing = _read_env(env) if env.exists() else {}
merged: dict[str, str] = {}
for s in m.settings:
if s.name in settings:
merged[s.name] = settings[s.name]
elif s.name in existing:
merged[s.name] = existing[s.name]
elif s.default is not None:
merged[s.name] = s.default
else:
merged[s.name] = ""
# Preserve any non-manifest keys already in .env (forward-compat).
for k, v in existing.items():
if k not in merged:
merged[k] = v
# Required-field check runs on the merged view so that editing just
# one field (e.g. password) doesn't trip on unsubmitted fields that
# already have values in the existing .env.
for s in m.settings:
if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required")
write_env(env, merged)
elif not env.exists() and env_example.exists():
# First install with no settings and no .env shipped: bootstrap from
# .env.example so compose has values to substitute.
shutil.copy2(env_example, env)
# .env carries app secrets — lock to root-only. Done before the placeholder
@ -92,13 +154,84 @@ def install_from(src: Path) -> Path:
if bad:
raise InstallError(
f"{m.name}: {env} still has placeholder values for {', '.join(bad)}. "
f"Edit the file (set real values), then re-run "
f"`furtka app install {m.name}`."
f"Open the app in the Furtka UI to fill in real values, or edit the "
f"file and re-run `furtka app install {m.name}`."
)
return target
def _read_env(env_path: Path) -> dict[str, str]:
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values."""
out: dict[str, str] = {}
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
out[key.strip()] = value
return out
def read_env_values(env_path: Path) -> dict[str, str]:
"""Public wrapper — returns {} if the file doesn't exist."""
if not env_path.exists():
return {}
return _read_env(env_path)
def update_env(name: str, settings: dict[str, str]) -> Path:
"""Merge `settings` into the installed app's .env.
Preserves values the user didn't submit. Validates required fields against
the merged view. Leaves files/manifest untouched for already-installed
apps only. Returns the target folder; caller is expected to run
reconcile to restart the containers.
"""
target = apps_dir() / name
manifest_path = target / "manifest.json"
if not manifest_path.exists():
raise InstallError(f"{name!r} is not installed")
try:
m = load_manifest(manifest_path)
except ManifestError as e:
raise InstallError(str(e)) from e
declared = {s.name for s in m.settings}
for key in settings:
if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}")
env = target / ".env"
existing = _read_env(env) if env.exists() else {}
merged: dict[str, str] = {}
for s in m.settings:
if s.name in settings:
merged[s.name] = settings[s.name]
elif s.name in existing:
merged[s.name] = existing[s.name]
elif s.default is not None:
merged[s.name] = s.default
else:
merged[s.name] = ""
for k, v in existing.items():
if k not in merged:
merged[k] = v
for s in m.settings:
if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required")
write_env(env, merged)
bad = _placeholder_keys(env)
if bad:
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
return target
def remove(name: str) -> Path:
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.

View file

@ -1,5 +1,6 @@
import json
from dataclasses import dataclass
import re
from dataclasses import dataclass, field
from pathlib import Path
REQUIRED_FIELDS = (
@ -12,11 +13,24 @@ REQUIRED_FIELDS = (
"icon",
)
VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
class ManifestError(Exception):
pass
@dataclass(frozen=True)
class Setting:
name: str # env-var name, e.g. SMB_PASSWORD
label: str # human label shown in the UI
description: str # one-sentence help text under the input
type: str # "text" | "password" | "number"
required: bool
default: str | None
@dataclass(frozen=True)
class Manifest:
name: str
@ -26,6 +40,8 @@ class Manifest:
volumes: tuple[str, ...]
ports: tuple[int, ...]
icon: str
description_long: str = ""
settings: tuple[Setting, ...] = field(default_factory=tuple)
def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data"
@ -35,6 +51,47 @@ class Manifest:
return f"furtka_{self.name}_{short}"
def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ManifestError(f"{manifest_path}: settings must be a list")
out: list[Setting] = []
seen: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
raise ManifestError(f"{manifest_path}: settings[{i}] must be an object")
name = item.get("name")
if not isinstance(name, str) or not SETTING_NAME_RE.match(name):
raise ManifestError(
f"{manifest_path}: settings[{i}].name must be an UPPER_SNAKE_CASE env-var name"
)
if name in seen:
raise ManifestError(f"{manifest_path}: settings has duplicate name {name!r}")
seen.add(name)
label = item.get("label", name)
description = item.get("description", "")
type_ = item.get("type", "text")
if type_ not in VALID_SETTING_TYPES:
valid = sorted(VALID_SETTING_TYPES)
raise ManifestError(f"{manifest_path}: settings[{name}].type must be one of {valid}")
required = bool(item.get("required", False))
default = item.get("default")
if default is not None and not isinstance(default, str):
default = str(default)
out.append(
Setting(
name=name,
label=str(label),
description=str(description),
type=type_,
required=required,
default=default,
)
)
return tuple(out)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json.
@ -68,6 +125,8 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
raise ManifestError(f"{path}: ports must be a list of integers")
settings = _parse_settings(raw.get("settings"), path)
return Manifest(
name=name,
display_name=str(raw["display_name"]),
@ -76,4 +135,6 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
volumes=tuple(volumes),
ports=tuple(ports),
icon=str(raw["icon"]),
description_long=str(raw.get("description_long", "")),
settings=settings,
)

View file

@ -153,3 +153,130 @@ def test_http_post_install_unknown_app(fake_dirs):
finally:
server.shutdown()
server.server_close()
# --- Settings endpoints ------------------------------------------------------
SETTINGS_MANIFEST = dict(
VALID_MANIFEST,
description_long="Long help text.",
settings=[
{
"name": "SMB_USER",
"label": "User",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
],
)
def test_get_settings_bundled(fake_dirs):
_, bundled = fake_dirs
_write_bundled(
bundled, "fileshare", manifest=SETTINGS_MANIFEST, env_example="SMB_USER=furtka\n"
)
status, body = api._do_get_settings("fileshare")
assert status == 200
assert body["installed"] is False
assert body["description_long"] == "Long help text."
names = [s["name"] for s in body["settings"]]
assert names == ["SMB_USER", "SMB_PASSWORD"]
# Password values never leak back.
pwd = next(s for s in body["settings"] if s["name"] == "SMB_PASSWORD")
assert pwd["value"] == ""
# Text value comes from .env.example.
user = next(s for s in body["settings"] if s["name"] == "SMB_USER")
assert user["value"] == "furtka"
def test_get_settings_not_found(fake_dirs):
status, _ = api._do_get_settings("ghost")
assert status == 404
def test_install_with_settings_writes_env_via_api(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
status, body = api._do_install(
"fileshare", settings={"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"}
)
assert status == 200, body
apps, _ = fake_dirs
env = (apps / "fileshare" / ".env").read_text()
assert "SMB_USER=alice" in env
assert "SMB_PASSWORD=s3cret" in env
def test_install_with_settings_rejects_empty_required_via_api(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
status, body = api._do_install("fileshare", settings={"SMB_USER": "a", "SMB_PASSWORD": ""})
assert status == 400
assert "SMB_PASSWORD" in body["error"]
def test_update_settings_merges(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
api._do_install("fileshare", settings={"SMB_USER": "alice", "SMB_PASSWORD": "original"})
# Edit flow: submit only the changed password.
status, body = api._do_update_settings("fileshare", {"SMB_PASSWORD": "newpass"})
assert status == 200, body
apps, _ = fake_dirs
env = (apps / "fileshare" / ".env").read_text()
assert "SMB_USER=alice" in env
assert "SMB_PASSWORD=newpass" in env
def test_update_settings_unknown_app(fake_dirs):
status, _ = api._do_update_settings("ghost", {"SMB_USER": "x"})
assert status == 404
def test_http_get_settings_route(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps/fileshare/settings") as r:
assert r.status == 200
data = json.loads(r.read())
assert data["name"] == "fileshare"
assert len(data["settings"]) == 2
finally:
server.shutdown()
server.server_close()
def test_http_post_install_with_settings(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/api/apps/install",
data=json.dumps(
{
"name": "fileshare",
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
assert r.status == 200
apps, _ = fake_dirs
assert "SMB_PASSWORD=s3cret" in (apps / "fileshare" / ".env").read_text()
finally:
server.shutdown()
server.server_close()

View file

@ -189,3 +189,81 @@ def test_placeholder_check_handles_quoted_values(tmp_path, fake_dirs):
)
with pytest.raises(installer.InstallError, match="placeholder"):
installer.install_from(src)
# --- Settings-driven install -------------------------------------------------
SETTINGS_MANIFEST = dict(
VALID_MANIFEST,
settings=[
{
"name": "SMB_USER",
"label": "User",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
],
)
def test_install_with_settings_writes_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=daniel" in env
assert "SMB_PASSWORD=hunter2" in env
def test_install_with_settings_rejects_empty_required(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
# SMB_PASSWORD has no default and is required — submitting empty is rejected.
with pytest.raises(installer.InstallError, match="'SMB_PASSWORD' is required"):
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": ""})
def test_install_with_settings_rejects_unknown_key(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
with pytest.raises(installer.InstallError, match="unknown setting 'FOO'"):
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b", "FOO": "x"})
def test_install_settings_merge_preserves_unchanged(tmp_path, fake_dirs):
# First install with full settings.
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
# Second call with only password — user should keep existing user name.
installer.install_from(src, settings={"SMB_PASSWORD": "newpass"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=daniel" in env
assert "SMB_PASSWORD=newpass" in env
def test_install_settings_applies_defaults_on_first_install(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
# Only password submitted; SMB_USER falls through to its manifest default
# ("furtka") and the required check passes because the merged view has it.
installer.install_from(src, settings={"SMB_PASSWORD": "hunter2"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=furtka" in env
assert "SMB_PASSWORD=hunter2" in env
def test_install_with_settings_writes_0600(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b"})
mode = (apps_dir() / "fileshare" / ".env").stat().st_mode & 0o777
assert mode == 0o600
def test_read_env_values_roundtrip(tmp_path, fake_dirs):
from furtka.installer import read_env_values, write_env
p = tmp_path / ".env"
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
values = read_env_values(p)
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}

View file

@ -88,3 +88,70 @@ def test_ports_wrong_type(tmp_path):
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="ports"):
load_manifest(path)
def test_settings_optional_default_empty(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.settings == ()
assert m.description_long == ""
def test_settings_parsed(tmp_path):
payload = dict(
VALID_MANIFEST,
description_long="Long description with details.",
settings=[
{
"name": "SMB_USER",
"label": "Benutzername",
"description": "Anmeldename",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Passwort", "type": "password", "required": True},
],
)
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.description_long == "Long description with details."
assert len(m.settings) == 2
assert m.settings[0].name == "SMB_USER"
assert m.settings[0].label == "Benutzername"
assert m.settings[0].default == "furtka"
assert m.settings[0].type == "text"
assert m.settings[0].required is True
assert m.settings[1].type == "password"
assert m.settings[1].default is None
def test_settings_reject_lowercase_name(tmp_path):
bad = dict(VALID_MANIFEST, settings=[{"name": "smb_user", "type": "text"}])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="UPPER_SNAKE_CASE"):
load_manifest(path)
def test_settings_reject_unknown_type(tmp_path):
bad = dict(VALID_MANIFEST, settings=[{"name": "FOO", "type": "email"}])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="type must be one of"):
load_manifest(path)
def test_settings_reject_duplicate_name(tmp_path):
bad = dict(
VALID_MANIFEST,
settings=[{"name": "FOO", "type": "text"}, {"name": "FOO", "type": "password"}],
)
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="duplicate"):
load_manifest(path)
def test_settings_non_list_rejected(tmp_path):
bad = dict(VALID_MANIFEST, settings={"FOO": "bar"})
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="settings must be a list"):
load_manifest(path)

View file

@ -12,9 +12,9 @@ from flask import Flask, jsonify, redirect, render_template, request, url_for
app = Flask(__name__)
LANGUAGES = {
"en": {"locale": "en_US.UTF-8", "label": "English"},
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch"},
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski"},
"en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch", "keyboard": "de"},
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski", "keyboard": "pl"},
}
STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka"))
@ -557,10 +557,18 @@ def build_archinstall_config(s):
"packages": [
"docker",
"docker-compose",
# Editors for console/SSH recovery — `nano` is the beginner-friendly
# one, `vim` stays because it's muscle-memory for the dev team.
"nano",
"vim",
"git",
"htop",
"curl",
# Remote access — archinstall 4.x's `ssh: True` flag is flaky about
# actually pulling in openssh, so list it explicitly and enable sshd
# via `services` below. Without this, the documented recovery path
# (SSH in → edit .env) doesn't work.
"openssh",
# Base OS post-install (landing page + mDNS on installed system).
"caddy",
"avahi",
@ -578,6 +586,7 @@ def build_archinstall_config(s):
# units (written in custom_commands) are enabled there instead.
"caddy",
"avahi-daemon",
"sshd",
],
# `gpasswd -a <user> docker` has to stay first — adds the user to
# the docker group once the group exists (archinstall creates users
@ -592,7 +601,11 @@ def build_archinstall_config(s):
"audio_config": None,
"locale_config": {
"locale": LANGUAGES[s["language"]]["locale"],
"keyboard_layout": "us",
# Keyboard layout follows the chosen language so a German user
# doesn't get a US layout at the TTY console (where things like
# `/`, `-`, `=` land on surprising keys and make even `sudo vim`
# painful). `en` falls through to "us" which is what we want.
"keyboard_layout": LANGUAGES[s["language"]]["keyboard"],
},
}