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
|
fi
|
||||||
echo
|
echo
|
||||||
echo " Then follow the wizard to install Furtka on this machine."
|
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
|
||||||
echo " Need a shell? Log in as root (no password)."
|
echo " Need a shell? Log in as root (no password)."
|
||||||
echo
|
echo
|
||||||
|
|
|
||||||
|
|
@ -331,3 +331,62 @@ def test_step2_rejects_disk_not_offered(monkeypatch):
|
||||||
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
|
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert app_module.settings["boot_drive"] == "/dev/sda"
|
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])?$")
|
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}$")
|
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
|
# Ordered phase markers for the install progress bar. Each tuple is
|
||||||
# (substring to search for in the archinstall log, progress percent when
|
# (substring to search for in the archinstall log, progress percent when
|
||||||
# reached, user-facing label). Pick the furthest phase whose marker is
|
# 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):
|
def validate_step1(form):
|
||||||
errors = []
|
errors = []
|
||||||
|
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
|
||||||
|
# lowercase, people type names the way they write them.
|
||||||
values = {
|
values = {
|
||||||
"hostname": form.get("hostname", "").strip(),
|
"hostname": form.get("hostname", "").strip().lower(),
|
||||||
"username": form.get("username", "").strip(),
|
"username": form.get("username", "").strip().lower(),
|
||||||
"password": form.get("password", ""),
|
"password": form.get("password", ""),
|
||||||
"language": form.get("language", ""),
|
"language": form.get("language", ""),
|
||||||
}
|
}
|
||||||
password2 = form.get("password2", "")
|
password2 = form.get("password2", "")
|
||||||
|
|
||||||
if not HOSTNAME_RE.match(values["hostname"]):
|
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"]):
|
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:
|
if len(values["password"]) < 8:
|
||||||
errors.append("Password must be at least 8 characters.")
|
errors.append("Password must be at least 8 characters.")
|
||||||
if values["password"] != password2:
|
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.
|
# 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")),
|
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
||||||
nss_sed,
|
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>/,
|
# Resource manager bootstrap: extract tarball → versions/<ver>/,
|
||||||
# symlink current, install wrapper, systemctl-link unit files.
|
# symlink current, install wrapper, systemctl-link unit files.
|
||||||
*_resource_manager_commands(),
|
*_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("/")
|
@app.route("/")
|
||||||
def home():
|
def home():
|
||||||
return redirect(url_for("install_step_1"))
|
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"])
|
@app.route("/install/step2", methods=["GET", "POST"])
|
||||||
def install_step_2():
|
def install_step_2():
|
||||||
drives = list_scored_devices()
|
drives = list_scored_devices()
|
||||||
|
|
@ -616,8 +678,11 @@ def install_step_2():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
boot_drive = request.form.get("boot_drive", "").strip()
|
boot_drive = request.form.get("boot_drive", "").strip()
|
||||||
# Only accept a disk we actually offered — never a free-form path.
|
# 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
|
settings["boot_drive"] = boot_drive
|
||||||
|
_selected_drive.clear()
|
||||||
|
_selected_drive.update(picked)
|
||||||
return redirect(url_for("install_overview"))
|
return redirect(url_for("install_overview"))
|
||||||
error = "Please pick one of the disks below."
|
error = "Please pick one of the disks below."
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|
@ -630,8 +695,14 @@ def install_step_2():
|
||||||
|
|
||||||
@app.route("/install/overview")
|
@app.route("/install/overview")
|
||||||
def 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 ""}
|
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 +
|
# The running archinstall process, so a second click (double-submit, Back +
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,23 @@
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lede" id="install-lede">
|
<p class="lede" id="install-lede">
|
||||||
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
|
{% 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.
|
{% elif progress.status == "error" %}{{ progress.phase }}.
|
||||||
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
|
{% 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>
|
</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" %}
|
{% if progress.status == "done" %}
|
||||||
{# The USB-stick step has to be read *before* the click: the machine reboots
|
{# 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
|
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 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 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>
|
</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">
|
<details class="log-details">
|
||||||
<summary>Show details</summary>
|
<summary>Show details</summary>
|
||||||
|
|
@ -59,17 +72,7 @@
|
||||||
var phaseEl = document.getElementById('progress-phase');
|
var phaseEl = document.getElementById('progress-phase');
|
||||||
var percentEl = document.getElementById('progress-percent');
|
var percentEl = document.getElementById('progress-percent');
|
||||||
var logEl = document.getElementById('install-log');
|
var logEl = document.getElementById('install-log');
|
||||||
var headingEl = document.getElementById('install-heading');
|
var failures = 0;
|
||||||
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.',
|
|
||||||
};
|
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
|
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
|
||||||
|
|
@ -77,6 +80,7 @@
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var p = data.progress;
|
var p = data.progress;
|
||||||
bar.style.width = p.percent + '%';
|
bar.style.width = p.percent + '%';
|
||||||
|
bar.parentNode.setAttribute('aria-valuenow', p.percent);
|
||||||
phaseEl.textContent = p.phase;
|
phaseEl.textContent = p.phase;
|
||||||
percentEl.textContent = p.percent;
|
percentEl.textContent = p.percent;
|
||||||
if (logEl.textContent !== data.log) {
|
if (logEl.textContent !== data.log) {
|
||||||
|
|
@ -84,21 +88,23 @@
|
||||||
logEl.textContent = data.log || '(waiting for install to start)';
|
logEl.textContent = data.log || '(waiting for install to start)';
|
||||||
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||||
}
|
}
|
||||||
if (p.status === 'done') {
|
if (p.status === 'done' || p.status === 'error') {
|
||||||
// Reload so the server-rendered Done state (with the
|
// Reload so the server-rendered Done / error state (with
|
||||||
// Reboot button) replaces the running-state markup.
|
// its Reboot or Try-again buttons) replaces the
|
||||||
|
// running-state markup.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p.status === 'error') {
|
failures = 0;
|
||||||
bar.classList.add('progress-bar-error');
|
|
||||||
headingEl.textContent = HEADINGS.error;
|
|
||||||
ledeEl.textContent = LEDES.error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(tick, 3000);
|
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);
|
setTimeout(tick, 3000);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,27 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Confirm install</h1>
|
<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">
|
<div class="alert alert-warn" role="alert">
|
||||||
This is destructive. All existing data on the selected boot drive will be lost.
|
Everything currently on this disk will be deleted and can't be recovered.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<table>
|
<table>
|
||||||
{% for k, v in settings.items() %}
|
<tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
|
||||||
<tr>
|
<tr><td>Username</td><td>{{ settings.username }}</td></tr>
|
||||||
<td>{{ k.replace('_', ' ') }}</td>
|
<tr><td>Password</td><td>{{ settings.password }}</td></tr>
|
||||||
<td>{{ v }}</td>
|
<tr><td>Language</td><td>{{ language_label }}</td></tr>
|
||||||
</tr>
|
<tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
|
||||||
{% endfor %}
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('install_run') }}"
|
<form method="post" action="{{ url_for('install_run') }}"
|
||||||
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
|
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
|
<button type="submit" class="btn btn-danger">Erase disk and install</button>
|
||||||
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
|
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% 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 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>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><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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Account & hostname</h1>
|
<h1>Name & account</h1>
|
||||||
<p class="lede">Set the server name and your administrator account.</p>
|
<p class="lede">Set the server name and your administrator account.</p>
|
||||||
|
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error" role="alert">
|
||||||
<strong>Please fix the following:</strong>
|
<strong>Please fix the following:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
||||||
|
|
@ -18,19 +18,19 @@
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('install_step_1') }}">
|
<form method="post" action="{{ url_for('install_step_1') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hostname">Hostname</label>
|
<label for="hostname">Server name</label>
|
||||||
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
|
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
|
||||||
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Admin username</label>
|
<label for="username">Admin username</label>
|
||||||
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
|
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
|
||||||
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
<span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required autocomplete="new-password" />
|
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
|
||||||
<span class="hint">At least 8 characters.</span>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password2">Confirm password</label>
|
<label for="password2">Confirm password</label>
|
||||||
|
|
@ -38,15 +38,25 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="language">Language</label>
|
<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() %}
|
{% for code, lang in languages.items() %}
|
||||||
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
|
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div class="actions">
|
<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>
|
</div>
|
||||||
</form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
{% extends "base.html" %}
|
{% 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 step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Boot drive</h1>
|
<h1>Pick a disk</h1>
|
||||||
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p>
|
<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 usable = drives | selectattr("usable") | list %}
|
||||||
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
|
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error" role="alert">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not usable %}
|
{% if not usable %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error" role="alert">
|
||||||
{% if drives %}
|
{% if drives %}
|
||||||
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
|
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue