wizard: move all config to /setup; install.sh is infrastructure-only
Unit Tests / test (push) Successful in 15m41s
Unit Tests / test (push) Successful in 15m41s
install.sh no longer prompts for anything. It installs packages (with sudo), creates the system user, clones the repo, and runs 'make install' — all as the invoking user. Only package installs and system-level ops use sudo. All folder creation happens under the user's own account, no chown needed. /setup wizard gains the missing validation that was previously in install.sh: - Step 1: checks pic.ngo name availability via backend (non-blocking) - Step 4: 'Verify token' button for Cloudflare and DuckDNS tokens, validated server-side through new /api/setup/validate steps API changes (routes/setup.py): - validate step 'pic_ngo_available': proxy check to ddns.pic.ngo - validate step 'cloudflare_token': verify via Cloudflare tokens API - validate step 'duckdns_token': verify via DuckDNS update endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+71
-7
@@ -1,10 +1,16 @@
|
||||
import logging
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json as _json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
|
||||
setup_bp = Blueprint('setup', __name__, url_prefix='/api/setup')
|
||||
|
||||
_DOMAIN_RE = re.compile(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$', re.I)
|
||||
|
||||
|
||||
def _get_setup_manager():
|
||||
from app import setup_manager
|
||||
@@ -24,8 +30,8 @@ def get_setup_status():
|
||||
def validate_setup_step():
|
||||
"""Validate a single wizard step.
|
||||
|
||||
Expects JSON body: ``{'step': '<step_name>', 'data': {...}}``.
|
||||
Supported steps: ``cell_name``, ``password``.
|
||||
Supported steps: ``cell_name``, ``password``,
|
||||
``pic_ngo_available``, ``cloudflare_token``, ``duckdns_token``.
|
||||
"""
|
||||
sm = _get_setup_manager()
|
||||
if sm.is_setup_complete():
|
||||
@@ -37,13 +43,37 @@ def validate_setup_step():
|
||||
|
||||
if step == 'cell_name':
|
||||
errors = sm.validate_cell_name(data.get('cell_name', ''))
|
||||
elif step == 'password':
|
||||
errors = sm.validate_password(data.get('password', ''))
|
||||
else:
|
||||
return jsonify({'valid': False, 'errors': [f"Unknown step: {step!r}"]}), 400
|
||||
|
||||
return jsonify({'valid': len(errors) == 0, 'errors': errors})
|
||||
|
||||
if step == 'password':
|
||||
errors = sm.validate_password(data.get('password', ''))
|
||||
return jsonify({'valid': len(errors) == 0, 'errors': errors})
|
||||
|
||||
if step == 'pic_ngo_available':
|
||||
name = data.get('cell_name', '').strip()
|
||||
errors = sm.validate_cell_name(name)
|
||||
if errors:
|
||||
return jsonify({'available': False, 'errors': errors})
|
||||
available = _check_pic_ngo_available(name)
|
||||
return jsonify({'available': available})
|
||||
|
||||
if step == 'cloudflare_token':
|
||||
token = data.get('token', '').strip()
|
||||
if not token:
|
||||
return jsonify({'valid': False, 'error': 'Token is required.'})
|
||||
valid = _verify_cloudflare_token(token)
|
||||
return jsonify({'valid': valid})
|
||||
|
||||
if step == 'duckdns_token':
|
||||
subdomain = data.get('subdomain', '').strip()
|
||||
token = data.get('token', '').strip()
|
||||
if not token or not subdomain:
|
||||
return jsonify({'valid': False, 'error': 'Subdomain and token are required.'})
|
||||
valid = _verify_duckdns_token(subdomain, token)
|
||||
return jsonify({'valid': valid})
|
||||
|
||||
return jsonify({'valid': False, 'errors': [f"Unknown step: {step!r}"]}), 400
|
||||
|
||||
|
||||
@setup_bp.route('/complete', methods=['POST'])
|
||||
def complete_setup():
|
||||
@@ -56,3 +86,37 @@ def complete_setup():
|
||||
result = sm.complete_setup(payload)
|
||||
status_code = 200 if result.get('success') else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
# ── external validation helpers ───────────────────────────────────────────────
|
||||
|
||||
def _check_pic_ngo_available(name: str) -> bool:
|
||||
try:
|
||||
url = f'https://ddns.pic.ngo/api/v1/check/{name}'
|
||||
with urllib.request.urlopen(url, timeout=8) as resp:
|
||||
body = _json.loads(resp.read())
|
||||
return bool(body.get('available'))
|
||||
except Exception:
|
||||
return True # assume available if check fails — don't block the wizard
|
||||
|
||||
|
||||
def _verify_cloudflare_token(token: str) -> bool:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
'https://api.cloudflare.com/client/v4/user/tokens/verify',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = _json.loads(resp.read())
|
||||
return bool(body.get('success'))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _verify_duckdns_token(subdomain: str, token: str) -> bool:
|
||||
try:
|
||||
url = f'https://www.duckdns.org/update?domains={subdomain}&token={token}&ip='
|
||||
with urllib.request.urlopen(url, timeout=8) as resp:
|
||||
return resp.read().strip() == b'OK'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
+34
-272
@@ -22,9 +22,9 @@
|
||||
# =============================================================================
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash install.sh # Standard install
|
||||
# sudo bash install.sh --force # Bypass idempotency check
|
||||
# sudo PIC_DIR=/srv/pic bash install.sh # Custom install directory
|
||||
# bash install.sh # Standard install (uses sudo internally for packages)
|
||||
# bash install.sh --force # Bypass idempotency check
|
||||
# PIC_DIR=/srv/pic bash install.sh # Custom install directory
|
||||
#
|
||||
# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk)
|
||||
#
|
||||
@@ -79,62 +79,13 @@ log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >
|
||||
|
||||
die() { log_error "$1"; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
TOTAL_STEPS=7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Must run as root
|
||||
# Sudo check — we need it for package installs and system user creation
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
die "This installer must be run as root (use sudo)."
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
die "sudo is required. Install it and ensure your user has sudo access."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -198,37 +149,32 @@ case "$PKG_MANAGER" in
|
||||
|
||||
apt)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
|
||||
| grep -v "^$" | sed 's/^/ /' || true
|
||||
|
||||
# Verify docker compose plugin installed
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
log_warn "docker-compose-plugin not available; falling back to standalone docker-compose"
|
||||
apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true
|
||||
sudo apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true
|
||||
fi
|
||||
;;
|
||||
|
||||
dnf)
|
||||
dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true
|
||||
sudo dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# Enable and start Docker (dnf installs but doesn't enable it)
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
|
||||
# Docker Compose plugin comes bundled with the Docker CE package on Fedora/RHEL.
|
||||
# If not present, install via the docker-compose-plugin package (Docker CE repo).
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
log_warn "docker compose plugin not found; installing docker-compose-plugin..."
|
||||
dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true
|
||||
sudo dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
;;
|
||||
|
||||
apk)
|
||||
apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true
|
||||
sudo apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# Enable Docker on Alpine (OpenRC)
|
||||
rc-update add docker default >/dev/null 2>&1 || true
|
||||
service docker start >/dev/null 2>&1 || true
|
||||
sudo rc-update add docker default >/dev/null 2>&1 || true
|
||||
sudo service docker start >/dev/null 2>&1 || true
|
||||
;;
|
||||
|
||||
esac
|
||||
@@ -253,10 +199,10 @@ log_step 3 "Configuring system user..."
|
||||
if ! id "$PIC_USER" >/dev/null 2>&1; then
|
||||
case "$PKG_MANAGER" in
|
||||
apk)
|
||||
adduser -S -D -H -s /sbin/nologin "$PIC_USER"
|
||||
sudo adduser -S -D -H -s /sbin/nologin "$PIC_USER"
|
||||
;;
|
||||
*)
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER"
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER"
|
||||
;;
|
||||
esac
|
||||
log_ok "Created system user: ${PIC_USER}"
|
||||
@@ -264,17 +210,18 @@ else
|
||||
log_ok "System user already exists: ${PIC_USER}"
|
||||
fi
|
||||
|
||||
# Ensure docker group exists and user is in it
|
||||
# Ensure docker group exists and invoking user is in it
|
||||
if ! getent group docker >/dev/null 2>&1; then
|
||||
groupadd docker
|
||||
sudo groupadd docker
|
||||
log_ok "Created docker group"
|
||||
fi
|
||||
|
||||
if ! id -nG "$PIC_USER" | grep -qw docker; then
|
||||
usermod -aG docker "$PIC_USER"
|
||||
log_ok "Added ${PIC_USER} to docker group"
|
||||
CURRENT_USER="${USER:-$(id -un)}"
|
||||
if ! id -nG "$CURRENT_USER" | grep -qw docker; then
|
||||
sudo usermod -aG docker "$CURRENT_USER"
|
||||
log_ok "Added ${CURRENT_USER} to docker group (re-login or newgrp docker to apply)"
|
||||
else
|
||||
log_ok "${PIC_USER} is already in docker group"
|
||||
log_ok "${CURRENT_USER} is already in docker group"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -294,203 +241,25 @@ else
|
||||
log_ok "Repository cloned to ${PIC_DIR}"
|
||||
fi
|
||||
|
||||
# Give the invoking user (or pic if run directly as root) ownership of the repo
|
||||
# so they can run `make update` and other git commands without sudo.
|
||||
REPO_OWNER="${SUDO_USER:-${PIC_USER}}"
|
||||
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
|
||||
# Allow all users to run git commands here regardless of who owns the files
|
||||
git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
|
||||
sudo git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5 — Configure cell identity
|
||||
# Step 5 — Run 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, 2–31 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'..."
|
||||
log_step 5 "Running 'make install'..."
|
||||
|
||||
cd "$PIC_DIR"
|
||||
|
||||
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
|
||||
if ! make install 2>&1 | sed 's/^/ /'; then
|
||||
die "'make install' failed. Check the output above."
|
||||
fi
|
||||
|
||||
# make install runs as root so config/ and data/ get created root-owned.
|
||||
# Re-apply ownership to the invoking user so they can manage files without sudo.
|
||||
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
|
||||
|
||||
log_ok "'make install' complete"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 7 — Start core services
|
||||
# Step 6 — Start core services
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step 7 "Starting core services..."
|
||||
log_step 6 "Starting core services..."
|
||||
|
||||
cd "$PIC_DIR"
|
||||
|
||||
@@ -501,9 +270,9 @@ fi
|
||||
log_ok "Core services started"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8 — Health check + print wizard URL
|
||||
# Step 7 — Health check + print wizard URL
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step 8 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
|
||||
log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
|
||||
|
||||
ELAPSED=0
|
||||
HEALTHY=0
|
||||
@@ -541,14 +310,7 @@ printf "\n${GREEN}${BOLD}=======================================================
|
||||
printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n"
|
||||
printf "${GREEN}${BOLD}============================================================${RESET}\n"
|
||||
printf "\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 " Open the setup wizard to configure your cell:\n"
|
||||
printf "\n"
|
||||
printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n"
|
||||
printf "\n"
|
||||
|
||||
+70
-28
@@ -200,43 +200,42 @@ function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false,
|
||||
|
||||
function Step1CellName({ value, onChange, onNext }) {
|
||||
const [error, setError] = useState('');
|
||||
const [serverError, setServerError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [availability, setAvailability] = useState(null); // null | 'available' | 'taken' | 'unknown'
|
||||
|
||||
const validate = () => {
|
||||
if (!value.trim()) return 'Cell name is required.';
|
||||
if (!CELL_NAME_RE.test(value))
|
||||
return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 2–31 characters.';
|
||||
return '';
|
||||
const isValid = CELL_NAME_RE.test(value);
|
||||
|
||||
const handleChange = v => {
|
||||
onChange(v.toLowerCase());
|
||||
setError('');
|
||||
setAvailability(null);
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
const err = validate();
|
||||
setError(err);
|
||||
setServerError('');
|
||||
if (err) return;
|
||||
if (!value.trim()) { setError('Cell name is required.'); return; }
|
||||
if (!isValid) { setError('Use lowercase letters, numbers, and hyphens only. Must start with a letter. 2–31 characters.'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await setupAPI.validate('cell_name', { cell_name: value });
|
||||
const res = await setupAPI.validate('pic_ngo_available', { cell_name: value });
|
||||
const avail = res.data?.available;
|
||||
setAvailability(avail ? 'available' : 'taken');
|
||||
onNext();
|
||||
} catch (e) {
|
||||
setServerError(
|
||||
e?.response?.data?.error || 'Validation failed. Please try a different name.'
|
||||
);
|
||||
setAvailability('unknown');
|
||||
onNext(); // don't block — availability check is informational
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = CELL_NAME_RE.test(value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={1}
|
||||
title="Name your cell"
|
||||
description="This becomes your cell's identity and subdomain. If you choose pic.ngo it will be reachable at name.pic.ngo."
|
||||
description="This becomes your cell's identity. If you choose pic.ngo it will be reachable at name.pic.ngo."
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
|
||||
@@ -248,29 +247,25 @@ function Step1CellName({ value, onChange, onNext }) {
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
onChange(e.target.value.toLowerCase());
|
||||
setError('');
|
||||
setServerError('');
|
||||
}}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
||||
placeholder="e.g. myhome"
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
|
||||
aria-describedby={error || serverError ? 'cell-name-error' : undefined}
|
||||
/>
|
||||
{isValid ? (
|
||||
{isValid && (
|
||||
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span>
|
||||
{availability === 'available' && <span className="text-green-400 ml-1">— available</span>}
|
||||
{availability === 'taken' && <span className="text-yellow-400 ml-1">— may already be taken</span>}
|
||||
</p>
|
||||
) : (
|
||||
)}
|
||||
{!isValid && value && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
||||
</p>
|
||||
)}
|
||||
<div id="cell-name-error">
|
||||
<FieldError message={error || serverError} />
|
||||
</div>
|
||||
<FieldError message={error} />
|
||||
</div>
|
||||
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
|
||||
</div>
|
||||
@@ -410,6 +405,26 @@ function Step3Domain({ value, onChange, onNext, onBack }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenVerifyButton({ onVerify, status }) {
|
||||
const label = status === 'checking' ? 'Verifying…'
|
||||
: status === 'valid' ? '✓ Valid'
|
||||
: status === 'invalid' ? 'Invalid — retry'
|
||||
: 'Verify token';
|
||||
const cls = status === 'valid' ? 'text-green-400 border-green-600'
|
||||
: status === 'invalid' ? 'text-red-400 border-red-600'
|
||||
: 'text-blue-400 border-blue-600 hover:bg-blue-900/20';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerify}
|
||||
disabled={status === 'checking'}
|
||||
className={`mt-2 px-3 py-1 text-xs border rounded transition-colors ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Step4DomainConfig({
|
||||
domainType, cellName,
|
||||
customDomain, onCustomDomain,
|
||||
@@ -419,6 +434,25 @@ function Step4DomainConfig({
|
||||
onNext, onBack,
|
||||
}) {
|
||||
const [errors, setErrors] = useState({});
|
||||
const [cfStatus, setCfStatus] = useState(null); // null|checking|valid|invalid
|
||||
const [dnsStatus, setDnsStatus] = useState(null);
|
||||
|
||||
const verifyCf = async () => {
|
||||
setCfStatus('checking');
|
||||
try {
|
||||
const res = await setupAPI.validate('cloudflare_token', { token: cloudflareToken });
|
||||
setCfStatus(res.data?.valid ? 'valid' : 'invalid');
|
||||
} catch { setCfStatus('invalid'); }
|
||||
};
|
||||
|
||||
const verifyDns = async () => {
|
||||
const sub = customDomain.replace(/\.duckdns\.org$/i, '') || customDomain.split('.')[0];
|
||||
setDnsStatus('checking');
|
||||
try {
|
||||
const res = await setupAPI.validate('duckdns_token', { subdomain: sub, token: duckdnsToken });
|
||||
setDnsStatus(res.data?.valid ? 'valid' : 'invalid');
|
||||
} catch { setDnsStatus('invalid'); }
|
||||
};
|
||||
|
||||
// ── pic_ngo: just show the derived domain ────────────────────────────────
|
||||
if (domainType === 'pic_ngo') {
|
||||
@@ -538,6 +572,7 @@ function Step4DomainConfig({
|
||||
onChange={e => {
|
||||
onCloudflareToken(e.target.value);
|
||||
setErrors(p => ({ ...p, token: '' }));
|
||||
setCfStatus(null);
|
||||
}}
|
||||
placeholder="Cloudflare API token with DNS:Edit permission"
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
|
||||
@@ -545,6 +580,9 @@ function Step4DomainConfig({
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Create at Cloudflare Dashboard → My Profile → API Tokens. Needs Zone / DNS / Edit.
|
||||
</p>
|
||||
{cloudflareToken.trim() && (
|
||||
<TokenVerifyButton onVerify={verifyCf} status={cfStatus} />
|
||||
)}
|
||||
<FieldError message={errors.token} />
|
||||
</div>
|
||||
)}
|
||||
@@ -562,6 +600,7 @@ function Step4DomainConfig({
|
||||
onChange={e => {
|
||||
onDuckdnsToken(e.target.value);
|
||||
setErrors(p => ({ ...p, token: '' }));
|
||||
setDnsStatus(null);
|
||||
}}
|
||||
placeholder="Your DuckDNS account token"
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
|
||||
@@ -569,6 +608,9 @@ function Step4DomainConfig({
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Found at duckdns.org after login. The subdomain must already exist in your account.
|
||||
</p>
|
||||
{duckdnsToken.trim() && (
|
||||
<TokenVerifyButton onVerify={verifyDns} status={dnsStatus} />
|
||||
)}
|
||||
<FieldError message={errors.token} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user