installer: restore cell identity prompts and domain setup
Unit Tests / test (push) Successful in 15m39s

Reverts 8d1ef39. The installer must collect cell name, domain mode, and
provider tokens before 'make install' so that DDNS registration,
availability checks, and Caddy TLS can be configured at first boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:01:32 -04:00
parent 8d1ef39ca5
commit 2d842abe5b
5 changed files with 345 additions and 19 deletions
+5
View File
@@ -510,6 +510,11 @@ class ConfigManager:
cfg.setdefault('peer_exit_map', {}) cfg.setdefault('peer_exit_map', {})
return dict(cfg) return dict(cfg)
def set_ddns_config(self, ddns_cfg: Dict[str, Any]) -> None:
"""Replace the top-level ddns section and persist."""
self.configs['ddns'] = ddns_cfg
self._save_all_configs()
def set_connectivity_field(self, field: str, value: Any) -> bool: def set_connectivity_field(self, field: str, value: Any) -> bool:
"""Set a single field within the connectivity config and persist.""" """Set a single field within the connectivity config and persist."""
cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}}) cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}})
-7
View File
@@ -55,11 +55,4 @@ def complete_setup():
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
result = sm.complete_setup(payload) result = sm.complete_setup(payload)
status_code = 200 if result.get('success') else 400 status_code = 200 if result.get('success') else 400
# TODO (Phase 3): if result.get('success') and domain_mode == 'pic_ngo':
# from app import ddns_manager
# name = payload.get('cell_name', '')
# ip = payload.get('public_ip', '')
# ddns_manager.register(name, ip)
return jsonify(result), status_code return jsonify(result), status_code
+49 -3
View File
@@ -60,6 +60,37 @@ VALID_DOMAIN_MODES = {'pic_ngo', 'cloudflare', 'duckdns', 'http01', 'lan'}
CELL_NAME_RE = re.compile(r'^[a-z][a-z0-9-]{1,30}$') CELL_NAME_RE = re.compile(r'^[a-z][a-z0-9-]{1,30}$')
DDNS_API_BASE = os.environ.get('DDNS_URL', 'https://ddns.pic.ngo/api/v1').replace('/api/v1', '')
DDNS_TOTP_SECRET = os.environ.get('DDNS_TOTP_SECRET', '')
def _build_ddns_config(domain_mode: str, cloudflare_api_token: str = '',
duckdns_token: str = '', duckdns_subdomain: str = '') -> dict:
"""Return the top-level ddns config dict for a given domain mode."""
if domain_mode == 'pic_ngo':
return {
'provider': 'pic_ngo',
'api_base_url': DDNS_API_BASE,
'totp_secret': DDNS_TOTP_SECRET,
'enabled': True,
}
if domain_mode == 'cloudflare':
cfg = {'provider': 'cloudflare', 'enabled': True}
if cloudflare_api_token:
cfg['api_token'] = cloudflare_api_token
return cfg
if domain_mode == 'duckdns':
cfg = {'provider': 'duckdns', 'enabled': True}
if duckdns_token:
cfg['token'] = duckdns_token
if duckdns_subdomain:
cfg['subdomain'] = duckdns_subdomain
return cfg
if domain_mode == 'http01':
return {'provider': 'http01', 'enabled': True}
return {'provider': 'none', 'enabled': False}
class SetupManager: class SetupManager:
"""Manages the first-run setup wizard state and completion.""" """Manages the first-run setup wizard state and completion."""
@@ -209,10 +240,25 @@ class SetupManager:
if duckdns_token: if duckdns_token:
self.config_manager.set_identity_field('duckdns_token', duckdns_token) self.config_manager.set_identity_field('duckdns_token', duckdns_token)
logger.info( # ── write top-level ddns section so DDNSManager can find provider ──
'DDNS registration deferred to Phase 3. ' duckdns_sub = domain_name.replace('.duckdns.org', '') if domain_mode == 'duckdns' else ''
f'ddns_provider={ddns_provider!r} domain_name={domain_name!r}' ddns_cfg = _build_ddns_config(
domain_mode,
cloudflare_api_token=cloudflare_api_token,
duckdns_token=duckdns_token,
duckdns_subdomain=duckdns_sub,
) )
self.config_manager.set_ddns_config(ddns_cfg)
# ── trigger DDNS registration for pic_ngo ─────────────────────────
if domain_mode == 'pic_ngo':
try:
from ddns_manager import DDNSManager
ddns_mgr = DDNSManager(self.config_manager)
ddns_mgr.register(cell_name, '')
logger.info(f'DDNS registered: {cell_name}.pic.ngo')
except Exception as exc:
logger.warning(f'DDNS registration failed (will retry at next heartbeat): {exc}')
# ── mark setup complete (must be last) ───────────────────────── # ── mark setup complete (must be last) ─────────────────────────
self.config_manager.set_identity_field('setup_complete', True) self.config_manager.set_identity_field('setup_complete', True)
+234 -9
View File
@@ -79,7 +79,56 @@ log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >
die() { log_error "$1"; exit 1; } die() { log_error "$1"; exit 1; }
TOTAL_STEPS=7 # ---------------------------------------------------------------------------
# Interactive prompt helpers (use /dev/tty so they work even with piped stdin)
# ---------------------------------------------------------------------------
prompt() {
# prompt <label> <default> <var>
local _label="$1" _default="$2" _var="$3" _inp=''
if [ -n "$_default" ]; then
printf " %s [%s]: " "$_label" "$_default" >/dev/tty
else
printf " %s: " "$_label" >/dev/tty
fi
read -r _inp </dev/tty || true
[ -z "$_inp" ] && _inp="$_default"
eval "${_var}=\${_inp}"
}
prompt_secret() {
# prompt_secret <label> <var>
local _label="$1" _var="$2" _inp=''
printf " %s: " "$_label" >/dev/tty
stty -echo </dev/tty 2>/dev/null || true
read -r _inp </dev/tty || true
stty echo </dev/tty 2>/dev/null || true
printf "\n" >/dev/tty
eval "${_var}=\${_inp}"
}
verify_cf_token() {
local _token="$1" _result=''
_result=$(curl -fsSm 10 \
-H "Authorization: Bearer ${_token}" \
"https://api.cloudflare.com/client/v4/user/tokens/verify" 2>/dev/null) || true
echo "$_result" | grep -q '"success":true'
}
verify_duckdns() {
local _sub="$1" _token="$2" _result=''
_result=$(curl -fsSm 10 \
"https://www.duckdns.org/update?domains=${_sub}&token=${_token}&ip=" 2>/dev/null) || true
[ "$_result" = "OK" ]
}
check_pic_ngo_available() {
local _name="$1" _result=''
_result=$(curl -fsSm 10 \
"https://ddns.pic.ngo/api/v1/check/${_name}" 2>/dev/null) || true
echo "$_result" | grep -q '"available":true'
}
TOTAL_STEPS=8
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Must run as root # Must run as root
@@ -253,13 +302,182 @@ chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 5 — Run make install # Step 5 — Configure cell identity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 5 "Running 'make install'..." log_step 5 "Configuring cell identity..."
if [ ! -c /dev/tty ]; then
die "No interactive terminal available. Re-run with a real terminal (not piped)."
fi
printf "\n" >/dev/tty
# ── Cell name ──────────────────────────────────────────────────────────────
PIC_CELL_NAME=''
while true; do
prompt "Cell name (e.g. myhome, alice, lab)" "" PIC_CELL_NAME
if echo "$PIC_CELL_NAME" | grep -qE '^[a-z][a-z0-9-]{1,30}$'; then
break
fi
log_warn "Must start with a letter, use only lowercase letters/digits/hyphens, 231 chars."
done
# ── Domain / DDNS choice ───────────────────────────────────────────────────
printf "\n" >/dev/tty
printf " How will your cell be publicly reachable?\n" >/dev/tty
printf " 1) pic.ngo subdomain — free, %s.pic.ngo, fully automatic HTTPS\n" "$PIC_CELL_NAME" >/dev/tty
printf " 2) Cloudflare DNS-01 — your own domain (must use Cloudflare nameservers)\n" >/dev/tty
printf " 3) DuckDNS — free *.duckdns.org subdomain\n" >/dev/tty
printf " 4) HTTP-01 (any) — any domain, port 80 must be publicly reachable\n" >/dev/tty
printf " 5) Local only — no public domain, LAN/VPN access only\n" >/dev/tty
printf "\n" >/dev/tty
PIC_DOMAIN_MODE=''
_choice=''
while true; do
prompt "Choice" "1" _choice
case "$_choice" in
1) PIC_DOMAIN_MODE="pic_ngo"; break ;;
2) PIC_DOMAIN_MODE="cloudflare"; break ;;
3) PIC_DOMAIN_MODE="duckdns"; break ;;
4) PIC_DOMAIN_MODE="http01"; break ;;
5) PIC_DOMAIN_MODE="lan"; break ;;
*) log_warn "Enter a number from 1 to 5." ;;
esac
done
PIC_DOMAIN_NAME=''
PIC_CF_TOKEN=''
PIC_DDK_TOKEN=''
PIC_DDK_SUB=''
# ── pic.ngo ────────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "pic_ngo" ]; then
PIC_DOMAIN_NAME="${PIC_CELL_NAME}.pic.ngo"
printf " Checking name availability at pic.ngo..." >/dev/tty
if check_pic_ngo_available "$PIC_CELL_NAME"; then
printf " available\n" >/dev/tty
log_ok "Will register: ${PIC_DOMAIN_NAME}"
else
printf "\n" >/dev/tty
log_warn "${PIC_DOMAIN_NAME} may already be taken or the server is unreachable."
log_warn "Registration will be retried at first boot."
fi
fi
# ── Cloudflare ─────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "cloudflare" ]; then
printf "\n" >/dev/tty
while true; do
prompt "Your domain name (e.g. home.example.com)" "" PIC_DOMAIN_NAME
if echo "$PIC_DOMAIN_NAME" | grep -qiE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$'; then
break
fi
log_warn "Enter a valid fully-qualified domain name (e.g. home.example.com)."
done
printf "\n" >/dev/tty
printf " Create an API token at: Cloudflare Dashboard → My Profile → API Tokens\n" >/dev/tty
printf " Required permission: Zone / DNS / Edit (set to all zones or your specific zone)\n" >/dev/tty
printf "\n" >/dev/tty
_attempts=0
while true; do
prompt_secret "Cloudflare API token" PIC_CF_TOKEN
if [ -z "$PIC_CF_TOKEN" ]; then
log_warn "Token cannot be empty."
continue
fi
printf " Verifying token with Cloudflare..." >/dev/tty
if verify_cf_token "$PIC_CF_TOKEN"; then
printf " valid\n" >/dev/tty
log_ok "Cloudflare token verified"
break
fi
printf " invalid\n" >/dev/tty
_attempts=$((_attempts + 1))
log_warn "Verification failed — check the token has Zone / DNS / Edit permission."
if [ "$_attempts" -ge 2 ]; then
log_warn "Token failed twice. You can still continue — it will be tested again at first boot."
prompt "Press Enter to continue with this token, or Ctrl-C to abort and re-run" "" _dummy
break
fi
done
fi
# ── DuckDNS ────────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "duckdns" ]; then
printf "\n" >/dev/tty
printf " First create a subdomain at duckdns.org, then enter the details below.\n" >/dev/tty
printf "\n" >/dev/tty
while true; do
prompt "DuckDNS subdomain (e.g. myhome → myhome.duckdns.org)" "" PIC_DDK_SUB
if echo "$PIC_DDK_SUB" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then
break
fi
log_warn "Subdomain must be lowercase letters, digits, and hyphens only."
done
PIC_DOMAIN_NAME="${PIC_DDK_SUB}.duckdns.org"
printf "\n" >/dev/tty
_attempts=0
while true; do
prompt_secret "DuckDNS token (from duckdns.org account page)" PIC_DDK_TOKEN
if [ -z "$PIC_DDK_TOKEN" ]; then
log_warn "Token cannot be empty."
continue
fi
printf " Verifying token with DuckDNS..." >/dev/tty
if verify_duckdns "$PIC_DDK_SUB" "$PIC_DDK_TOKEN"; then
printf " valid\n" >/dev/tty
log_ok "DuckDNS token verified (${PIC_DOMAIN_NAME})"
break
fi
printf " invalid\n" >/dev/tty
_attempts=$((_attempts + 1))
log_warn "Verification failed — make sure the subdomain exists at duckdns.org and the token is correct."
if [ "$_attempts" -ge 2 ]; then
log_warn "Token failed twice. You can still continue — it will be tested again at first boot."
prompt "Press Enter to continue with this token, or Ctrl-C to abort and re-run" "" _dummy
break
fi
done
fi
# ── HTTP-01 ────────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "http01" ]; then
printf "\n" >/dev/tty
while true; do
prompt "Your domain name (e.g. home.example.com)" "" PIC_DOMAIN_NAME
if echo "$PIC_DOMAIN_NAME" | grep -qiE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$'; then
break
fi
log_warn "Enter a valid fully-qualified domain name."
done
printf "\n" >/dev/tty
log_warn "HTTP-01 requires port 80 to be publicly reachable from the internet."
log_warn "Make sure your router forwards port 80 to this machine before completing setup."
fi
# ── Local only ─────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "lan" ]; then
log_ok "Local-only mode — no public domain or DDNS will be configured."
fi
printf "\n" >/dev/tty
log_ok "Identity configured: cell=${PIC_CELL_NAME} mode=${PIC_DOMAIN_MODE}${PIC_DOMAIN_NAME:+ domain=${PIC_DOMAIN_NAME}}"
# ---------------------------------------------------------------------------
# Step 6 — Run make install
# ---------------------------------------------------------------------------
log_step 6 "Running 'make install'..."
cd "$PIC_DIR" cd "$PIC_DIR"
if ! make install 2>&1 | sed 's/^/ /'; then if ! CELL_NAME="$PIC_CELL_NAME" \
DOMAIN_MODE="$PIC_DOMAIN_MODE" \
CELL_DOMAIN_NAME="${PIC_DOMAIN_NAME:-}" \
CLOUDFLARE_API_TOKEN="${PIC_CF_TOKEN:-}" \
DUCKDNS_TOKEN="${PIC_DDK_TOKEN:-}" \
DUCKDNS_SUBDOMAIN="${PIC_DDK_SUB:-}" \
make install 2>&1 | sed 's/^/ /'; then
die "'make install' failed. Check the output above." die "'make install' failed. Check the output above."
fi fi
@@ -270,9 +488,9 @@ chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
log_ok "'make install' complete" log_ok "'make install' complete"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 6 — Start core services # Step 7 — Start core services
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 6 "Starting core services..." log_step 7 "Starting core services..."
cd "$PIC_DIR" cd "$PIC_DIR"
@@ -283,9 +501,9 @@ fi
log_ok "Core services started" log_ok "Core services started"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 7 — Health check + print wizard URL # Step 8 — Health check + print wizard URL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..." log_step 8 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
ELAPSED=0 ELAPSED=0
HEALTHY=0 HEALTHY=0
@@ -323,7 +541,14 @@ printf "\n${GREEN}${BOLD}=======================================================
printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n" printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n"
printf "${GREEN}${BOLD}============================================================${RESET}\n" printf "${GREEN}${BOLD}============================================================${RESET}\n"
printf "\n" printf "\n"
printf " Open the setup wizard to configure your cell:\n" printf " Cell: ${BOLD}%s${RESET}\n" "$PIC_CELL_NAME"
if [ -n "$PIC_DOMAIN_NAME" ]; then
printf " Domain: ${BOLD}%s${RESET} (%s)\n" "$PIC_DOMAIN_NAME" "$PIC_DOMAIN_MODE"
else
printf " Domain: %s\n" "local only (LAN/VPN)"
fi
printf "\n"
printf " Open the setup wizard to set your admin password and choose services:\n"
printf "\n" printf "\n"
printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n" printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n"
printf "\n" printf "\n"
+57
View File
@@ -320,3 +320,60 @@ def test_get_setup_status_preconfigured_returns_installer_values(setup_manager,
assert pre['domain_mode'] == 'pic_ngo' assert pre['domain_mode'] == 'pic_ngo'
assert pre['domain_name'] == 'myhome.pic.ngo' assert pre['domain_name'] == 'myhome.pic.ngo'
assert 'cloudflare_api_token' not in pre assert 'cloudflare_api_token' not in pre
# ── _build_ddns_config ────────────────────────────────────────────────────────
from setup_manager import _build_ddns_config
def test_build_ddns_config_pic_ngo():
cfg = _build_ddns_config('pic_ngo')
assert cfg['provider'] == 'pic_ngo'
assert cfg['enabled'] is True
def test_build_ddns_config_cloudflare_includes_token():
cfg = _build_ddns_config('cloudflare', cloudflare_api_token='tok123')
assert cfg['provider'] == 'cloudflare'
assert cfg['api_token'] == 'tok123'
def test_build_ddns_config_duckdns_includes_token_and_subdomain():
cfg = _build_ddns_config('duckdns', duckdns_token='duck', duckdns_subdomain='myhome')
assert cfg['provider'] == 'duckdns'
assert cfg['token'] == 'duck'
assert cfg['subdomain'] == 'myhome'
def test_build_ddns_config_lan_disabled():
cfg = _build_ddns_config('lan')
assert cfg['provider'] == 'none'
assert cfg['enabled'] is False
# ── ddns config written on complete_setup ─────────────────────────────────────
def test_complete_setup_writes_ddns_config_section(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload(domain_mode='lan'))
mock_config_manager.set_ddns_config.assert_called_once()
ddns_arg = mock_config_manager.set_ddns_config.call_args[0][0]
assert ddns_arg['provider'] == 'none'
def test_complete_setup_writes_cloudflare_ddns_config(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
payload = _valid_payload(
domain_mode='cloudflare',
domain_name='home.example.com',
cloudflare_api_token='cf-token-xyz',
)
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(payload)
ddns_arg = mock_config_manager.set_ddns_config.call_args[0][0]
assert ddns_arg['provider'] == 'cloudflare'
assert ddns_arg['api_token'] == 'cf-token-xyz'