fix: bug scan — API hardening, safer disk picker, updater rollback, friendlier installer #19
8 changed files with 207 additions and 58 deletions
|
|
@ -30,6 +30,8 @@ new=$(
|
|||
fi
|
||||
echo
|
||||
echo " Then follow the wizard to install Furtka on this machine."
|
||||
echo " The install downloads packages, so this machine needs a network"
|
||||
echo " cable with internet access. Wi-Fi is not set up automatically."
|
||||
echo
|
||||
echo " Need a shell? Log in as root (no password)."
|
||||
echo
|
||||
|
|
|
|||
|
|
@ -331,3 +331,62 @@ def test_step2_rejects_disk_not_offered(monkeypatch):
|
|||
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
|
||||
assert resp.status_code == 302
|
||||
assert app_module.settings["boot_drive"] == "/dev/sda"
|
||||
|
||||
|
||||
def test_validate_step1_lowercases_names():
|
||||
errors, values = validate_step1(
|
||||
{
|
||||
"hostname": "Furtka",
|
||||
"username": "Daniel",
|
||||
"password": "topsecretpw",
|
||||
"password2": "topsecretpw",
|
||||
"language": "de",
|
||||
}
|
||||
)
|
||||
assert errors == []
|
||||
assert values["hostname"] == "furtka"
|
||||
assert values["username"] == "daniel"
|
||||
|
||||
|
||||
def test_validate_step1_rejects_reserved_usernames():
|
||||
for name in ("root", "caddy", "docker", "nobody"):
|
||||
errors, _ = validate_step1(
|
||||
{
|
||||
"hostname": "furtka",
|
||||
"username": name,
|
||||
"password": "topsecretpw",
|
||||
"password2": "topsecretpw",
|
||||
"language": "en",
|
||||
}
|
||||
)
|
||||
assert len(errors) == 1 and "reserved" in errors[0], name
|
||||
|
||||
|
||||
def test_post_install_sets_wait_online_any(monkeypatch):
|
||||
import base64
|
||||
|
||||
import app as app_module
|
||||
|
||||
cmds = app_module._post_install_commands("h", "u", "pw12345678")
|
||||
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
|
||||
assert len(hits) == 1
|
||||
payload = hits[0].split("printf %s ")[1].split(" |")[0]
|
||||
assert "--any" in base64.b64decode(payload).decode()
|
||||
|
||||
|
||||
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
|
||||
app_module = _install_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
|
||||
client = app_module.app.test_client()
|
||||
for path in ("/install/step1", "/install/step2", "/install/overview"):
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/install/log")
|
||||
|
||||
|
||||
def test_error_page_offers_retry(monkeypatch, tmp_path):
|
||||
app_module = _install_env(monkeypatch, tmp_path)
|
||||
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
|
||||
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
|
||||
assert "Try again" in html
|
||||
assert "next-steps" not in html
|
||||
|
|
|
|||
|
|
@ -78,6 +78,35 @@ settings = {
|
|||
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
# Accounts that already exist on the installed system (base + the packages
|
||||
# we install). Picking one of these either fails user creation mid-install
|
||||
# (root) or makes the admin collide with a service account (caddy, docker…).
|
||||
RESERVED_USERNAMES = frozenset(
|
||||
{
|
||||
"root",
|
||||
"bin",
|
||||
"daemon",
|
||||
"mail",
|
||||
"ftp",
|
||||
"http",
|
||||
"nobody",
|
||||
"dbus",
|
||||
"git",
|
||||
"uuidd",
|
||||
"avahi",
|
||||
"caddy",
|
||||
"docker",
|
||||
"polkitd",
|
||||
"rtkit",
|
||||
"systemd-coredump",
|
||||
"systemd-journal-remote",
|
||||
"systemd-network",
|
||||
"systemd-oom",
|
||||
"systemd-resolve",
|
||||
"systemd-timesync",
|
||||
}
|
||||
)
|
||||
|
||||
# Ordered phase markers for the install progress bar. Each tuple is
|
||||
# (substring to search for in the archinstall log, progress percent when
|
||||
# reached, user-facing label). Pick the furthest phase whose marker is
|
||||
|
|
@ -148,18 +177,26 @@ def parse_install_progress(log, exit_code=None):
|
|||
|
||||
def validate_step1(form):
|
||||
errors = []
|
||||
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
|
||||
# lowercase, people type names the way they write them.
|
||||
values = {
|
||||
"hostname": form.get("hostname", "").strip(),
|
||||
"username": form.get("username", "").strip(),
|
||||
"hostname": form.get("hostname", "").strip().lower(),
|
||||
"username": form.get("username", "").strip().lower(),
|
||||
"password": form.get("password", ""),
|
||||
"language": form.get("language", ""),
|
||||
}
|
||||
password2 = form.get("password2", "")
|
||||
|
||||
if not HOSTNAME_RE.match(values["hostname"]):
|
||||
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
|
||||
errors.append(
|
||||
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
|
||||
)
|
||||
if not USERNAME_RE.match(values["username"]):
|
||||
errors.append("Username must start with a letter or underscore, lowercase only.")
|
||||
errors.append(
|
||||
"The username must start with a letter and can only use letters, digits, - and _."
|
||||
)
|
||||
elif values["username"] in RESERVED_USERNAMES:
|
||||
errors.append(f"“{values['username']}” is reserved by the system — please pick another.")
|
||||
if len(values["password"]) < 8:
|
||||
errors.append("Password must be at least 8 characters.")
|
||||
if values["password"] != password2:
|
||||
|
|
@ -453,6 +490,16 @@ def _post_install_commands(hostname, admin_username, admin_password):
|
|||
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
||||
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
||||
nss_sed,
|
||||
# The installed system inherits the live ISO's networkd config
|
||||
# (DHCP on every Ethernet + Wi-Fi link) but not its wait-online
|
||||
# drop-in, so a board with a second, unplugged NIC stalled boot for
|
||||
# up to 2 minutes before network-online.target. Any one link is
|
||||
# enough for a home server.
|
||||
_write_file_cmd(
|
||||
"/etc/systemd/system/systemd-networkd-wait-online.service.d/any-interface.conf",
|
||||
"[Service]\nExecStart=\n"
|
||||
"ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any\n",
|
||||
),
|
||||
# Resource manager bootstrap: extract tarball → versions/<ver>/,
|
||||
# symlink current, install wrapper, systemctl-link unit files.
|
||||
*_resource_manager_commands(),
|
||||
|
|
@ -585,6 +632,16 @@ def spawn_archinstall(config_path, creds_path, log_path):
|
|||
)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _stay_on_progress_while_installing():
|
||||
# While archinstall runs, the wizard pages would let a second browser
|
||||
# (or Back) edit settings that no longer apply — show the progress.
|
||||
running = _install_proc is not None and _install_proc.poll() is None
|
||||
if running and request.endpoint in ("install_step_1", "install_step_2", "install_overview"):
|
||||
return redirect(url_for("install_log_view"))
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return redirect(url_for("install_step_1"))
|
||||
|
|
@ -609,6 +666,11 @@ def install_step_1():
|
|||
)
|
||||
|
||||
|
||||
# Size/type of the disk picked in step 2, for the confirm page (re-probing
|
||||
# would re-run smartctl on every disk).
|
||||
_selected_drive: dict = {}
|
||||
|
||||
|
||||
@app.route("/install/step2", methods=["GET", "POST"])
|
||||
def install_step_2():
|
||||
drives = list_scored_devices()
|
||||
|
|
@ -616,8 +678,11 @@ def install_step_2():
|
|||
if request.method == "POST":
|
||||
boot_drive = request.form.get("boot_drive", "").strip()
|
||||
# Only accept a disk we actually offered — never a free-form path.
|
||||
if any(d["name"] == boot_drive and d["usable"] for d in drives):
|
||||
picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
|
||||
if picked:
|
||||
settings["boot_drive"] = boot_drive
|
||||
_selected_drive.clear()
|
||||
_selected_drive.update(picked)
|
||||
return redirect(url_for("install_overview"))
|
||||
error = "Please pick one of the disks below."
|
||||
return render_template(
|
||||
|
|
@ -630,8 +695,14 @@ def install_step_2():
|
|||
|
||||
@app.route("/install/overview")
|
||||
def install_overview():
|
||||
if not settings["boot_drive"] or not settings["username"]:
|
||||
return redirect(url_for("install_step_1"))
|
||||
masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
|
||||
return render_template("install/overview.html", settings=masked)
|
||||
drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
|
||||
language = LANGUAGES.get(settings["language"], {}).get("label", settings["language"])
|
||||
return render_template(
|
||||
"install/overview.html", settings=masked, drive=drive, language_label=language
|
||||
)
|
||||
|
||||
|
||||
# The running archinstall process, so a second click (double-submit, Back +
|
||||
|
|
|
|||
|
|
@ -18,10 +18,23 @@
|
|||
</h1>
|
||||
<p class="lede" id="install-lede">
|
||||
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
|
||||
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
|
||||
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
|
||||
{% elif progress.status == "error" %}{{ progress.phase }}.
|
||||
{% else %}This takes 5–20 minutes, depending on your internet speed. Don't power off the machine — it's fine to close this page and come back.{% endif %}
|
||||
</p>
|
||||
|
||||
{% if progress.status == "error" %}
|
||||
<p>Your settings are kept, so you can simply try again. If it fails the same way twice,
|
||||
open <em>Show details</em> below — the last lines of the log say what went wrong.</p>
|
||||
<form method="post" action="{{ url_for('install_run') }}"
|
||||
onsubmit="this.querySelector('button').disabled = true;">
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Try again</button>
|
||||
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">Pick a different disk</a>
|
||||
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">Change settings</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if progress.status == "done" %}
|
||||
{# The USB-stick step has to be read *before* the click: the machine reboots
|
||||
3 s after it, and most BIOSes boot the stick before the disk — so the
|
||||
|
|
@ -43,7 +56,7 @@
|
|||
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
|
||||
<div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
|
||||
</div>
|
||||
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
|
||||
<p class="progress-phase" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
|
||||
|
||||
<details class="log-details">
|
||||
<summary>Show details</summary>
|
||||
|
|
@ -59,17 +72,7 @@
|
|||
var phaseEl = document.getElementById('progress-phase');
|
||||
var percentEl = document.getElementById('progress-percent');
|
||||
var logEl = document.getElementById('install-log');
|
||||
var headingEl = document.getElementById('install-heading');
|
||||
var ledeEl = document.getElementById('install-lede');
|
||||
|
||||
var HEADINGS = {
|
||||
done: 'Furtka is ready',
|
||||
error: 'Installation hit a snag',
|
||||
};
|
||||
var LEDES = {
|
||||
done: 'Installation finished. Remove the USB / eject the installer image, then reboot.',
|
||||
error: 'Something went wrong. Open the details below and share them so we can help.',
|
||||
};
|
||||
var failures = 0;
|
||||
|
||||
function tick() {
|
||||
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
|
||||
|
|
@ -77,6 +80,7 @@
|
|||
.then(function (data) {
|
||||
var p = data.progress;
|
||||
bar.style.width = p.percent + '%';
|
||||
bar.parentNode.setAttribute('aria-valuenow', p.percent);
|
||||
phaseEl.textContent = p.phase;
|
||||
percentEl.textContent = p.percent;
|
||||
if (logEl.textContent !== data.log) {
|
||||
|
|
@ -84,21 +88,23 @@
|
|||
logEl.textContent = data.log || '(waiting for install to start)';
|
||||
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
if (p.status === 'done') {
|
||||
// Reload so the server-rendered Done state (with the
|
||||
// Reboot button) replaces the running-state markup.
|
||||
if (p.status === 'done' || p.status === 'error') {
|
||||
// Reload so the server-rendered Done / error state (with
|
||||
// its Reboot or Try-again buttons) replaces the
|
||||
// running-state markup.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (p.status === 'error') {
|
||||
bar.classList.add('progress-bar-error');
|
||||
headingEl.textContent = HEADINGS.error;
|
||||
ledeEl.textContent = LEDES.error;
|
||||
return;
|
||||
}
|
||||
failures = 0;
|
||||
setTimeout(tick, 3000);
|
||||
})
|
||||
.catch(function () { setTimeout(tick, 3000); });
|
||||
.catch(function () {
|
||||
failures += 1;
|
||||
if (failures >= 2) {
|
||||
phaseEl.textContent = 'Lost connection to the installer — retrying…';
|
||||
}
|
||||
setTimeout(tick, 3000);
|
||||
});
|
||||
}
|
||||
setTimeout(tick, 3000);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -5,28 +5,27 @@
|
|||
|
||||
{% block content %}
|
||||
<h1>Confirm install</h1>
|
||||
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p>
|
||||
<p class="lede">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p>
|
||||
|
||||
<div class="alert alert-warn">
|
||||
This is destructive. All existing data on the selected boot drive will be lost.
|
||||
<div class="alert alert-warn" role="alert">
|
||||
Everything currently on this disk will be deleted and can't be recovered.
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<table>
|
||||
{% for k, v in settings.items() %}
|
||||
<tr>
|
||||
<td>{{ k.replace('_', ' ') }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
|
||||
<tr><td>Username</td><td>{{ settings.username }}</td></tr>
|
||||
<tr><td>Password</td><td>{{ settings.password }}</td></tr>
|
||||
<tr><td>Language</td><td>{{ language_label }}</td></tr>
|
||||
<tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('install_run') }}"
|
||||
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
|
||||
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
|
||||
<button type="submit" class="btn btn-danger">Erase disk and install</button>
|
||||
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@
|
|||
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
|
||||
<p>When the machine comes back up (~1 minute), open Furtka in your browser:</p>
|
||||
<p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p>
|
||||
<p class="muted">If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.</p>
|
||||
<p class="muted">If that address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names),
|
||||
use the IP address instead: it's shown on the machine's screen, or look for a device called
|
||||
<strong>{{ hostname }}</strong> in your router's list of connected devices. The address may differ from the one the installer used.</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Account & hostname</h1>
|
||||
<h1>Name & account</h1>
|
||||
<p class="lede">Set the server name and your administrator account.</p>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert alert-error" role="alert">
|
||||
<strong>Please fix the following:</strong>
|
||||
<ul>
|
||||
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
||||
|
|
@ -18,19 +18,19 @@
|
|||
|
||||
<form method="post" action="{{ url_for('install_step_1') }}">
|
||||
<div class="field">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
|
||||
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span>
|
||||
<label for="hostname">Server name</label>
|
||||
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
|
||||
<span class="hint" id="hostname-hint">You'll open your server at <strong>http://<span id="hostname-preview">{{ values.hostname or "furtka" }}</span>.local</strong>. Letters, numbers and dashes only.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">Admin username</label>
|
||||
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
|
||||
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
||||
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
|
||||
<span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="new-password" />
|
||||
<span class="hint">At least 8 characters.</span>
|
||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
|
||||
<span class="hint" id="password-hint">At least 8 characters. This one password is used for the web login and for the server itself (SSH / console), so pick a good one.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password2">Confirm password</label>
|
||||
|
|
@ -38,15 +38,25 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
<select id="language" name="language" aria-describedby="language-hint">
|
||||
{% for code, lang in languages.items() %}
|
||||
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="hint" id="language-hint">Language and keyboard layout of the installed server. The Furtka pages themselves are in English for now.</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Next — pick boot drive</button>
|
||||
<button type="submit" class="btn btn-primary">Next — pick a disk</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById('hostname');
|
||||
var preview = document.getElementById('hostname-preview');
|
||||
input.addEventListener('input', function () {
|
||||
preview.textContent = input.value.trim().toLowerCase() || 'furtka';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %}
|
||||
{% block title %}Step 2 — Disk · Furtka Installer{% endblock %}
|
||||
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Boot drive</h1>
|
||||
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p>
|
||||
<h1>Pick a disk</h1>
|
||||
<p class="lede">Choose the disk Furtka will be installed on. <strong>Everything on it will be erased.</strong> We pre-select the one that looks fastest and healthiest.</p>
|
||||
|
||||
{% set usable = drives | selectattr("usable") | list %}
|
||||
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
<div class="alert alert-error" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not usable %}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert alert-error" role="alert">
|
||||
{% if drives %}
|
||||
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
|
||||
{% else %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue