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 logging
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import json as _json
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
logger = logging.getLogger('picell')
|
logger = logging.getLogger('picell')
|
||||||
|
|
||||||
setup_bp = Blueprint('setup', __name__, url_prefix='/api/setup')
|
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():
|
def _get_setup_manager():
|
||||||
from app import setup_manager
|
from app import setup_manager
|
||||||
@@ -24,8 +30,8 @@ def get_setup_status():
|
|||||||
def validate_setup_step():
|
def validate_setup_step():
|
||||||
"""Validate a single wizard 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()
|
sm = _get_setup_manager()
|
||||||
if sm.is_setup_complete():
|
if sm.is_setup_complete():
|
||||||
@@ -37,12 +43,36 @@ def validate_setup_step():
|
|||||||
|
|
||||||
if step == 'cell_name':
|
if step == 'cell_name':
|
||||||
errors = sm.validate_cell_name(data.get('cell_name', ''))
|
errors = sm.validate_cell_name(data.get('cell_name', ''))
|
||||||
elif step == 'password':
|
return jsonify({'valid': len(errors) == 0, 'errors': errors})
|
||||||
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'])
|
@setup_bp.route('/complete', methods=['POST'])
|
||||||
@@ -56,3 +86,37 @@ def complete_setup():
|
|||||||
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
|
||||||
return jsonify(result), status_code
|
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:
|
# Usage:
|
||||||
# sudo bash install.sh # Standard install
|
# bash install.sh # Standard install (uses sudo internally for packages)
|
||||||
# sudo bash install.sh --force # Bypass idempotency check
|
# bash install.sh --force # Bypass idempotency check
|
||||||
# sudo PIC_DIR=/srv/pic bash install.sh # Custom install directory
|
# PIC_DIR=/srv/pic bash install.sh # Custom install directory
|
||||||
#
|
#
|
||||||
# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk)
|
# 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; }
|
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
|
# Sudo check — we need it for package installs and system user creation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if ! command -v sudo >/dev/null 2>&1; then
|
||||||
die "This installer must be run as root (use sudo)."
|
die "sudo is required. Install it and ensure your user has sudo access."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -198,37 +149,32 @@ case "$PKG_MANAGER" in
|
|||||||
|
|
||||||
apt)
|
apt)
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
sudo apt-get update -qq
|
||||||
apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
|
sudo apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
|
||||||
| grep -v "^$" | sed 's/^/ /' || true
|
| grep -v "^$" | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Verify docker compose plugin installed
|
|
||||||
if ! docker compose version >/dev/null 2>&1; then
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
log_warn "docker-compose-plugin not available; falling back to standalone docker-compose"
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
dnf)
|
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)
|
sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
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
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
log_warn "docker compose plugin not found; installing docker-compose-plugin..."
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
apk)
|
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)
|
sudo rc-update add docker default >/dev/null 2>&1 || true
|
||||||
rc-update add docker default >/dev/null 2>&1 || true
|
sudo service docker start >/dev/null 2>&1 || true
|
||||||
service docker start >/dev/null 2>&1 || true
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
esac
|
esac
|
||||||
@@ -253,10 +199,10 @@ log_step 3 "Configuring system user..."
|
|||||||
if ! id "$PIC_USER" >/dev/null 2>&1; then
|
if ! id "$PIC_USER" >/dev/null 2>&1; then
|
||||||
case "$PKG_MANAGER" in
|
case "$PKG_MANAGER" in
|
||||||
apk)
|
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
|
esac
|
||||||
log_ok "Created system user: ${PIC_USER}"
|
log_ok "Created system user: ${PIC_USER}"
|
||||||
@@ -264,17 +210,18 @@ else
|
|||||||
log_ok "System user already exists: ${PIC_USER}"
|
log_ok "System user already exists: ${PIC_USER}"
|
||||||
fi
|
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
|
if ! getent group docker >/dev/null 2>&1; then
|
||||||
groupadd docker
|
sudo groupadd docker
|
||||||
log_ok "Created docker group"
|
log_ok "Created docker group"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! id -nG "$PIC_USER" | grep -qw docker; then
|
CURRENT_USER="${USER:-$(id -un)}"
|
||||||
usermod -aG docker "$PIC_USER"
|
if ! id -nG "$CURRENT_USER" | grep -qw docker; then
|
||||||
log_ok "Added ${PIC_USER} to docker group"
|
sudo usermod -aG docker "$CURRENT_USER"
|
||||||
|
log_ok "Added ${CURRENT_USER} to docker group (re-login or newgrp docker to apply)"
|
||||||
else
|
else
|
||||||
log_ok "${PIC_USER} is already in docker group"
|
log_ok "${CURRENT_USER} is already in docker group"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -294,203 +241,25 @@ else
|
|||||||
log_ok "Repository cloned to ${PIC_DIR}"
|
log_ok "Repository cloned to ${PIC_DIR}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Give the invoking user (or pic if run directly as root) ownership of the repo
|
sudo git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
|
||||||
# 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
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Step 5 — Configure cell identity
|
# Step 5 — Run make install
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
log_step 5 "Configuring cell identity..."
|
log_step 5 "Running 'make install'..."
|
||||||
|
|
||||||
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'..."
|
|
||||||
|
|
||||||
cd "$PIC_DIR"
|
cd "$PIC_DIR"
|
||||||
|
|
||||||
if ! CELL_NAME="$PIC_CELL_NAME" \
|
if ! make install 2>&1 | sed 's/^/ /'; then
|
||||||
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
|
||||||
|
|
||||||
# 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"
|
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"
|
cd "$PIC_DIR"
|
||||||
|
|
||||||
@@ -501,9 +270,9 @@ fi
|
|||||||
log_ok "Core services started"
|
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
|
ELAPSED=0
|
||||||
HEALTHY=0
|
HEALTHY=0
|
||||||
@@ -541,14 +310,7 @@ 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 " Cell: ${BOLD}%s${RESET}\n" "$PIC_CELL_NAME"
|
printf " Open the setup wizard to configure your cell:\n"
|
||||||
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"
|
||||||
|
|||||||
+70
-28
@@ -200,43 +200,42 @@ function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false,
|
|||||||
|
|
||||||
function Step1CellName({ value, onChange, onNext }) {
|
function Step1CellName({ value, onChange, onNext }) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [serverError, setServerError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [availability, setAvailability] = useState(null); // null | 'available' | 'taken' | 'unknown'
|
||||||
|
|
||||||
const validate = () => {
|
const isValid = CELL_NAME_RE.test(value);
|
||||||
if (!value.trim()) return 'Cell name is required.';
|
|
||||||
if (!CELL_NAME_RE.test(value))
|
const handleChange = v => {
|
||||||
return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 2–31 characters.';
|
onChange(v.toLowerCase());
|
||||||
return '';
|
setError('');
|
||||||
|
setAvailability(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
const err = validate();
|
if (!value.trim()) { setError('Cell name is required.'); return; }
|
||||||
setError(err);
|
if (!isValid) { setError('Use lowercase letters, numbers, and hyphens only. Must start with a letter. 2–31 characters.'); return; }
|
||||||
setServerError('');
|
|
||||||
if (err) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
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();
|
onNext();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setServerError(
|
setAvailability('unknown');
|
||||||
e?.response?.data?.error || 'Validation failed. Please try a different name.'
|
onNext(); // don't block — availability check is informational
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValid = CELL_NAME_RE.test(value);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
step={1}
|
step={1}
|
||||||
title="Name your cell"
|
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>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
|
<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"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={e => {
|
onChange={e => handleChange(e.target.value)}
|
||||||
onChange(e.target.value.toLowerCase());
|
|
||||||
setError('');
|
|
||||||
setServerError('');
|
|
||||||
}}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
||||||
placeholder="e.g. myhome"
|
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"
|
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">
|
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
|
||||||
<Globe className="h-3.5 w-3.5" />
|
<Globe className="h-3.5 w-3.5" />
|
||||||
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span>
|
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>
|
</p>
|
||||||
) : (
|
)}
|
||||||
|
{!isValid && value && (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div id="cell-name-error">
|
<FieldError message={error} />
|
||||||
<FieldError message={error || serverError} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
|
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
|
||||||
</div>
|
</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({
|
function Step4DomainConfig({
|
||||||
domainType, cellName,
|
domainType, cellName,
|
||||||
customDomain, onCustomDomain,
|
customDomain, onCustomDomain,
|
||||||
@@ -419,6 +434,25 @@ function Step4DomainConfig({
|
|||||||
onNext, onBack,
|
onNext, onBack,
|
||||||
}) {
|
}) {
|
||||||
const [errors, setErrors] = useState({});
|
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 ────────────────────────────────
|
// ── pic_ngo: just show the derived domain ────────────────────────────────
|
||||||
if (domainType === 'pic_ngo') {
|
if (domainType === 'pic_ngo') {
|
||||||
@@ -538,6 +572,7 @@ function Step4DomainConfig({
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
onCloudflareToken(e.target.value);
|
onCloudflareToken(e.target.value);
|
||||||
setErrors(p => ({ ...p, token: '' }));
|
setErrors(p => ({ ...p, token: '' }));
|
||||||
|
setCfStatus(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Cloudflare API token with DNS:Edit permission"
|
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Create at Cloudflare Dashboard → My Profile → API Tokens. Needs Zone / DNS / Edit.
|
Create at Cloudflare Dashboard → My Profile → API Tokens. Needs Zone / DNS / Edit.
|
||||||
</p>
|
</p>
|
||||||
|
{cloudflareToken.trim() && (
|
||||||
|
<TokenVerifyButton onVerify={verifyCf} status={cfStatus} />
|
||||||
|
)}
|
||||||
<FieldError message={errors.token} />
|
<FieldError message={errors.token} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -562,6 +600,7 @@ function Step4DomainConfig({
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
onDuckdnsToken(e.target.value);
|
onDuckdnsToken(e.target.value);
|
||||||
setErrors(p => ({ ...p, token: '' }));
|
setErrors(p => ({ ...p, token: '' }));
|
||||||
|
setDnsStatus(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Your DuckDNS account token"
|
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Found at duckdns.org after login. The subdomain must already exist in your account.
|
Found at duckdns.org after login. The subdomain must already exist in your account.
|
||||||
</p>
|
</p>
|
||||||
|
{duckdnsToken.trim() && (
|
||||||
|
<TokenVerifyButton onVerify={verifyDns} status={dnsStatus} />
|
||||||
|
)}
|
||||||
<FieldError message={errors.token} />
|
<FieldError message={errors.token} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user