Add dns-stack profile and stable IP prompt
This commit is contained in:
@@ -110,6 +110,13 @@ const firstbootErrorClose = document.getElementById("firstbootErrorClose");
|
||||
const firstbootCopyError = document.getElementById("firstbootCopyError");
|
||||
const firstbootShowRecovery = document.getElementById("firstbootShowRecovery");
|
||||
const firstbootRecovery = document.getElementById("firstbootRecovery");
|
||||
const networkModal = document.getElementById("networkModal");
|
||||
const networkClose = document.getElementById("networkClose");
|
||||
const networkReserveBtn = document.getElementById("networkReserveBtn");
|
||||
const networkStaticBtn = document.getElementById("networkStaticBtn");
|
||||
const networkLaterBtn = document.getElementById("networkLaterBtn");
|
||||
const networkHelpBtn = document.getElementById("networkHelpBtn");
|
||||
const networkProfileHint = document.getElementById("networkProfileHint");
|
||||
const confirmModal = document.getElementById("confirmModal");
|
||||
const confirmTitle = document.getElementById("confirmTitle");
|
||||
const confirmBody = document.getElementById("confirmBody");
|
||||
@@ -172,6 +179,58 @@ const firstbootUI = createFirstbootUI({
|
||||
showToast,
|
||||
});
|
||||
|
||||
const networkState = {
|
||||
shown: false,
|
||||
profile: null,
|
||||
};
|
||||
|
||||
function networkKey(profile) {
|
||||
const id = profile?.id || profile?.name || "profile";
|
||||
return `pikit:network-setup:${id}`;
|
||||
}
|
||||
|
||||
function markNetworkHandled(profile, message) {
|
||||
if (!profile) return;
|
||||
try {
|
||||
localStorage.setItem(networkKey(profile), "done");
|
||||
} catch (err) {
|
||||
// ignore storage failures
|
||||
}
|
||||
if (networkModal) networkModal.classList.add("hidden");
|
||||
networkState.shown = false;
|
||||
if (message) showToast?.(message, "success");
|
||||
}
|
||||
|
||||
function openNetworkModal(profile, force = false) {
|
||||
if (!networkModal) return;
|
||||
if (!profile?.requires_stable_ip && !force) return;
|
||||
if (networkProfileHint) {
|
||||
const name = profile?.name ? `${profile.name} profile` : "This profile";
|
||||
networkProfileHint.textContent = `${name} needs a stable IP address so your devices can always reach DNS.`;
|
||||
}
|
||||
networkModal.classList.remove("hidden");
|
||||
networkState.shown = true;
|
||||
networkState.profile = profile;
|
||||
}
|
||||
|
||||
function shouldShowNetworkPrompt(firstbootData) {
|
||||
if (!firstbootData || firstbootData.state !== "done") return false;
|
||||
const profile = firstbootData.profile || null;
|
||||
if (!profile?.requires_stable_ip) return false;
|
||||
if (networkState.shown) return false;
|
||||
try {
|
||||
return localStorage.getItem(networkKey(profile)) !== "done";
|
||||
} catch (err) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFirstbootStatus(firstbootData) {
|
||||
if (shouldShowNetworkPrompt(firstbootData)) {
|
||||
openNetworkModal(firstbootData.profile);
|
||||
}
|
||||
}
|
||||
|
||||
const statusController = createStatusController({
|
||||
heroStats,
|
||||
servicesGrid,
|
||||
@@ -191,6 +250,7 @@ const statusController = createStatusController({
|
||||
getError: getFirstbootError,
|
||||
ui: firstbootUI,
|
||||
},
|
||||
onFirstbootStatus: handleFirstbootStatus,
|
||||
});
|
||||
const { loadStatus } = statusController;
|
||||
|
||||
@@ -220,6 +280,30 @@ function wireDialogs() {
|
||||
addServiceModal?.addEventListener("click", (e) => {
|
||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||
});
|
||||
networkModal?.addEventListener("click", (e) => {
|
||||
if (e.target === networkModal) {
|
||||
markNetworkHandled(networkState.profile, "Network setup saved");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireNetworkSetup() {
|
||||
networkClose?.addEventListener("click", () => {
|
||||
markNetworkHandled(networkState.profile, "Network setup saved");
|
||||
});
|
||||
networkReserveBtn?.addEventListener("click", () => {
|
||||
markNetworkHandled(networkState.profile, "Router reservation saved");
|
||||
});
|
||||
networkStaticBtn?.addEventListener("click", () => {
|
||||
markNetworkHandled(networkState.profile, "Static IP reminder saved");
|
||||
});
|
||||
networkLaterBtn?.addEventListener("click", () => {
|
||||
markNetworkHandled(networkState.profile, "Network setup saved");
|
||||
});
|
||||
networkHelpBtn?.addEventListener("click", () => {
|
||||
if (helpModal) helpModal.classList.add("hidden");
|
||||
openNetworkModal(networkState.profile || { requires_stable_ip: true }, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Testing hook
|
||||
@@ -348,6 +432,7 @@ function main() {
|
||||
window.__pikitTest.exposeServiceForm?.();
|
||||
}
|
||||
wireDialogs();
|
||||
wireNetworkSetup();
|
||||
wireResetAndUpdates();
|
||||
wireAccordions({
|
||||
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createStatusController({
|
||||
setUpdatesUI = null,
|
||||
updatesFlagEl = null,
|
||||
firstboot = null,
|
||||
onFirstbootStatus = null,
|
||||
}) {
|
||||
let lastStatusData = null;
|
||||
let lastFirstbootState = null;
|
||||
@@ -88,6 +89,7 @@ export function createStatusController({
|
||||
firstbootData = await firstboot.getStatus();
|
||||
lastFirstbootState = firstbootData?.state || lastFirstbootState;
|
||||
firstboot.ui.update(firstbootData);
|
||||
onFirstbootStatus?.(firstbootData);
|
||||
if (firstbootData?.state === "error" && firstboot.getError) {
|
||||
const err = await firstboot.getError();
|
||||
if (err?.present) {
|
||||
|
||||
@@ -132,6 +132,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="networkModal" class="modal hidden">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Network setup</p>
|
||||
<h3>Keep DNS reliable</h3>
|
||||
<p class="hint" id="networkProfileHint">
|
||||
This profile needs a stable IP address so your devices can always reach DNS.
|
||||
</p>
|
||||
</div>
|
||||
<button id="networkClose" class="ghost icon-btn close-btn" title="Close network setup">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<p>
|
||||
The easiest option is a router reservation. It keeps the Pi on the same IP without
|
||||
changing anything on the Pi itself.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Open your router’s admin page (often <code>http://192.168.0.1</code> or <code>http://10.0.0.1</code>).</li>
|
||||
<li>Find <strong>DHCP reservations</strong> or <strong>Static leases</strong>.</li>
|
||||
<li>Reserve the Pi’s current IP and MAC address.</li>
|
||||
</ol>
|
||||
<div class="control-actions wrap gap">
|
||||
<button id="networkReserveBtn">I set a router reservation</button>
|
||||
<button id="networkStaticBtn" class="ghost">I’ll set a static IP on the Pi</button>
|
||||
<button id="networkLaterBtn" class="ghost">I’ll do this later</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
You can reopen this from <strong>Help → Network setup</strong> at any time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="changelogModal" class="modal hidden">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header sticky">
|
||||
@@ -726,6 +762,14 @@
|
||||
<li>Factory reset reverts passwords and firewall rules and reboots. Use only when you need a clean slate.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Network setup (DNS profiles)</h4>
|
||||
<ul>
|
||||
<li>DNS profiles work best with a stable IP (router reservation or static IP).</li>
|
||||
<li>
|
||||
<button id="networkHelpBtn" class="ghost">Open network setup</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4>Troubleshooting</h4>
|
||||
<ul>
|
||||
<li>Service link fails? Make sure the service runs, port/path are right, and edit via the “…” menu.</li>
|
||||
|
||||
@@ -3,6 +3,8 @@ import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .constants import FIRSTBOOT_DIR, FIRSTBOOT_DONE, FIRSTBOOT_ERROR, FIRSTBOOT_LOG, FIRSTBOOT_STATE, WEB_ROOT
|
||||
|
||||
PROFILE_FILE = pathlib.Path("/etc/pikit/profile.json")
|
||||
from .helpers import ensure_dir, sha256_file
|
||||
|
||||
DEFAULT_STEPS = [
|
||||
@@ -82,6 +84,18 @@ def read_firstboot_status() -> Dict[str, Any]:
|
||||
ca_path = WEB_ROOT / "assets" / "pikit-ca.crt"
|
||||
ca_hash = sha256_file(ca_path) if ca_path.exists() else None
|
||||
|
||||
profile_summary: Dict[str, Any] = {}
|
||||
if PROFILE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(PROFILE_FILE.read_text())
|
||||
profile_summary = {
|
||||
"id": data.get("id"),
|
||||
"name": data.get("name"),
|
||||
"requires_stable_ip": bool(data.get("requires_stable_ip", False)),
|
||||
}
|
||||
except Exception:
|
||||
profile_summary = {}
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"steps": steps,
|
||||
@@ -91,6 +105,7 @@ def read_firstboot_status() -> Dict[str, Any]:
|
||||
"error_path": "/api/firstboot/error",
|
||||
"ca_hash": ca_hash,
|
||||
"ca_url": "/assets/pikit-ca.crt",
|
||||
"profile": profile_summary,
|
||||
}
|
||||
|
||||
|
||||
|
||||
28
profiles/dns-stack/profile.json
Normal file
28
profiles/dns-stack/profile.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "dns-stack",
|
||||
"name": "DNS Stack",
|
||||
"requires_stable_ip": true,
|
||||
"firewall_ports": [53, 8089, 8489],
|
||||
"firewall_enable": true,
|
||||
"services": [
|
||||
{ "name": "Pi-hole", "port": 8489, "scheme": "https", "path": "/admin/" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "tls_bundle",
|
||||
"source_cert": "/etc/pikit/certs/pikit.local.crt",
|
||||
"source_key": "/etc/pikit/certs/pikit.local.key",
|
||||
"dest": "/etc/pihole/tls.pem",
|
||||
"owner": "root:pihole",
|
||||
"mode": "640",
|
||||
"restart": "pihole-FTL"
|
||||
},
|
||||
{
|
||||
"type": "replace_text",
|
||||
"file": "/etc/pihole/pihole.toml",
|
||||
"match": "domain = \"pi.hole\"",
|
||||
"replace": "domain = \"pikit.local\"",
|
||||
"restart": "pihole-FTL"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ PROFILE_FILE="/etc/pikit/profile.json"
|
||||
MOTD_FILE="/etc/motd"
|
||||
FIRSTBOOT_CONF="/etc/pikit/firstboot.conf"
|
||||
APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf"
|
||||
SERVICE_JSON="/etc/pikit/services.json"
|
||||
|
||||
STEPS=(
|
||||
"Preparing system"
|
||||
@@ -58,7 +59,7 @@ configure_unattended_defaults() {
|
||||
log "python3 missing; skipping unattended-upgrades defaults."
|
||||
return
|
||||
fi
|
||||
PYTHONPATH=/usr/local/bin python3 - <<'PY'
|
||||
PROFILE_FILE="$PROFILE_FILE" SERVICE_JSON="$SERVICE_JSON" PYTHONPATH=/usr/local/bin python3 - <<'PY'
|
||||
import sys
|
||||
try:
|
||||
from pikit_api.auto_updates import set_updates_config
|
||||
@@ -71,6 +72,198 @@ PY
|
||||
log "Unattended-upgrades defaults applied (security-only)."
|
||||
}
|
||||
|
||||
apply_profile() {
|
||||
if [ ! -f "$PROFILE_FILE" ]; then
|
||||
log "Profile step skipped (no profile.json)."
|
||||
return
|
||||
fi
|
||||
PYTHONPATH=/usr/local/bin python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import grp
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
profile_path = pathlib.Path(os.environ.get("PROFILE_FILE", "/etc/pikit/profile.json"))
|
||||
services_path = pathlib.Path(os.environ.get("SERVICE_JSON", "/etc/pikit/services.json"))
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg)
|
||||
|
||||
def ipv6_enabled() -> bool:
|
||||
cfg = pathlib.Path("/etc/default/ufw")
|
||||
if not cfg.exists():
|
||||
return True
|
||||
for line in cfg.read_text().splitlines():
|
||||
if line.strip().startswith("IPV6="):
|
||||
return line.split("=", 1)[1].strip().lower() == "yes"
|
||||
return True
|
||||
|
||||
def get_ipv6_prefixes() -> list:
|
||||
if not ipv6_enabled():
|
||||
return []
|
||||
try:
|
||||
out = subprocess.check_output(["ip", "-6", "addr", "show", "scope", "global"], text=True)
|
||||
except Exception:
|
||||
return []
|
||||
prefixes = set()
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("inet6 "):
|
||||
continue
|
||||
if " temporary " in f" {line} ":
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
prefixes.add(parts[1])
|
||||
return sorted(prefixes)
|
||||
|
||||
try:
|
||||
profile = json.loads(profile_path.read_text())
|
||||
except Exception as e:
|
||||
log(f"Profile load failed: {e}")
|
||||
profile = {}
|
||||
|
||||
ports = profile.get("firewall_ports") or []
|
||||
firewall_enable = bool(profile.get("firewall_enable", False))
|
||||
base_ports = profile.get("firewall_base_ports") or [22, 80, 443, 5252, 5253]
|
||||
if ports:
|
||||
if shutil.which("ufw"):
|
||||
ipv6_prefixes = get_ipv6_prefixes()
|
||||
if ipv6_prefixes:
|
||||
log(f"IPv6 LAN prefixes: {', '.join(ipv6_prefixes)}")
|
||||
for raw in ports:
|
||||
try:
|
||||
port = int(raw)
|
||||
except Exception:
|
||||
continue
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
|
||||
for prefix in ipv6_prefixes:
|
||||
subprocess.run(["ufw", "allow", "from", prefix, "to", "any", "port", str(port)], check=False)
|
||||
if firewall_enable:
|
||||
for raw in base_ports:
|
||||
try:
|
||||
port = int(raw)
|
||||
except Exception:
|
||||
continue
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
|
||||
for prefix in ipv6_prefixes:
|
||||
subprocess.run(["ufw", "allow", "from", prefix, "to", "any", "port", str(port)], check=False)
|
||||
subprocess.run(["ufw", "--force", "enable"], check=False)
|
||||
log("UFW enabled.")
|
||||
log("Profile firewall rules applied.")
|
||||
else:
|
||||
log("Profile firewall step skipped (ufw missing).")
|
||||
else:
|
||||
log("Profile firewall step skipped (no ports).")
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
return name.strip().lower()
|
||||
|
||||
services = []
|
||||
if services_path.exists():
|
||||
try:
|
||||
services = json.loads(services_path.read_text())
|
||||
except Exception:
|
||||
services = []
|
||||
if not isinstance(services, list):
|
||||
services = []
|
||||
|
||||
profile_services = profile.get("services") or []
|
||||
if isinstance(profile_services, list) and profile_services:
|
||||
for psvc in profile_services:
|
||||
if not isinstance(psvc, dict):
|
||||
continue
|
||||
p_name = normalize_name(str(psvc.get("name", "")))
|
||||
p_port = str(psvc.get("port", ""))
|
||||
p_path = str(psvc.get("path", "")) if psvc.get("path") is not None else ""
|
||||
replaced = False
|
||||
for svc in services:
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
s_name = normalize_name(str(svc.get("name", "")))
|
||||
s_port = str(svc.get("port", ""))
|
||||
s_path = str(svc.get("path", "")) if svc.get("path") is not None else ""
|
||||
if (p_name and s_name == p_name) or (p_port and s_port == p_port and s_path == p_path):
|
||||
svc.update(psvc)
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
services.append(psvc)
|
||||
services_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
services_path.write_text(json.dumps(services, indent=2))
|
||||
log("Profile services merged.")
|
||||
else:
|
||||
log("Profile services step skipped (none).")
|
||||
|
||||
actions = profile.get("actions") or []
|
||||
if isinstance(actions, list) and actions:
|
||||
for action in actions:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
action_type = action.get("type")
|
||||
if action_type == "tls_bundle":
|
||||
src_cert = pathlib.Path(action.get("source_cert", ""))
|
||||
src_key = pathlib.Path(action.get("source_key", ""))
|
||||
dest = pathlib.Path(action.get("dest", ""))
|
||||
if not src_cert.exists() or not src_key.exists():
|
||||
log(f"TLS bundle skipped (missing cert/key): {dest}")
|
||||
continue
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = src_cert.read_bytes() + b\"\\n\" + src_key.read_bytes() + b\"\\n\"
|
||||
dest.write_bytes(content)
|
||||
owner = action.get("owner")
|
||||
if owner:
|
||||
user, _, group = str(owner).partition(\":\")
|
||||
try:
|
||||
uid = pwd.getpwnam(user).pw_uid if user else -1
|
||||
except Exception:
|
||||
uid = -1
|
||||
try:
|
||||
gid = grp.getgrnam(group).gr_gid if group else -1
|
||||
except Exception:
|
||||
gid = -1
|
||||
if uid != -1 or gid != -1:
|
||||
os.chown(dest, uid if uid != -1 else -1, gid if gid != -1 else -1)
|
||||
mode = action.get("mode")
|
||||
if mode:
|
||||
try:
|
||||
os.chmod(dest, int(str(mode), 8))
|
||||
except Exception:
|
||||
pass
|
||||
restart = action.get("restart")
|
||||
if restart:
|
||||
subprocess.run([\"systemctl\", \"restart\", str(restart)], check=False)
|
||||
log(f\"TLS bundle written: {dest}\")
|
||||
continue
|
||||
|
||||
if action_type == "replace_text":
|
||||
file_path = pathlib.Path(action.get("file", ""))
|
||||
match = str(action.get("match", ""))
|
||||
replacement = str(action.get("replace", ""))
|
||||
if not file_path.exists():
|
||||
log(f\"Replace skipped (missing file): {file_path}\")
|
||||
continue
|
||||
content = file_path.read_text()
|
||||
if match not in content:
|
||||
log(f\"Replace skipped (pattern not found): {file_path}\")
|
||||
continue
|
||||
file_path.write_text(content.replace(match, replacement, 1))
|
||||
restart = action.get("restart")
|
||||
if restart:
|
||||
subprocess.run([\"systemctl\", \"restart\", str(restart)], check=False)
|
||||
log(f\"Replaced text in: {file_path}\")
|
||||
continue
|
||||
else:
|
||||
log("Profile actions step skipped (none).")
|
||||
PY
|
||||
}
|
||||
|
||||
write_state() {
|
||||
local state="$1"
|
||||
local current="$2"
|
||||
@@ -260,34 +453,7 @@ finish_step 3
|
||||
|
||||
begin_step 4
|
||||
configure_unattended_defaults
|
||||
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then
|
||||
python3 - <<'PY' > /tmp/pikit-profile-ports.txt
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
profile = Path("/etc/pikit/profile.json")
|
||||
try:
|
||||
data = json.loads(profile.read_text())
|
||||
except Exception:
|
||||
data = {}
|
||||
ports = data.get("firewall_ports") or []
|
||||
for port in ports:
|
||||
try:
|
||||
port_int = int(port)
|
||||
except Exception:
|
||||
continue
|
||||
print(port_int)
|
||||
PY
|
||||
while read -r port; do
|
||||
[ -z "$port" ] && continue
|
||||
for subnet in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16; do
|
||||
ufw allow from "$subnet" to any port "$port" || true
|
||||
done
|
||||
done < /tmp/pikit-profile-ports.txt
|
||||
rm -f /tmp/pikit-profile-ports.txt
|
||||
else
|
||||
log "Profile firewall step skipped (no profile.json or ufw missing)"
|
||||
fi
|
||||
apply_profile
|
||||
|
||||
if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then
|
||||
echo "CA bundle missing in web assets" >&2
|
||||
|
||||
Reference in New Issue
Block a user