Compare commits
14 Commits
515f3d5075
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 925ab1f696 | |||
| 439886624e | |||
| 24877df976 | |||
| bfa0d99dd1 | |||
| 1e2cf5580f | |||
| 1989dfa0a3 | |||
| 5dab6377bc | |||
| 0a24d20bbc | |||
| 46599bd37e | |||
| dde4d9a53f | |||
| 674a66f7a0 | |||
| 9df3bf6a17 | |||
| 0773179962 | |||
| 3a35cf72d3 |
+5
-1
@@ -86,4 +86,8 @@ backups/
|
|||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Coverage data
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
@@ -1,87 +1,282 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file is the primary context source for Claude Code in this repository. Read it fully before touching any code.
|
||||||
|
|
||||||
## What This Project Is
|
---
|
||||||
|
|
||||||
**Personal Internet Cell (PIC)** — a self-hosted digital infrastructure platform. It manages DNS, DHCP, NTP, WireGuard VPN, email, calendar/contacts (CalDAV), file storage (WebDAV), reverse proxy (Caddy), a certificate authority, and container orchestration, all from a single API + React UI.
|
## Project Overview
|
||||||
|
|
||||||
## Common Commands
|
**Personal Internet Cell (PIC)** is a self-hosted digital infrastructure platform for individuals who want full ownership of their core internet services without relying on cloud providers.
|
||||||
|
|
||||||
```bash
|
A PIC instance runs DNS, DHCP, NTP, WireGuard VPN, email (SMTP/IMAP), calendar/contacts (CalDAV/CardDAV), file storage (WebDAV), HTTPS reverse proxy (Caddy), an internal certificate authority, and optional third-party services — all managed from a single REST API and a React web UI. No manual config-file editing is required for normal operations.
|
||||||
# Full stack
|
|
||||||
make start # docker-compose up -d
|
|
||||||
make stop # docker-compose down
|
|
||||||
make restart # docker-compose restart
|
|
||||||
make status # docker status + API health
|
|
||||||
make logs # docker-compose logs -f
|
|
||||||
make build # rebuild api image
|
|
||||||
|
|
||||||
# Tests
|
**Primary users:** technically capable individuals, homelab operators, small families or teams.
|
||||||
make test # pytest tests/ api/tests/
|
|
||||||
make test-coverage # pytest with coverage HTML report
|
|
||||||
make test-api # pytest tests/test_api_endpoints.py
|
|
||||||
pytest tests/test_<module>.py # single test file
|
|
||||||
|
|
||||||
# Local dev (no Docker)
|
**What the product optimizes for:**
|
||||||
pip install -r api/requirements.txt
|
- One-command install, browser-based first-run wizard, no manual `.env` editing for identity
|
||||||
python api/app.py # Flask API on :3000
|
- Everything managed through the API and UI — the user should never need to `ssh` for day-to-day operations
|
||||||
|
- Security by default: session auth, CSRF protection, WireGuard isolation, internal CA, no open API port
|
||||||
|
- Reliability and observability: structured logs, health monitoring, automated config backups
|
||||||
|
|
||||||
cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000)
|
**Key constraints:**
|
||||||
|
- Runs on a single Linux host with Docker; no Kubernetes, no swarm
|
||||||
|
- Must work on Debian, Ubuntu, Fedora, RHEL, and Alpine
|
||||||
|
- The Flask API must never be exposed directly; Caddy always proxies it
|
||||||
|
- All secrets live in `data/` (git-ignored), never in the repo
|
||||||
|
|
||||||
# WireGuard
|
---
|
||||||
make show-routes
|
|
||||||
make add-peer PEER_NAME=foo PEER_IP=10.0.0.5 PEER_KEY=<pubkey>
|
## Tech Stack
|
||||||
make list-peers
|
|
||||||
```
|
### Backend
|
||||||
|
- **Python 3.11** — Flask REST API (`api/app.py`)
|
||||||
|
- **Flask** — routing, sessions, before-request hooks (enforce_setup, enforce_auth, check_csrf)
|
||||||
|
- **bcrypt** — password hashing in `AuthManager`
|
||||||
|
- **Docker SDK for Python** — container lifecycle in `ContainerManager`
|
||||||
|
- **PyNaCl / Age** — encryption in `VaultManager`
|
||||||
|
- **pyotp** — TOTP for DDNS registration
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React 18** — SPA
|
||||||
|
- **Vite** — dev server and build (proxies `/api` → `:3000`)
|
||||||
|
- **Tailwind CSS** — all styling; no custom CSS files
|
||||||
|
- **Axios** — all API calls go through `src/services/api.js`
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- **Docker Compose** — all 12+ service containers
|
||||||
|
- **Caddy** — reverse proxy, TLS termination (Let's Encrypt DNS-01 or HTTP-01 or internal CA)
|
||||||
|
- **CoreDNS** — `.cell` TLD authoritative DNS
|
||||||
|
- **dnsmasq** — DHCP
|
||||||
|
- **chrony** — NTP
|
||||||
|
- **WireGuard** — VPN (kernel module, not userspace)
|
||||||
|
- **Postfix + Dovecot** — email via `docker-mailserver`
|
||||||
|
- **Radicale** — CalDAV/CardDAV
|
||||||
|
- **PowerDNS** — authoritative DNS on the DDNS VPS (separate repo: `pic-ddns`)
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- **Gitea Actions** — unit tests on every push, image builds on tag
|
||||||
|
- **act_runner** — self-hosted runner on pic0 (192.168.31.51)
|
||||||
|
- **Gitea Container Registry** — images pushed to `git.pic.ngo`
|
||||||
|
|
||||||
|
Do not introduce: Redux, styled-components, SQLAlchemy, Celery, or any async framework (asyncio/FastAPI) into the main API unless explicitly requested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Backend (`api/`)
|
```
|
||||||
|
Browser / WireGuard peer
|
||||||
|
└── Caddy (:80/:443) TLS termination, reverse proxy
|
||||||
|
└── React SPA (:8081) Vite + Tailwind (Nginx in container)
|
||||||
|
└── Flask API (:3000) REST API, bound to 127.0.0.1 only
|
||||||
|
├── NetworkManager CoreDNS, dnsmasq, chrony
|
||||||
|
├── WireGuardManager WireGuard peer lifecycle
|
||||||
|
├── PeerRegistry peer registration and trust
|
||||||
|
├── EmailManager Postfix + Dovecot
|
||||||
|
├── CalendarManager Radicale CalDAV/CardDAV
|
||||||
|
├── FileManager WebDAV + Filegator
|
||||||
|
├── RoutingManager iptables NAT and routing
|
||||||
|
├── FirewallManager iptables INPUT/FORWARD rules
|
||||||
|
├── VaultManager internal CA, TLS certs, Age encryption
|
||||||
|
├── ContainerManager Docker SDK
|
||||||
|
├── CellLinkManager site-to-site WireGuard links
|
||||||
|
├── ConnectivityManager per-peer exit routing (WG ext, OpenVPN, Tor)
|
||||||
|
├── DDNSManager dynamic DNS heartbeat
|
||||||
|
├── ServiceStoreManager optional service install/remove
|
||||||
|
├── CaddyManager Caddyfile generation and reload
|
||||||
|
├── AuthManager bcrypt passwords, session auth, RBAC
|
||||||
|
└── SetupManager first-run wizard state
|
||||||
|
```
|
||||||
|
|
||||||
All service managers inherit `BaseServiceManager` (`api/base_service_manager.py`). This enforces a consistent interface: `get_status()`, `get_config()`, `update_config()`, `validate_config()`, `test_connectivity()`, `get_logs()`, `restart_service()`. When adding or modifying a service manager, follow this pattern.
|
### Key files
|
||||||
|
|
||||||
The `ServiceBus` (`api/service_bus.py`) is a pub/sub event system used for inter-service communication. Services publish events (e.g., `SERVICE_STARTED`, `CONFIG_CHANGED`, `PEER_CONNECTED`) and subscribe to events from dependencies. Dependency graph is declared in the bus — e.g., `wireguard` depends on `network`; `email` depends on `network` and `vault`.
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `api/app.py` | Flask app, all REST endpoints, before-request hooks, health monitor thread |
|
||||||
|
| `api/managers.py` | Singleton instantiation of all service managers |
|
||||||
|
| `api/base_service_manager.py` | Abstract base class: `get_status`, `get_config`, `update_config`, `validate_config`, `test_connectivity`, `get_logs`, `restart_service` |
|
||||||
|
| `api/config_manager.py` | Single source of truth for `cell_config.json` — all read/write goes through here |
|
||||||
|
| `api/service_bus.py` | Pub/sub event system between managers |
|
||||||
|
| `webui/src/services/api.js` | Axios API client — all UI→API calls |
|
||||||
|
| `docker-compose.yml` | Container definitions and network topology |
|
||||||
|
| `Makefile` | All operational commands |
|
||||||
|
| `install.sh` | Bash installer served via `https://install.pic.ngo` |
|
||||||
|
|
||||||
`ConfigManager` (`api/config_manager.py`) is the single source of truth. Config lives in `/app/config/cell_config.json` (mapped from `config/api/`). All managers read/write through ConfigManager, which validates against per-service schemas and maintains automatic backups.
|
### Directory layout
|
||||||
|
|
||||||
`LogManager` (`api/log_manager.py`) provides structured JSON logging with rotation (5 MB / 5 backups per service). Use it instead of `print()` or raw `logging`.
|
```
|
||||||
|
api/ Flask API and all service managers
|
||||||
|
webui/ React SPA (Vite + Tailwind)
|
||||||
|
tests/ pytest unit tests (no running services required)
|
||||||
|
tests/integration/ require a running PIC stack
|
||||||
|
tests/e2e/ Playwright UI and WireGuard e2e tests
|
||||||
|
config/ Runtime config per service (mostly git-ignored)
|
||||||
|
data/ Runtime secrets and state (fully git-ignored)
|
||||||
|
scripts/ Setup and maintenance scripts
|
||||||
|
install.sh One-line installer entry point
|
||||||
|
Makefile All make targets
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
`app.py` (2000+ lines) contains all Flask REST endpoints, organized by service. It runs a background health-monitoring thread.
|
### Config and secrets
|
||||||
|
|
||||||
Service managers:
|
- Runtime config: `config/api/cell_config.json` — managed by `ConfigManager`, never edit directly
|
||||||
- `network_manager.py` — DNS (CoreDNS), DHCP (dnsmasq), NTP (chrony)
|
- Secrets and user data: `data/` — git-ignored, contains `auth_users.json`, WireGuard keys, DDNS token, CA key
|
||||||
- `wireguard_manager.py` — VPN peer lifecycle, QR codes
|
- DDNS config lives under the top-level `ddns` key in `cell_config.json`, accessed via `config_manager.configs.get('ddns', {})`
|
||||||
- `peer_registry.py` — peer registration/lookup
|
- Do not read `_identity.domain` expecting a dict — it is a plain string (the domain mode, e.g. `"pic_ngo"`)
|
||||||
- `routing_manager.py` — NAT, firewall rules, VPN gateway
|
|
||||||
- `vault_manager.py` — internal certificate authority
|
|
||||||
- `email_manager.py` — Postfix + Dovecot
|
|
||||||
- `calendar_manager.py` — Radicale CalDAV/CardDAV
|
|
||||||
- `file_manager.py` — WebDAV storage
|
|
||||||
- `container_manager.py` — Docker SDK wrappers
|
|
||||||
- `cell_manager.py` — top-level orchestration
|
|
||||||
|
|
||||||
### Frontend (`webui/`)
|
### Before-request hooks (app.py)
|
||||||
|
|
||||||
React 18 + Vite + Tailwind CSS. All API calls go through `src/services/api.js` (Axios). Vite dev server proxies `/api` to `localhost:3000`. Pages in `src/pages/`, shared components in `src/components/`.
|
Three hooks run on every request in this order:
|
||||||
|
1. `enforce_setup` — returns 428 for all `/api/*` except `/api/setup/*` and `/health` until setup is complete. Skipped when `app.config['TESTING']` is True.
|
||||||
|
2. `enforce_auth` — returns 401 if no session; returns 503 if users file exists but is empty (misconfiguration). Skipped when `app.config['TESTING']` is True.
|
||||||
|
3. `check_csrf` — requires `X-CSRF-Token` header on all mutating requests except `/api/auth/*` and `/api/setup/*`.
|
||||||
|
|
||||||
### Infrastructure
|
---
|
||||||
|
|
||||||
`docker-compose.yml` defines 13 services on a custom bridge network `cell-network` (172.20.0.0/16). Cell IPs default to 10.0.0.0/24. Key ports: 53 (DNS), 80/443 (Caddy), 3000 (API), 5173/8081 (WebUI), 51820/udp (WireGuard), 25/587/993 (mail), 5232 (CalDAV), 8080 (WebDAV).
|
## Coding Conventions
|
||||||
|
|
||||||
Config files for each service live under `config/<service>/`. Persistent data is under `data/` (git-ignored). WireGuard configs are also git-ignored.
|
### Python (API)
|
||||||
|
|
||||||
## Testing
|
- All managers inherit `BaseServiceManager` — always implement all abstract methods
|
||||||
|
- Use `self.logger` (from `BaseServiceManager`) — never `print()` or raw `logging`
|
||||||
|
- Config reads go through `self.config_manager` — never open `cell_config.json` directly
|
||||||
|
- Use `threading.RLock` for shared state; managers run in a multi-threaded Flask app
|
||||||
|
- Do not use `any` typing; be explicit
|
||||||
|
- Keep Flask route handlers thin — business logic belongs in the manager, not in `app.py`
|
||||||
|
- Error responses must be JSON: `jsonify({'error': '...'}), <status_code>`
|
||||||
|
- Do not catch bare `Exception` and silently swallow it — log at minimum
|
||||||
|
|
||||||
Tests live in `tests/` (28 files). Use mocking (`pytest-mock`) for external system calls. Integration tests in `test_integration.py` require Docker services running.
|
### JavaScript (webui)
|
||||||
|
|
||||||
## AI Collaboration Rules (Claude Code)
|
- All API calls go through `src/services/api.js` — never use `fetch` or a new Axios instance directly
|
||||||
|
- Use functional components; no class components
|
||||||
|
- Tailwind utilities only — no inline styles, no custom CSS files
|
||||||
|
- Keep page components in `src/pages/`, reusable UI in `src/components/`
|
||||||
|
- State: local `useState`/`useEffect` is fine; no Redux or global state library
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- No comments that describe *what* the code does — only *why* if non-obvious
|
||||||
|
- No dead code, no commented-out blocks
|
||||||
|
- No backwards-compat shims for things being removed
|
||||||
|
- Prefer editing existing files over creating new ones
|
||||||
|
- Tests that write to disk: mock `builtins.open` with `OSError` rather than relying on `/nonexistent/path` (CI runs as root and can create any path)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing and Quality
|
||||||
|
|
||||||
|
Before considering any task complete:
|
||||||
|
1. Run `make test` — all 1500+ unit tests must pass
|
||||||
|
2. Fix failures before committing — the pre-commit hook will block the commit anyway
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Use `unittest.mock` / `pytest-mock` for all Docker, filesystem, and subprocess calls
|
||||||
|
- Tests must pass in CI (rootless environment where filesystem assumptions don't hold)
|
||||||
|
- When testing write-failure paths, mock `builtins.open` with `side_effect=OSError` — do not rely on unwritable paths
|
||||||
|
- Integration tests (`tests/integration/`) require a running stack — exclude from CI with `--ignore=tests/integration`
|
||||||
|
- E2e tests (`tests/e2e/`) require Playwright — exclude from CI with `--ignore=tests/e2e`
|
||||||
|
- Add tests for any new API endpoint, manager method, or utility function
|
||||||
|
- Do not add tests for Flask routing boilerplate or trivial getters — test behaviour, not structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Placement Rules
|
||||||
|
|
||||||
|
| New thing | Where it goes |
|
||||||
|
|---|---|
|
||||||
|
| New service manager | `api/<name>_manager.py`, registered in `api/managers.py` and wired into `app.py` |
|
||||||
|
| New API endpoints | `app.py` — grouped with the relevant manager's existing endpoints |
|
||||||
|
| New React page | `webui/src/pages/` |
|
||||||
|
| Reusable UI component | `webui/src/components/` |
|
||||||
|
| New pytest test file | `tests/test_<module>.py` |
|
||||||
|
| Operational script | `scripts/` |
|
||||||
|
| Documentation | Update `README.md`, `QUICKSTART.md`, or `Personal Internet Cell – Project Wiki.md` as appropriate |
|
||||||
|
|
||||||
|
Do not create a new abstraction for a single use case. Do not create near-duplicate files — edit the existing one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- **Never expose the Flask API port (3000) directly** — it must always be behind Caddy
|
||||||
|
- **Never commit secrets** — `data/`, `.env`, `*.key`, `*.pem` are all git-ignored; keep it that way
|
||||||
|
- **Do not modify `enforce_setup` or `enforce_auth` hooks** without understanding the full auth flow — these are the security boundary
|
||||||
|
- **Do not change the `cell_config.json` schema** without updating `ConfigManager` validation and all manager reads
|
||||||
|
- **Do not rename API route paths** without checking the webui `api.js` client and any external callers
|
||||||
|
- **Do not modify WireGuard key generation** — losing the server private key means all peers must be re-provisioned
|
||||||
|
- Flag any change to auth flow, CSRF logic, or session management as security-sensitive before implementing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stack lifecycle (always use make — never call docker/docker-compose directly)
|
||||||
|
make start # build and start all containers
|
||||||
|
make stop # stop all containers
|
||||||
|
make restart # restart containers
|
||||||
|
make status # container status + API health check
|
||||||
|
make logs # follow all container logs
|
||||||
|
make logs-api # follow API logs only
|
||||||
|
make logs-caddy # follow Caddy logs
|
||||||
|
make shell-api # shell inside the API container
|
||||||
|
make build-api # rebuild API image after code change
|
||||||
|
make build-webui # rebuild webui image after code change
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
make test # pytest tests/ --ignore=tests/e2e --ignore=tests/integration
|
||||||
|
make test-coverage # coverage report in htmlcov/
|
||||||
|
pytest tests/test_<module>.py -v # single test file
|
||||||
|
|
||||||
|
# Local dev (no Docker)
|
||||||
|
pip install -r api/requirements.txt
|
||||||
|
python3 api/app.py # Flask API on :3000
|
||||||
|
|
||||||
|
cd webui && npm install && npm run dev # React UI on :5173 (proxies /api → :3000)
|
||||||
|
|
||||||
|
# Peer / WireGuard
|
||||||
|
make list-peers
|
||||||
|
make show-routes
|
||||||
|
|
||||||
|
# Admin password
|
||||||
|
make show-admin-password
|
||||||
|
make reset-admin-password
|
||||||
|
|
||||||
|
# Backup / restore
|
||||||
|
make backup
|
||||||
|
make restore
|
||||||
|
|
||||||
|
# Maintenance
|
||||||
|
make update # git pull + rebuild + restart
|
||||||
|
make uninstall # stop containers; prompt to delete config/ and data/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure Topology
|
||||||
|
|
||||||
|
| Machine | IP | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| pic0 | 192.168.31.51 | Dev machine — you are here. Run all commands directly. |
|
||||||
|
| pic1 | 192.168.31.52 | Test/staging PIC instance |
|
||||||
|
| Gitea | 192.168.31.50 | Self-hosted git server (`gitea@192.168.31.50:roof/pic.git`) |
|
||||||
|
| DDNS VPS | 192.168.31.101 (LAN) / 178.168.15.65 (public) | PowerDNS + FastAPI for `*.pic.ngo` DDNS |
|
||||||
|
|
||||||
|
The `roof` user on pic0 has passwordless sudo and is in the `docker` group — use both freely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Collaboration Rules
|
||||||
|
|
||||||
These rules apply to every Claude Code session in this repo:
|
These rules apply to every Claude Code session in this repo:
|
||||||
|
|
||||||
- **Read memory first** — load `/home/roof/.claude/projects/-home-roof/memory/MEMORY.md` and referenced files at session start.
|
- **Read memory first** — load `/home/roof/.claude/projects/-home-roof/memory/MEMORY.md` at session start; follow referenced memory files for relevant context.
|
||||||
- **Dev machine context** — you are already on pic0 (192.168.31.51), the dev machine. Execute commands here directly; do not ask the user to run them.
|
- **You are on pic0** — execute commands directly here; do not ask the user to run them.
|
||||||
- **Use all available agents** — spawn specialized sub-agents (pic-remote, pic-qa, pic-architect, etc.) for tasks that match their description.
|
- **`make` is the only container interface** — never call `docker` or `docker-compose` directly. All container lifecycle goes through `make start`, `make stop`, `make build`, `make logs`, etc.
|
||||||
- **make is the only interface** — never call docker/docker-compose directly. All container lifecycle operations go through `make start`, `make stop`, `make build`, `make logs`, etc.
|
- **Use specialized agents** — spawn `pic-remote` for VPS/pic1 SSH tasks, `pic-qa` for test writing, `pic-architect` for design decisions, `pic-designer` for UI review, `pic-devops` for docker-compose/Makefile changes, `pic-writer` for documentation.
|
||||||
- **Test every new feature** — after implementing any change, run `make test` before considering the task done.
|
- **Test before commit** — run `make test` and fix all failures before staging. The pre-commit hook enforces this, but run it manually first.
|
||||||
- **Test before commit** — the pre-commit hook enforces this, but run `make test` manually first and fix all failures before staging files.
|
- **No skipping hooks** — never use `--no-verify` unless the only change is documentation or a workflow file with no Python/JS.
|
||||||
|
- **Commits need context** — write commit messages that explain *why*, not just *what*. Always add the Co-Authored-By trailer.
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ check-deps:
|
|||||||
|
|
||||||
setup: check-deps
|
setup: check-deps
|
||||||
@echo "Setting up Personal Internet Cell..."
|
@echo "Setting up Personal Internet Cell..."
|
||||||
@sudo chown -R $$(id -u):$$(id -g) config/ data/ 2>/dev/null || true
|
@sudo chown -R $${SUDO_USER:-$$(id -un)}:$${SUDO_USER:-$$(id -un)} config/ data/ 2>/dev/null || true
|
||||||
CELL_NAME=$(or $(CELL_NAME),mycell) \
|
CELL_NAME=$(or $(CELL_NAME),mycell) \
|
||||||
CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \
|
CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \
|
||||||
VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \
|
VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \
|
||||||
@@ -131,6 +131,7 @@ shell-%:
|
|||||||
|
|
||||||
update:
|
update:
|
||||||
@echo "Pulling latest code..."
|
@echo "Pulling latest code..."
|
||||||
|
@git config --global --add safe.directory $$(pwd) 2>/dev/null || true
|
||||||
@git stash --include-untracked --quiet 2>/dev/null || true
|
@git stash --include-untracked --quiet 2>/dev/null || true
|
||||||
git pull
|
git pull
|
||||||
@git stash pop --quiet 2>/dev/null || true
|
@git stash pop --quiet 2>/dev/null || true
|
||||||
|
|||||||
+2
-2
@@ -199,8 +199,8 @@ def enforce_auth():
|
|||||||
backward-compatibility with pre-auth test suites.
|
backward-compatibility with pre-auth test suites.
|
||||||
"""
|
"""
|
||||||
path = request.path
|
path = request.path
|
||||||
# Always allow non-API paths and auth namespace
|
# Always allow non-API paths, auth namespace, and setup namespace
|
||||||
if not path.startswith('/api/') or path.startswith('/api/auth/'):
|
if not path.startswith('/api/') or path.startswith('/api/auth/') or path.startswith('/api/setup/'):
|
||||||
return None
|
return None
|
||||||
# Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session
|
# Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session
|
||||||
if path.startswith('/api/cells/peer-sync/'):
|
if path.startswith('/api/cells/peer-sync/'):
|
||||||
|
|||||||
+18
-7
@@ -128,9 +128,12 @@ class SetupManager:
|
|||||||
cell_name = payload.get('cell_name', '')
|
cell_name = payload.get('cell_name', '')
|
||||||
password = payload.get('password', '')
|
password = payload.get('password', '')
|
||||||
domain_mode = payload.get('domain_mode', '')
|
domain_mode = payload.get('domain_mode', '')
|
||||||
|
domain_name = payload.get('domain_name', '')
|
||||||
timezone = payload.get('timezone', '')
|
timezone = payload.get('timezone', '')
|
||||||
services_enabled = payload.get('services_enabled', [])
|
services_enabled = payload.get('services_enabled', [])
|
||||||
ddns_provider = payload.get('ddns_provider', 'none')
|
ddns_provider = payload.get('ddns_provider', 'none')
|
||||||
|
cloudflare_api_token = payload.get('cloudflare_api_token', '')
|
||||||
|
duckdns_token = payload.get('duckdns_token', '')
|
||||||
|
|
||||||
errors.extend(self.validate_cell_name(cell_name))
|
errors.extend(self.validate_cell_name(cell_name))
|
||||||
errors.extend(self.validate_password(password))
|
errors.extend(self.validate_password(password))
|
||||||
@@ -168,28 +171,36 @@ class SetupManager:
|
|||||||
if self.is_setup_complete():
|
if self.is_setup_complete():
|
||||||
return {'success': False, 'errors': ['Setup has already been completed.']}
|
return {'success': False, 'errors': ['Setup has already been completed.']}
|
||||||
|
|
||||||
# ── create admin user ──────────────────────────────────────────
|
# ── create or update admin user ────────────────────────────────
|
||||||
|
# The installer may have bootstrapped an admin account from a
|
||||||
|
# generated password. The wizard's job is to set the real password,
|
||||||
|
# so update it if the account already exists.
|
||||||
ok = self.auth_manager.create_user(
|
ok = self.auth_manager.create_user(
|
||||||
username='admin',
|
username='admin',
|
||||||
password=password,
|
password=password,
|
||||||
role='admin',
|
role='admin',
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return {'success': False, 'errors': ['Failed to create admin user. The username may already exist.']}
|
ok = self.auth_manager.update_password('admin', password)
|
||||||
|
if not ok:
|
||||||
|
return {'success': False, 'errors': ['Failed to set admin password.']}
|
||||||
|
|
||||||
# ── persist identity fields ────────────────────────────────────
|
# ── persist identity fields ────────────────────────────────────
|
||||||
self.config_manager.set_identity_field('cell_name', cell_name)
|
self.config_manager.set_identity_field('cell_name', cell_name)
|
||||||
self.config_manager.set_identity_field('domain_mode', domain_mode)
|
self.config_manager.set_identity_field('domain_mode', domain_mode)
|
||||||
|
if domain_name:
|
||||||
|
self.config_manager.set_identity_field('domain_name', domain_name)
|
||||||
self.config_manager.set_identity_field('timezone', timezone)
|
self.config_manager.set_identity_field('timezone', timezone)
|
||||||
self.config_manager.set_identity_field('services_enabled', services_enabled)
|
self.config_manager.set_identity_field('services_enabled', services_enabled)
|
||||||
self.config_manager.set_identity_field('ddns_provider', ddns_provider)
|
self.config_manager.set_identity_field('ddns_provider', ddns_provider)
|
||||||
|
if cloudflare_api_token:
|
||||||
|
self.config_manager.set_identity_field('cloudflare_api_token', cloudflare_api_token)
|
||||||
|
if duckdns_token:
|
||||||
|
self.config_manager.set_identity_field('duckdns_token', duckdns_token)
|
||||||
|
|
||||||
# NOTE: DDNS registration is deferred to Phase 3.
|
|
||||||
# For now we just store ddns_provider in config.
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'DDNS registration skipped (Phase 1). '
|
'DDNS registration deferred to Phase 3. '
|
||||||
'DDNS registration will happen in Phase 3. '
|
f'ddns_provider={ddns_provider!r} domain_name={domain_name!r}'
|
||||||
f'ddns_provider={ddns_provider!r} stored in identity config.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── mark setup complete (must be last) ─────────────────────────
|
# ── mark setup complete (must be last) ─────────────────────────
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"cell_name": "modified",
|
|
||||||
"domain": "cell.local",
|
|
||||||
"ip_range": "10.0.0.0/24",
|
|
||||||
"network": {
|
|
||||||
"dns_port": 53,
|
|
||||||
"dhcp_range": "10.0.0.100-10.0.0.200",
|
|
||||||
"ntp_servers": ["pool.ntp.org"]
|
|
||||||
},
|
|
||||||
"wireguard": {
|
|
||||||
"port": 51820,
|
|
||||||
"private_key": "test_key",
|
|
||||||
"address": "10.0.0.1/24"
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"domain": "cell.local",
|
|
||||||
"smtp_port": 25,
|
|
||||||
"imap_port": 143
|
|
||||||
},
|
|
||||||
"calendar": {
|
|
||||||
"port": 5232,
|
|
||||||
"data_dir": "/app/data/calendar"
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"port": 8080,
|
|
||||||
"data_dir": "/app/data/files"
|
|
||||||
},
|
|
||||||
"routing": {
|
|
||||||
"nat_enabled": true,
|
|
||||||
"firewall_enabled": true
|
|
||||||
},
|
|
||||||
"vault": {
|
|
||||||
"ca_configured": true,
|
|
||||||
"fernet_configured": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
# Personal Internet Cell - Network Configuration Guide
|
|
||||||
|
|
||||||
This guide explains how to configure networking for the Personal Internet Cell to provide internet access to WireGuard VPN clients.
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Overview](#overview)
|
|
||||||
2. [Network Architecture](#network-architecture)
|
|
||||||
3. [Quick Setup](#quick-setup)
|
|
||||||
4. [Detailed Configuration](#detailed-configuration)
|
|
||||||
5. [Troubleshooting](#troubleshooting)
|
|
||||||
6. [Advanced Configuration](#advanced-configuration)
|
|
||||||
7. [Security Considerations](#security-considerations)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Personal Internet Cell provides a complete VPN solution with internet access. This requires proper configuration of:
|
|
||||||
|
|
||||||
- **IP Forwarding**: Allow traffic to pass through the server
|
|
||||||
- **NAT (Network Address Translation)**: Translate private IPs to public IPs
|
|
||||||
- **Routing**: Direct traffic from VPN clients to the internet
|
|
||||||
- **Firewall Rules**: Control traffic flow and security
|
|
||||||
|
|
||||||
## Network Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Internet
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Host Server] (195.178.106.244)
|
|
||||||
│
|
|
||||||
├── [Docker Network] (172.20.0.0/16)
|
|
||||||
│ └── [WireGuard Container] (cell-wireguard)
|
|
||||||
│ └── [WireGuard Interface] (wg0: 10.0.0.1/24)
|
|
||||||
│
|
|
||||||
└── [VPN Clients] (10.0.0.2-10.0.0.254/24)
|
|
||||||
└── [Internet Access via NAT]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
|
|
||||||
- **Host Interface**: `eth0` (or main network interface)
|
|
||||||
- **WireGuard Interface**: `wg0` (10.0.0.1/24)
|
|
||||||
- **Client Network**: `10.0.0.0/24`
|
|
||||||
- **NAT Translation**: Client IPs → Host IP
|
|
||||||
|
|
||||||
## Quick Setup
|
|
||||||
|
|
||||||
### 1. Run the Network Configuration Script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make the script executable (if not already done)
|
|
||||||
chmod +x /opt/pic/scripts/setup-network.sh
|
|
||||||
|
|
||||||
# Run the configuration
|
|
||||||
sudo /opt/pic/scripts/setup-network.sh setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Verify Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check status
|
|
||||||
sudo /opt/pic/scripts/setup-network.sh status
|
|
||||||
|
|
||||||
# Test configuration
|
|
||||||
sudo /opt/pic/scripts/setup-network.sh test
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Connect a VPN Client
|
|
||||||
|
|
||||||
Use the generated WireGuard configuration to connect a client. The client should now have internet access.
|
|
||||||
|
|
||||||
## Detailed Configuration
|
|
||||||
|
|
||||||
### IP Forwarding
|
|
||||||
|
|
||||||
IP forwarding allows the server to route packets between different network interfaces.
|
|
||||||
|
|
||||||
**Enable on Host:**
|
|
||||||
```bash
|
|
||||||
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
|
|
||||||
sysctl -p
|
|
||||||
```
|
|
||||||
|
|
||||||
**Enable in Container:**
|
|
||||||
```bash
|
|
||||||
docker exec cell-wireguard sh -c "echo 1 > /proc/sys/net/ipv4/ip_forward"
|
|
||||||
```
|
|
||||||
|
|
||||||
### NAT Configuration
|
|
||||||
|
|
||||||
NAT (Network Address Translation) allows VPN clients to access the internet using the server's public IP.
|
|
||||||
|
|
||||||
**Container NAT Rules:**
|
|
||||||
```bash
|
|
||||||
# Allow forwarding for WireGuard traffic
|
|
||||||
iptables -A FORWARD -i wg0 -j ACCEPT
|
|
||||||
iptables -A FORWARD -o wg0 -j ACCEPT
|
|
||||||
|
|
||||||
# NAT rule for internet access
|
|
||||||
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
|
|
||||||
```
|
|
||||||
|
|
||||||
**Host NAT Rules:**
|
|
||||||
```bash
|
|
||||||
# Allow traffic from WireGuard network
|
|
||||||
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
|
|
||||||
iptables -A FORWARD -i wg0 -j ACCEPT
|
|
||||||
iptables -A FORWARD -o wg0 -j ACCEPT
|
|
||||||
```
|
|
||||||
|
|
||||||
### Routing Configuration
|
|
||||||
|
|
||||||
**WireGuard Interface Setup:**
|
|
||||||
```bash
|
|
||||||
# Create WireGuard interface
|
|
||||||
ip link add dev wg0 type wireguard
|
|
||||||
|
|
||||||
# Set private key
|
|
||||||
wg set wg0 private-key /path/to/private-key
|
|
||||||
|
|
||||||
# Set listen port
|
|
||||||
wg set wg0 listen-port 51820
|
|
||||||
|
|
||||||
# Add IP address
|
|
||||||
ip addr add 10.0.0.1/24 dev wg0
|
|
||||||
|
|
||||||
# Bring interface up
|
|
||||||
ip link set wg0 up
|
|
||||||
|
|
||||||
# Add peers
|
|
||||||
wg set wg0 peer <public-key> allowed-ips 10.0.0.2/32
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### 1. VPN Connected but No Internet
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- WireGuard shows connected
|
|
||||||
- Can ping server (10.0.0.1)
|
|
||||||
- Cannot access internet
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
```bash
|
|
||||||
# Check IP forwarding
|
|
||||||
cat /proc/sys/net/ipv4/ip_forward
|
|
||||||
# Should return 1
|
|
||||||
|
|
||||||
# Check NAT rules
|
|
||||||
iptables -t nat -L POSTROUTING -n
|
|
||||||
# Should show MASQUERADE rule for 10.0.0.0/24
|
|
||||||
|
|
||||||
# Check forwarding rules
|
|
||||||
iptables -L FORWARD -n
|
|
||||||
# Should show ACCEPT rules for wg0
|
|
||||||
|
|
||||||
# Restart network configuration
|
|
||||||
sudo /opt/pic/scripts/setup-network.sh reset
|
|
||||||
sudo /opt/pic/scripts/setup-network.sh setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Cannot Connect to VPN
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- WireGuard client cannot connect
|
|
||||||
- No handshake in server logs
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
```bash
|
|
||||||
# Check WireGuard interface
|
|
||||||
docker exec cell-wireguard wg show
|
|
||||||
|
|
||||||
# Check if port 51820 is open
|
|
||||||
netstat -ulnp | grep 51820
|
|
||||||
|
|
||||||
# Check firewall rules
|
|
||||||
ufw status
|
|
||||||
iptables -L INPUT -n
|
|
||||||
|
|
||||||
# Check Docker port mapping
|
|
||||||
docker port cell-wireguard
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. DNS Issues
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Can ping IP addresses
|
|
||||||
- Cannot resolve domain names
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
```bash
|
|
||||||
# Check DNS configuration in client config
|
|
||||||
# Should include: DNS = 8.8.8.8, 1.1.1.1
|
|
||||||
|
|
||||||
# Test DNS from container
|
|
||||||
docker exec cell-wireguard nslookup google.com
|
|
||||||
|
|
||||||
# Check if DNS is being blocked
|
|
||||||
docker exec cell-wireguard iptables -L -n | grep 53
|
|
||||||
```
|
|
||||||
|
|
||||||
### Diagnostic Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check network status
|
|
||||||
sudo /opt/pic/scripts/setup-network.sh status
|
|
||||||
|
|
||||||
# Test connectivity from container
|
|
||||||
docker exec cell-wireguard ping -c 3 8.8.8.8
|
|
||||||
|
|
||||||
# Check routing table
|
|
||||||
docker exec cell-wireguard ip route show
|
|
||||||
|
|
||||||
# Check interface status
|
|
||||||
docker exec cell-wireguard ip addr show wg0
|
|
||||||
|
|
||||||
# Check NAT rules
|
|
||||||
docker exec cell-wireguard iptables -t nat -L -n
|
|
||||||
|
|
||||||
# Check forwarding rules
|
|
||||||
docker exec cell-wireguard iptables -L FORWARD -n
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Custom DNS Servers
|
|
||||||
|
|
||||||
To use custom DNS servers, modify the WireGuard client configuration:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Interface]
|
|
||||||
PrivateKey = <private-key>
|
|
||||||
Address = 10.0.0.2/32
|
|
||||||
DNS = 1.1.1.1, 1.0.0.1, 8.8.8.8, 8.8.4.4
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = <server-public-key>
|
|
||||||
Endpoint = 195.178.106.244:51820
|
|
||||||
AllowedIPs = 0.0.0.0/0
|
|
||||||
PersistentKeepalive = 25
|
|
||||||
```
|
|
||||||
|
|
||||||
### Split Tunneling
|
|
||||||
|
|
||||||
To allow only specific traffic through the VPN:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Peer]
|
|
||||||
AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
||||||
# Only route private networks through VPN
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port Forwarding
|
|
||||||
|
|
||||||
To forward specific ports to VPN clients:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Forward port 8080 to client 10.0.0.2
|
|
||||||
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.2:8080
|
|
||||||
iptables -A FORWARD -p tcp -d 10.0.0.2 --dport 8080 -j ACCEPT
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bandwidth Limiting
|
|
||||||
|
|
||||||
To limit bandwidth for VPN clients:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install tc (traffic control)
|
|
||||||
apt-get install iproute2
|
|
||||||
|
|
||||||
# Limit client 10.0.0.2 to 1Mbps
|
|
||||||
tc qdisc add dev wg0 root handle 1: htb default 30
|
|
||||||
tc class add dev wg0 parent 1: classid 1:1 htb rate 1mbit
|
|
||||||
tc class add dev wg0 parent 1:1 classid 1:10 htb rate 1mbit ceil 1mbit
|
|
||||||
tc filter add dev wg0 protocol ip parent 1:0 prio 1 u32 match ip dst 10.0.0.2 flowid 1:10
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Firewall Rules
|
|
||||||
|
|
||||||
**Basic Security Rules:**
|
|
||||||
```bash
|
|
||||||
# Drop invalid packets
|
|
||||||
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
|
|
||||||
|
|
||||||
# Allow established connections
|
|
||||||
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
||||||
|
|
||||||
# Allow WireGuard traffic
|
|
||||||
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
|
||||||
|
|
||||||
# Allow SSH (if needed)
|
|
||||||
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
|
|
||||||
|
|
||||||
# Drop everything else
|
|
||||||
iptables -A INPUT -j DROP
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client Isolation
|
|
||||||
|
|
||||||
To prevent clients from communicating with each other:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Block inter-client communication
|
|
||||||
iptables -A FORWARD -i wg0 -o wg0 -j DROP
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
To log VPN traffic:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Log all WireGuard traffic
|
|
||||||
iptables -A FORWARD -i wg0 -j LOG --log-prefix "WG-FORWARD: "
|
|
||||||
iptables -A FORWARD -o wg0 -j LOG --log-prefix "WG-FORWARD: "
|
|
||||||
|
|
||||||
# Log NAT traffic
|
|
||||||
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -j LOG --log-prefix "WG-NAT: "
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Real-time Monitoring
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Monitor WireGuard connections
|
|
||||||
watch -n 1 "docker exec cell-wireguard wg show"
|
|
||||||
|
|
||||||
# Monitor traffic
|
|
||||||
watch -n 1 "docker exec cell-wireguard wg show wg0 transfer"
|
|
||||||
|
|
||||||
# Monitor NAT rules
|
|
||||||
watch -n 1 "iptables -t nat -L POSTROUTING -n -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Log Analysis
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check system logs
|
|
||||||
journalctl -u pic-network.service -f
|
|
||||||
|
|
||||||
# Check iptables logs
|
|
||||||
tail -f /var/log/kern.log | grep WG-
|
|
||||||
|
|
||||||
# Check Docker logs
|
|
||||||
docker logs cell-wireguard -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup and Recovery
|
|
||||||
|
|
||||||
### Backup Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup iptables rules
|
|
||||||
iptables-save > /opt/pic/backups/iptables-backup-$(date +%Y%m%d).rules
|
|
||||||
|
|
||||||
# Backup WireGuard configuration
|
|
||||||
cp /opt/pic/config/wireguard/wg_confs/wg0.conf /opt/pic/backups/wg0-backup-$(date +%Y%m%d).conf
|
|
||||||
|
|
||||||
# Backup network script
|
|
||||||
cp /opt/pic/scripts/setup-network.sh /opt/pic/backups/setup-network-backup-$(date +%Y%m%d).sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restore Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Restore iptables rules
|
|
||||||
iptables-restore < /opt/pic/backups/iptables-backup-YYYYMMDD.rules
|
|
||||||
|
|
||||||
# Restore WireGuard configuration
|
|
||||||
cp /opt/pic/backups/wg0-backup-YYYYMMDD.conf /opt/pic/config/wireguard/wg_confs/wg0.conf
|
|
||||||
docker restart cell-wireguard
|
|
||||||
```
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. Check the troubleshooting section above
|
|
||||||
2. Run the diagnostic commands
|
|
||||||
3. Check the logs for error messages
|
|
||||||
4. Verify your network configuration
|
|
||||||
5. Test with a simple client configuration
|
|
||||||
|
|
||||||
For additional help, check the main Personal Internet Cell documentation or create an issue in the project repository.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script to fix import statements in test files
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def fix_imports_in_file(file_path):
|
|
||||||
"""Fix import statements in a test file"""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Fix relative imports to absolute imports from api package
|
|
||||||
content = re.sub(r'from \.(\w+) import', r'from \1 import', content)
|
|
||||||
content = re.sub(r'import \.(\w+)', r'import \1', content)
|
|
||||||
|
|
||||||
# Add path setup if not present
|
|
||||||
if 'sys.path.insert' not in content and 'api_dir' not in content:
|
|
||||||
path_setup = '''import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add api directory to path
|
|
||||||
api_dir = Path(__file__).parent.parent / 'api'
|
|
||||||
sys.path.insert(0, str(api_dir))
|
|
||||||
|
|
||||||
'''
|
|
||||||
# Insert after the first import line
|
|
||||||
lines = content.split('\n')
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
if line.startswith('import ') or line.startswith('from '):
|
|
||||||
lines.insert(i, path_setup.rstrip())
|
|
||||||
break
|
|
||||||
content = '\n'.join(lines)
|
|
||||||
|
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
print(f"Fixed imports in {file_path}")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Fix all test files"""
|
|
||||||
tests_dir = Path('tests')
|
|
||||||
|
|
||||||
for test_file in tests_dir.glob('test_*.py'):
|
|
||||||
if test_file.name not in ['test_cli_tool.py', 'test_peer_registry.py']: # Already fixed
|
|
||||||
fix_imports_in_file(test_file)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Fix import statements in test files
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def fix_imports_in_file(file_path):
|
|
||||||
"""Fix import statements in a test file"""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Replace 'from api.' with 'from .'
|
|
||||||
content = re.sub(r'from api\.', 'from .', content)
|
|
||||||
content = re.sub(r'import api\.', 'import .', content)
|
|
||||||
|
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
print(f"Fixed imports in {file_path}")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
tests_dir = Path('tests')
|
|
||||||
|
|
||||||
for test_file in tests_dir.glob('test_*.py'):
|
|
||||||
fix_imports_in_file(test_file)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
+10
-5
@@ -245,23 +245,28 @@ else
|
|||||||
log_ok "Repository cloned to ${PIC_DIR}"
|
log_ok "Repository cloned to ${PIC_DIR}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure the pic user owns the directory
|
# Give the invoking user (or pic if run directly as root) ownership of the repo
|
||||||
chown -R "${PIC_USER}:${PIC_USER}" "$PIC_DIR"
|
# 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 — Run make install
|
# Step 5 — Run make install
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
log_step 5 "Running 'make install'..."
|
log_step 5 "Running 'make install'..."
|
||||||
|
|
||||||
# make install generates config, writes the systemd unit, and touches .installed.
|
|
||||||
# We run it as the pic user (via sudo -u) so files get correct ownership, but
|
|
||||||
# make install itself calls sudo internally where root is needed.
|
|
||||||
cd "$PIC_DIR"
|
cd "$PIC_DIR"
|
||||||
|
|
||||||
if ! make install 2>&1 | sed 's/^/ /'; then
|
if ! 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"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,559 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Comprehensive tests for Flask app endpoints
|
|
||||||
"""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
|
|
||||||
# Add api directory to path
|
|
||||||
api_dir = Path(__file__).parent / 'api'
|
|
||||||
sys.path.insert(0, str(api_dir))
|
|
||||||
|
|
||||||
class TestFlaskAppEndpoints(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test environment"""
|
|
||||||
# Create temporary directories
|
|
||||||
self.test_dir = tempfile.mkdtemp()
|
|
||||||
self.data_dir = os.path.join(self.test_dir, 'data')
|
|
||||||
self.config_dir = os.path.join(self.test_dir, 'config')
|
|
||||||
os.makedirs(self.data_dir, exist_ok=True)
|
|
||||||
os.makedirs(self.config_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
os.environ['TESTING'] = 'true'
|
|
||||||
os.environ['LOG_LEVEL'] = 'ERROR'
|
|
||||||
|
|
||||||
# Import and create app
|
|
||||||
from app import app
|
|
||||||
self.app = app
|
|
||||||
self.client = app.test_client()
|
|
||||||
|
|
||||||
# Mock external dependencies
|
|
||||||
self.patchers = []
|
|
||||||
|
|
||||||
# Mock subprocess.run
|
|
||||||
subprocess_patcher = patch('subprocess.run')
|
|
||||||
self.mock_subprocess = subprocess_patcher.start()
|
|
||||||
self.mock_subprocess.return_value.returncode = 0
|
|
||||||
self.mock_subprocess.return_value.stdout = b"test output"
|
|
||||||
self.patchers.append(subprocess_patcher)
|
|
||||||
|
|
||||||
# Mock docker
|
|
||||||
docker_patcher = patch('docker.from_env')
|
|
||||||
self.mock_docker = docker_patcher.start()
|
|
||||||
self.mock_docker_client = MagicMock()
|
|
||||||
self.mock_docker.return_value = self.mock_docker_client
|
|
||||||
self.patchers.append(docker_patcher)
|
|
||||||
|
|
||||||
# Mock file operations
|
|
||||||
file_patcher = patch('builtins.open', create=True)
|
|
||||||
self.mock_file = file_patcher.start()
|
|
||||||
self.mock_file.return_value.__enter__.return_value.read.return_value = '{}'
|
|
||||||
self.patchers.append(file_patcher)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
"""Clean up test environment"""
|
|
||||||
shutil.rmtree(self.test_dir)
|
|
||||||
for patcher in self.patchers:
|
|
||||||
patcher.stop()
|
|
||||||
|
|
||||||
def test_health_endpoint(self):
|
|
||||||
"""Test /health endpoint"""
|
|
||||||
response = self.client.get('/health')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_status_endpoint(self):
|
|
||||||
"""Test /api/status endpoint"""
|
|
||||||
response = self.client.get('/api/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_config_get_endpoint(self):
|
|
||||||
"""Test GET /api/config endpoint"""
|
|
||||||
response = self.client.get('/api/config')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, dict)
|
|
||||||
|
|
||||||
def test_api_config_put_endpoint(self):
|
|
||||||
"""Test PUT /api/config endpoint"""
|
|
||||||
test_config = {'test': 'value'}
|
|
||||||
response = self.client.put('/api/config',
|
|
||||||
data=json.dumps(test_config),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_config_backup_endpoint(self):
|
|
||||||
"""Test POST /api/config/backup endpoint"""
|
|
||||||
response = self.client.post('/api/config/backup')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('backup_id', data)
|
|
||||||
|
|
||||||
def test_api_config_backups_endpoint(self):
|
|
||||||
"""Test GET /api/config/backups endpoint"""
|
|
||||||
response = self.client.get('/api/config/backups')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_config_restore_endpoint(self):
|
|
||||||
"""Test POST /api/config/restore/<backup_id> endpoint"""
|
|
||||||
response = self.client.post('/api/config/restore/test_backup')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_config_export_endpoint(self):
|
|
||||||
"""Test GET /api/config/export endpoint"""
|
|
||||||
response = self.client.get('/api/config/export')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, dict)
|
|
||||||
|
|
||||||
def test_api_config_import_endpoint(self):
|
|
||||||
"""Test POST /api/config/import endpoint"""
|
|
||||||
test_config = {'test': 'value'}
|
|
||||||
response = self.client.post('/api/config/import',
|
|
||||||
data=json.dumps(test_config),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_services_bus_status_endpoint(self):
|
|
||||||
"""Test GET /api/services/bus/status endpoint"""
|
|
||||||
response = self.client.get('/api/services/bus/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('services', data)
|
|
||||||
|
|
||||||
def test_api_services_bus_events_endpoint(self):
|
|
||||||
"""Test GET /api/services/bus/events endpoint"""
|
|
||||||
response = self.client.get('/api/services/bus/events')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_services_bus_start_endpoint(self):
|
|
||||||
"""Test POST /api/services/bus/services/<service_name>/start endpoint"""
|
|
||||||
response = self.client.post('/api/services/bus/services/test/start')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_services_bus_stop_endpoint(self):
|
|
||||||
"""Test POST /api/services/bus/services/<service_name>/stop endpoint"""
|
|
||||||
response = self.client.post('/api/services/bus/services/test/stop')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_services_bus_restart_endpoint(self):
|
|
||||||
"""Test POST /api/services/bus/services/<service_name>/restart endpoint"""
|
|
||||||
response = self.client.post('/api/services/bus/services/test/restart')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_logs_services_endpoint(self):
|
|
||||||
"""Test GET /api/logs/services/<service> endpoint"""
|
|
||||||
response = self.client.get('/api/logs/services/test')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_logs_search_endpoint(self):
|
|
||||||
"""Test POST /api/logs/search endpoint"""
|
|
||||||
search_data = {'query': 'test', 'level': 'INFO'}
|
|
||||||
response = self.client.post('/api/logs/search',
|
|
||||||
data=json.dumps(search_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_logs_export_endpoint(self):
|
|
||||||
"""Test POST /api/logs/export endpoint"""
|
|
||||||
export_data = {'format': 'json', 'filters': {}}
|
|
||||||
response = self.client.post('/api/logs/export',
|
|
||||||
data=json.dumps(export_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('export_path', data)
|
|
||||||
|
|
||||||
def test_api_logs_statistics_endpoint(self):
|
|
||||||
"""Test GET /api/logs/statistics endpoint"""
|
|
||||||
response = self.client.get('/api/logs/statistics')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('total_entries', data)
|
|
||||||
|
|
||||||
def test_api_logs_rotate_endpoint(self):
|
|
||||||
"""Test POST /api/logs/rotate endpoint"""
|
|
||||||
response = self.client.post('/api/logs/rotate')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_dns_records_endpoints(self):
|
|
||||||
"""Test DNS records endpoints"""
|
|
||||||
# GET
|
|
||||||
response = self.client.get('/api/dns/records')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST
|
|
||||||
record_data = {'name': 'test.example.com', 'type': 'A', 'value': '192.168.1.1'}
|
|
||||||
response = self.client.post('/api/dns/records',
|
|
||||||
data=json.dumps(record_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE
|
|
||||||
response = self.client.delete('/api/dns/records',
|
|
||||||
data=json.dumps(record_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_dhcp_endpoints(self):
|
|
||||||
"""Test DHCP endpoints"""
|
|
||||||
# GET leases
|
|
||||||
response = self.client.get('/api/dhcp/leases')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST reservation
|
|
||||||
reservation_data = {'mac': '00:11:22:33:44:55', 'ip': '192.168.1.100'}
|
|
||||||
response = self.client.post('/api/dhcp/reservations',
|
|
||||||
data=json.dumps(reservation_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE reservation
|
|
||||||
response = self.client.delete('/api/dhcp/reservations',
|
|
||||||
data=json.dumps(reservation_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_ntp_status_endpoint(self):
|
|
||||||
"""Test GET /api/ntp/status endpoint"""
|
|
||||||
response = self.client.get('/api/ntp/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_network_info_endpoint(self):
|
|
||||||
"""Test GET /api/network/info endpoint"""
|
|
||||||
response = self.client.get('/api/network/info')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('interfaces', data)
|
|
||||||
|
|
||||||
def test_api_dns_status_endpoint(self):
|
|
||||||
"""Test GET /api/dns/status endpoint"""
|
|
||||||
response = self.client.get('/api/dns/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_network_test_endpoint(self):
|
|
||||||
"""Test POST /api/network/test endpoint"""
|
|
||||||
test_data = {'target': '8.8.8.8', 'type': 'ping'}
|
|
||||||
response = self.client.post('/api/network/test',
|
|
||||||
data=json.dumps(test_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_wireguard_endpoints(self):
|
|
||||||
"""Test WireGuard endpoints"""
|
|
||||||
# GET keys
|
|
||||||
response = self.client.get('/api/wireguard/keys')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('public_key', data)
|
|
||||||
|
|
||||||
# POST generate peer keys
|
|
||||||
response = self.client.post('/api/wireguard/keys/peer')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('public_key', data)
|
|
||||||
|
|
||||||
# GET config
|
|
||||||
response = self.client.get('/api/wireguard/config')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('config', data)
|
|
||||||
|
|
||||||
# GET peers
|
|
||||||
response = self.client.get('/api/wireguard/peers')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST add peer
|
|
||||||
peer_data = {'peer': 'test_peer', 'ip': '10.0.0.1', 'public_key': 'test_key'}
|
|
||||||
response = self.client.post('/api/wireguard/peers',
|
|
||||||
data=json.dumps(peer_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE remove peer
|
|
||||||
response = self.client.delete('/api/wireguard/peers',
|
|
||||||
data=json.dumps({'peer': 'test_peer'}),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# GET status
|
|
||||||
response = self.client.get('/api/wireguard/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_peers_endpoints(self):
|
|
||||||
"""Test peers endpoints"""
|
|
||||||
# GET peers
|
|
||||||
response = self.client.get('/api/peers')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST add peer
|
|
||||||
peer_data = {'peer': 'test_peer', 'ip': '10.0.0.1'}
|
|
||||||
response = self.client.post('/api/peers',
|
|
||||||
data=json.dumps(peer_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE remove peer
|
|
||||||
response = self.client.delete('/api/peers/test_peer')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
def test_api_email_endpoints(self):
|
|
||||||
"""Test email endpoints"""
|
|
||||||
# GET users
|
|
||||||
response = self.client.get('/api/email/users')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST create user
|
|
||||||
user_data = {'username': 'test_user', 'email': 'test@example.com'}
|
|
||||||
response = self.client.post('/api/email/users',
|
|
||||||
data=json.dumps(user_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE user
|
|
||||||
response = self.client.delete('/api/email/users/test_user')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# GET status
|
|
||||||
response = self.client.get('/api/email/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_calendar_endpoints(self):
|
|
||||||
"""Test calendar endpoints"""
|
|
||||||
# GET users
|
|
||||||
response = self.client.get('/api/calendar/users')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST create user
|
|
||||||
user_data = {'username': 'test_user', 'email': 'test@example.com'}
|
|
||||||
response = self.client.post('/api/calendar/users',
|
|
||||||
data=json.dumps(user_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE user
|
|
||||||
response = self.client.delete('/api/calendar/users/test_user')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# GET status
|
|
||||||
response = self.client.get('/api/calendar/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_files_endpoints(self):
|
|
||||||
"""Test files endpoints"""
|
|
||||||
# GET users
|
|
||||||
response = self.client.get('/api/files/users')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST create user
|
|
||||||
user_data = {'username': 'test_user'}
|
|
||||||
response = self.client.post('/api/files/users',
|
|
||||||
data=json.dumps(user_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# DELETE user
|
|
||||||
response = self.client.delete('/api/files/users/test_user')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# GET status
|
|
||||||
response = self.client.get('/api/files/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
def test_api_routing_endpoints(self):
|
|
||||||
"""Test routing endpoints"""
|
|
||||||
# GET status
|
|
||||||
response = self.client.get('/api/routing/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
# POST NAT rule
|
|
||||||
nat_data = {'type': 'masquerade', 'interface': 'eth0'}
|
|
||||||
response = self.client.post('/api/routing/nat',
|
|
||||||
data=json.dumps(nat_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('rule_id', data)
|
|
||||||
|
|
||||||
# GET NAT rules
|
|
||||||
response = self.client.get('/api/routing/nat')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_vault_endpoints(self):
|
|
||||||
"""Test vault endpoints"""
|
|
||||||
# GET status
|
|
||||||
response = self.client.get('/api/vault/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('status', data)
|
|
||||||
|
|
||||||
# GET certificates
|
|
||||||
response = self.client.get('/api/vault/certificates')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST generate certificate
|
|
||||||
cert_data = {'common_name': 'test.example.com'}
|
|
||||||
response = self.client.post('/api/vault/certificates',
|
|
||||||
data=json.dumps(cert_data),
|
|
||||||
content_type='application/json')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('certificate', data)
|
|
||||||
|
|
||||||
# GET CA certificate
|
|
||||||
response = self.client.get('/api/vault/ca/certificate')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('certificate', data)
|
|
||||||
|
|
||||||
def test_api_containers_endpoints(self):
|
|
||||||
"""Test containers endpoints"""
|
|
||||||
# GET containers
|
|
||||||
response = self.client.get('/api/containers')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
# POST start container
|
|
||||||
response = self.client.post('/api/containers/test/start')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# POST stop container
|
|
||||||
response = self.client.post('/api/containers/test/stop')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('success', data)
|
|
||||||
|
|
||||||
# GET container logs
|
|
||||||
response = self.client.get('/api/containers/test/logs')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_services_status_endpoint(self):
|
|
||||||
"""Test GET /api/services/status endpoint"""
|
|
||||||
response = self.client.get('/api/services/status')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('services', data)
|
|
||||||
|
|
||||||
def test_api_services_connectivity_endpoint(self):
|
|
||||||
"""Test GET /api/services/connectivity endpoint"""
|
|
||||||
response = self.client.get('/api/services/connectivity')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIn('results', data)
|
|
||||||
|
|
||||||
def test_api_health_history_endpoint(self):
|
|
||||||
"""Test GET /api/health/history endpoint"""
|
|
||||||
response = self.client.get('/api/health/history')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
def test_api_logs_endpoint(self):
|
|
||||||
"""Test GET /api/logs endpoint"""
|
|
||||||
response = self.client.get('/api/logs')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.data)
|
|
||||||
self.assertIsInstance(data, list)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -133,7 +133,8 @@ class TestGenerateCorefile(unittest.TestCase):
|
|||||||
self.assertIn('reload', content)
|
self.assertIn('reload', content)
|
||||||
|
|
||||||
def test_returns_false_on_write_error(self):
|
def test_returns_false_on_write_error(self):
|
||||||
result = firewall_manager.generate_corefile([], '/nonexistent/path/Corefile')
|
with unittest.mock.patch('builtins.open', side_effect=OSError('Permission denied')):
|
||||||
|
result = firewall_manager.generate_corefile([], '/any/path/Corefile')
|
||||||
self.assertFalse(result)
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
api_dir = Path(__file__).parent.parent / 'api'
|
api_dir = Path(__file__).parent.parent / 'api'
|
||||||
@@ -98,7 +99,8 @@ class TestWriteEnvFile(unittest.TestCase):
|
|||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
|
|
||||||
def test_returns_false_on_unwritable_path(self):
|
def test_returns_false_on_unwritable_path(self):
|
||||||
result = ip_utils.write_env_file('172.20.0.0/16', '/nonexistent/deep/path/.env')
|
with unittest.mock.patch('builtins.open', side_effect=OSError('Permission denied')):
|
||||||
|
result = ip_utils.write_env_file('172.20.0.0/16', '/any/path/.env')
|
||||||
self.assertFalse(result)
|
self.assertFalse(result)
|
||||||
|
|
||||||
def test_contains_cell_network(self):
|
def test_contains_cell_network(self):
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ def test_anon_blocked_from_peer_routes(anon_client):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_routes_bypass_auth(anon_client):
|
||||||
|
"""/api/setup/* must be reachable without a session — setup runs before any account exists."""
|
||||||
|
r = anon_client.get('/api/setup/status')
|
||||||
|
assert r.status_code != 401
|
||||||
|
|
||||||
|
|
||||||
def test_anon_blocked_from_peer_dashboard(anon_client):
|
def test_anon_blocked_from_peer_dashboard(anon_client):
|
||||||
r = anon_client.get('/api/peer/dashboard')
|
r = anon_client.get('/api/peer/dashboard')
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|||||||
@@ -252,10 +252,11 @@ def test_complete_setup_returns_error_when_create_user_fails(
|
|||||||
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
|
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
|
||||||
mock_config_manager.get_identity.return_value = {}
|
mock_config_manager.get_identity.return_value = {}
|
||||||
mock_auth_manager.create_user.return_value = False
|
mock_auth_manager.create_user.return_value = False
|
||||||
|
mock_auth_manager.update_password.return_value = False
|
||||||
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
|
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
|
||||||
result = setup_manager.complete_setup(_valid_payload())
|
result = setup_manager.complete_setup(_valid_payload())
|
||||||
assert result['success'] is False
|
assert result['success'] is False
|
||||||
assert any('admin' in e.lower() or 'user' in e.lower() for e in result['errors'])
|
assert any('admin' in e.lower() or 'password' in e.lower() for e in result['errors'])
|
||||||
|
|
||||||
|
|
||||||
# ── get_setup_status ──────────────────────────────────────────────────────────
|
# ── get_setup_status ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
+295
-81
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react';
|
||||||
import { setupAPI } from '../services/api';
|
import { setupAPI } from '../services/api';
|
||||||
|
|
||||||
// ── constants ─────────────────────────────────────────────────────────────────
|
// ── constants ─────────────────────────────────────────────────────────────────
|
||||||
@@ -13,27 +13,36 @@ const DOMAIN_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: 'pic_ngo',
|
value: 'pic_ngo',
|
||||||
label: 'PIC.NGO subdomain',
|
label: 'PIC.NGO subdomain',
|
||||||
description: 'Get a free yourname.pic.ngo domain — managed automatically.',
|
description: 'Get a free yourname.pic.ngo address — HTTPS and DDNS managed automatically.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'custom',
|
value: 'custom',
|
||||||
label: 'Custom domain',
|
label: 'Custom domain',
|
||||||
description: 'Bring your own domain. You will configure DNS records manually.',
|
description: 'Use your own domain with Cloudflare, DuckDNS, or standard HTTP challenge.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'lan_only',
|
value: 'lan',
|
||||||
label: 'LAN only',
|
label: 'LAN only',
|
||||||
description: 'No public domain. Accessible only on your local network and via VPN.',
|
description: 'No public domain. Accessible only on your local network and via VPN.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DDNS_OPTIONS = [
|
const CUSTOM_METHOD_OPTIONS = [
|
||||||
{ value: 'pic_ngo', label: 'pic.ngo (managed)', description: 'Automatic — no setup required.' },
|
{
|
||||||
{ value: 'cloudflare', label: 'Cloudflare', description: 'Use Cloudflare DNS with API token.' },
|
value: 'cloudflare',
|
||||||
{ value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' },
|
label: 'Cloudflare DNS',
|
||||||
{ value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' },
|
description: 'DNS-01 via Cloudflare API. Your domain must use Cloudflare nameservers.',
|
||||||
{ value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' },
|
},
|
||||||
{ value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' },
|
{
|
||||||
|
value: 'duckdns',
|
||||||
|
label: 'DuckDNS',
|
||||||
|
description: 'Free subdomain via duckdns.org with automatic DNS-01 challenge.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'http01',
|
||||||
|
label: 'HTTP-01 (any registrar)',
|
||||||
|
description: 'Standard ACME challenge. Port 80 must be publicly reachable.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPTIONAL_SERVICES = [
|
const OPTIONAL_SERVICES = [
|
||||||
@@ -55,35 +64,37 @@ function getAllTimezones() {
|
|||||||
try {
|
try {
|
||||||
return Intl.supportedValuesOf('timeZone');
|
return Intl.supportedValuesOf('timeZone');
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback list for older browsers
|
|
||||||
return [
|
return [
|
||||||
'UTC',
|
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
|
||||||
'America/New_York',
|
'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin',
|
||||||
'America/Chicago',
|
'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney',
|
||||||
'America/Denver',
|
|
||||||
'America/Los_Angeles',
|
|
||||||
'Europe/London',
|
|
||||||
'Europe/Paris',
|
|
||||||
'Europe/Berlin',
|
|
||||||
'Asia/Tokyo',
|
|
||||||
'Asia/Shanghai',
|
|
||||||
'Australia/Sydney',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function passwordStrength(pw) {
|
function passwordStrength(pw) {
|
||||||
if (!pw) return { label: '', color: '', width: '0%' };
|
if (!pw) return { label: '', color: '', width: '0%', score: 0 };
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (pw.length >= 12) score++;
|
if (pw.length >= 12) score++;
|
||||||
if (pw.length >= 16) score++;
|
if (pw.length >= 16) score++;
|
||||||
if (/[A-Z]/.test(pw)) score++;
|
if (/[A-Z]/.test(pw)) score++;
|
||||||
|
if (/[a-z]/.test(pw)) score++;
|
||||||
if (/[0-9]/.test(pw)) score++;
|
if (/[0-9]/.test(pw)) score++;
|
||||||
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||||||
if (score <= 1) return { label: 'Weak', color: 'bg-red-500', width: '20%' };
|
if (score <= 2) return { label: 'Weak', color: 'bg-red-500', width: '20%', score };
|
||||||
if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' };
|
if (score === 3) return { label: 'Fair', color: 'bg-yellow-500', width: '45%', score };
|
||||||
if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' };
|
if (score === 4) return { label: 'Good', color: 'bg-blue-500', width: '70%', score };
|
||||||
return { label: 'Strong', color: 'bg-green-500', width: '100%' };
|
return { label: 'Strong', color: 'bg-green-500', width: '100%', score };
|
||||||
|
}
|
||||||
|
|
||||||
|
function meetsApiRequirements(pw) {
|
||||||
|
return pw.length >= 12 && /[A-Z]/.test(pw) && /[a-z]/.test(pw) && /[0-9]/.test(pw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDomainMode(domainType, customMethod) {
|
||||||
|
if (domainType === 'pic_ngo') return 'pic_ngo';
|
||||||
|
if (domainType === 'lan') return 'lan';
|
||||||
|
return customMethod || 'http01';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── sub-components ────────────────────────────────────────────────────────────
|
// ── sub-components ────────────────────────────────────────────────────────────
|
||||||
@@ -218,12 +229,14 @@ function Step1CellName({ value, onChange, onNext }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs."
|
description="This becomes your cell's identity and subdomain. 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">
|
||||||
@@ -241,13 +254,20 @@ function Step1CellName({ value, onChange, onNext }) {
|
|||||||
setServerError('');
|
setServerError('');
|
||||||
}}
|
}}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
||||||
placeholder="e.g. homelab"
|
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}
|
aria-describedby={error || serverError ? 'cell-name-error' : undefined}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
{isValid ? (
|
||||||
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
|
||||||
</p>
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<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">
|
<div id="cell-name-error">
|
||||||
<FieldError message={error || serverError} />
|
<FieldError message={error || serverError} />
|
||||||
</div>
|
</div>
|
||||||
@@ -263,11 +283,18 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const strength = passwordStrength(password);
|
const strength = passwordStrength(password);
|
||||||
|
const ready = meetsApiRequirements(password) && password === confirm;
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const e = {};
|
const e = {};
|
||||||
if (!password) e.password = 'Password is required.';
|
if (!password) {
|
||||||
else if (password.length < 12) e.password = 'Password must be at least 12 characters.';
|
e.password = 'Password is required.';
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
if (password.length < 12) e.password = 'Must be at least 12 characters.';
|
||||||
|
else if (!/[A-Z]/.test(password)) e.password = 'Must contain at least one uppercase letter.';
|
||||||
|
else if (!/[a-z]/.test(password)) e.password = 'Must contain at least one lowercase letter.';
|
||||||
|
else if (!/[0-9]/.test(password)) e.password = 'Must contain at least one digit.';
|
||||||
if (!confirm) e.confirm = 'Please confirm your password.';
|
if (!confirm) e.confirm = 'Please confirm your password.';
|
||||||
else if (password !== confirm) e.confirm = 'Passwords do not match.';
|
else if (password !== confirm) e.confirm = 'Passwords do not match.';
|
||||||
return e;
|
return e;
|
||||||
@@ -279,14 +306,12 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
if (Object.keys(e).length === 0) onNext();
|
if (Object.keys(e).length === 0) onNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isReady = password.length >= 12 && password === confirm;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
step={2}
|
step={2}
|
||||||
title="Set admin password"
|
title="Set admin password"
|
||||||
description="This password protects access to your cell. Choose something strong and store it safely."
|
description="This password protects access to your cell. At least 12 characters with uppercase, lowercase, and a digit."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -313,7 +338,6 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Strength bar */}
|
|
||||||
{password.length > 0 && (
|
{password.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="w-full bg-gray-700 rounded-full h-1">
|
<div className="w-full bg-gray-700 rounded-full h-1">
|
||||||
@@ -356,7 +380,7 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
|
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
|
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!ready} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -386,27 +410,182 @@ function Step3Domain({ value, onChange, onNext, onBack }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step4DDNS({ value, onChange, onNext, onBack }) {
|
function Step4DomainConfig({
|
||||||
|
domainType, cellName,
|
||||||
|
customDomain, onCustomDomain,
|
||||||
|
customMethod, onCustomMethod,
|
||||||
|
cloudflareToken, onCloudflareToken,
|
||||||
|
duckdnsToken, onDuckdnsToken,
|
||||||
|
onNext, onBack,
|
||||||
|
}) {
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
// ── pic_ngo: just show the derived domain ────────────────────────────────
|
||||||
|
if (domainType === 'pic_ngo') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StepHeader
|
||||||
|
step={4}
|
||||||
|
title="Your pic.ngo domain"
|
||||||
|
description="Your cell will be reachable at the address below. HTTPS and DDNS are managed automatically."
|
||||||
|
/>
|
||||||
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-5 text-center mb-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Your public address</p>
|
||||||
|
<p className="text-2xl font-mono font-semibold text-white tracking-tight">
|
||||||
|
{cellName || '…'}.pic.ngo
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
|
DNS and TLS certificates are provisioned automatically via the pic.ngo API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Not the right name? Go back to step 1 to change your cell name.
|
||||||
|
</p>
|
||||||
|
<NavButtons onBack={onBack} onNext={onNext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── custom domain ─────────────────────────────────────────────────────────
|
||||||
|
const validateCustom = () => {
|
||||||
|
const e = {};
|
||||||
|
const dom = customDomain.trim();
|
||||||
|
if (!dom) {
|
||||||
|
e.domain = 'Domain name is required.';
|
||||||
|
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(dom)) {
|
||||||
|
e.domain = 'Enter a valid domain name (e.g. home.example.com).';
|
||||||
|
}
|
||||||
|
if (!customMethod) e.method = 'Select a TLS method.';
|
||||||
|
if (customMethod === 'cloudflare' && !cloudflareToken.trim())
|
||||||
|
e.token = 'Cloudflare API token is required.';
|
||||||
|
if (customMethod === 'duckdns' && !duckdnsToken.trim())
|
||||||
|
e.token = 'DuckDNS token is required.';
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const e = validateCustom();
|
||||||
|
setErrors(e);
|
||||||
|
if (Object.keys(e).length === 0) onNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReady =
|
||||||
|
customDomain.trim() &&
|
||||||
|
customMethod &&
|
||||||
|
(customMethod === 'http01' ||
|
||||||
|
(customMethod === 'cloudflare' && cloudflareToken.trim()) ||
|
||||||
|
(customMethod === 'duckdns' && duckdnsToken.trim()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
step={4}
|
step={4}
|
||||||
title="DDNS provider"
|
title="Domain configuration"
|
||||||
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup."
|
description="Enter your domain and choose how TLS certificates will be obtained."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-5">
|
||||||
{DDNS_OPTIONS.map(opt => (
|
{/* Domain name */}
|
||||||
<RadioOption
|
<div>
|
||||||
key={opt.value}
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
value={opt.value}
|
Domain name <span className="text-red-400">*</span>
|
||||||
label={opt.label}
|
</label>
|
||||||
description={opt.description}
|
<input
|
||||||
selected={value === opt.value}
|
type="text"
|
||||||
onChange={onChange}
|
value={customDomain}
|
||||||
|
onChange={e => {
|
||||||
|
onCustomDomain(e.target.value.toLowerCase().trim());
|
||||||
|
setErrors(p => ({ ...p, domain: '' }));
|
||||||
|
}}
|
||||||
|
placeholder="e.g. home.example.com"
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
))}
|
<FieldError message={errors.domain} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TLS method */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
TLS method <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{CUSTOM_METHOD_OPTIONS.map(opt => (
|
||||||
|
<RadioOption
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
description={opt.description}
|
||||||
|
selected={customMethod === opt.value}
|
||||||
|
onChange={v => {
|
||||||
|
onCustomMethod(v);
|
||||||
|
setErrors(p => ({ ...p, method: '', token: '' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FieldError message={errors.method} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cloudflare token */}
|
||||||
|
{customMethod === 'cloudflare' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Cloudflare API token <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={cloudflareToken}
|
||||||
|
onChange={e => {
|
||||||
|
onCloudflareToken(e.target.value);
|
||||||
|
setErrors(p => ({ ...p, token: '' }));
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Create at Cloudflare Dashboard → My Profile → API Tokens. Needs Zone / DNS / Edit.
|
||||||
|
</p>
|
||||||
|
<FieldError message={errors.token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DuckDNS token */}
|
||||||
|
{customMethod === 'duckdns' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
DuckDNS token <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={duckdnsToken}
|
||||||
|
onChange={e => {
|
||||||
|
onDuckdnsToken(e.target.value);
|
||||||
|
setErrors(p => ({ ...p, 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Found at duckdns.org after login. The subdomain must already exist in your account.
|
||||||
|
</p>
|
||||||
|
<FieldError message={errors.token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTTP-01 info */}
|
||||||
|
{customMethod === 'http01' && (
|
||||||
|
<div className="p-3 bg-yellow-950/40 border border-yellow-700/50 rounded-lg">
|
||||||
|
<p className="text-xs text-yellow-300">
|
||||||
|
<span className="font-semibold">Port 80 must be publicly reachable</span> from the
|
||||||
|
internet for Let's Encrypt HTTP-01 validation. Ensure your router forwards port 80
|
||||||
|
to this machine before completing setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
|
|
||||||
|
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,7 +605,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
|
|||||||
description="Choose which services to enable. You can change this later in Settings."
|
description="Choose which services to enable. You can change this later in Settings."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Optional services */}
|
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2 mb-6">
|
||||||
{OPTIONAL_SERVICES.map(svc => {
|
{OPTIONAL_SERVICES.map(svc => {
|
||||||
const checked = selected.includes(svc.key);
|
const checked = selected.includes(svc.key);
|
||||||
@@ -452,7 +630,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Always-on services */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||||
Always enabled
|
Always enabled
|
||||||
@@ -538,10 +715,18 @@ function ReviewRow({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
||||||
const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_type)?.label || fields.domain_type;
|
const domainDisplay =
|
||||||
const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider;
|
fields.domain_type === 'pic_ngo' ? `${fields.cell_name}.pic.ngo` :
|
||||||
const serviceLabels = fields.services.length
|
fields.domain_type === 'lan' ? 'LAN only (no public domain)' :
|
||||||
? fields.services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
|
fields.custom_domain || '(not set)';
|
||||||
|
|
||||||
|
const tlsDisplay =
|
||||||
|
fields.domain_type === 'pic_ngo' ? 'Automatic (pic.ngo)' :
|
||||||
|
fields.domain_type === 'lan' ? '—' :
|
||||||
|
CUSTOM_METHOD_OPTIONS.find(o => o.value === fields.custom_method)?.label || fields.custom_method;
|
||||||
|
|
||||||
|
const serviceLabels = (fields.services_enabled || []).length
|
||||||
|
? fields.services_enabled.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
|
||||||
: 'None selected';
|
: 'None selected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -554,9 +739,9 @@ function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
|||||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
|
||||||
<ReviewRow label="Cell name" value={fields.cell_name} />
|
<ReviewRow label="Cell name" value={fields.cell_name} />
|
||||||
<ReviewRow label="Admin password" value="••••••••••••" />
|
<ReviewRow label="Admin password" value="••••••••••••" />
|
||||||
<ReviewRow label="Domain type" value={domainLabel} />
|
<ReviewRow label="Domain" value={domainDisplay} />
|
||||||
{fields.domain_type !== 'lan_only' && (
|
{fields.domain_type !== 'lan' && (
|
||||||
<ReviewRow label="DDNS provider" value={ddnsLabel} />
|
<ReviewRow label="TLS / DNS" value={tlsDisplay} />
|
||||||
)}
|
)}
|
||||||
<ReviewRow label="Optional services" value={serviceLabels} />
|
<ReviewRow label="Optional services" value={serviceLabels} />
|
||||||
<ReviewRow label="Timezone" value={fields.timezone} />
|
<ReviewRow label="Timezone" value={fields.timezone} />
|
||||||
@@ -591,7 +776,10 @@ export default function Setup() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||||
const [domainType, setDomainType] = useState('pic_ngo');
|
const [domainType, setDomainType] = useState('pic_ngo');
|
||||||
const [ddnsProvider, setDdnsProvider] = useState('pic_ngo');
|
const [customDomain, setCustomDomain] = useState('');
|
||||||
|
const [customMethod, setCustomMethod] = useState('');
|
||||||
|
const [cloudflareToken, setCloudflareToken] = useState('');
|
||||||
|
const [duckdnsToken, setDuckdnsToken] = useState('');
|
||||||
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
|
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
|
||||||
const [timezone, setTimezone] = useState(
|
const [timezone, setTimezone] = useState(
|
||||||
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
|
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
|
||||||
@@ -601,39 +789,46 @@ export default function Setup() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
|
||||||
const skipDdns = domainType === 'lan_only';
|
const skipStep4 = domainType === 'lan';
|
||||||
|
|
||||||
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
||||||
const goBack = () => setStep(s => Math.max(s - 1, 1));
|
const goBack = () => setStep(s => Math.max(s - 1, 1));
|
||||||
|
|
||||||
// Skip step 4 when LAN only
|
const handleStep3Next = () => skipStep4 ? setStep(5) : setStep(4);
|
||||||
const handleStep3Next = () => {
|
|
||||||
if (skipDdns) setStep(5);
|
|
||||||
else setStep(4);
|
|
||||||
};
|
|
||||||
const handleStep4Back = () => setStep(3);
|
const handleStep4Back = () => setStep(3);
|
||||||
const handleStep5Back = () => {
|
const handleStep5Back = () => skipStep4 ? setStep(3) : setStep(4);
|
||||||
if (skipDdns) setStep(3);
|
|
||||||
else setStep(4);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const domainMode = getDomainMode(domainType, customMethod);
|
||||||
|
const domainName =
|
||||||
|
domainType === 'pic_ngo' ? `${cellName}.pic.ngo` :
|
||||||
|
domainType === 'lan' ? '' :
|
||||||
|
customDomain;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
cell_name: cellName,
|
cell_name: cellName,
|
||||||
password,
|
password,
|
||||||
domain_type: domainType,
|
domain_mode: domainMode,
|
||||||
...(skipDdns ? {} : { ddns_provider: ddnsProvider }),
|
domain_name: domainName,
|
||||||
services,
|
|
||||||
timezone,
|
timezone,
|
||||||
|
services_enabled: services,
|
||||||
|
...(domainType !== 'lan' && {
|
||||||
|
ddns_provider: domainType === 'pic_ngo' ? 'pic_ngo' : customMethod === 'http01' ? 'none' : customMethod,
|
||||||
|
}),
|
||||||
|
...(customMethod === 'cloudflare' && { cloudflare_api_token: cloudflareToken }),
|
||||||
|
...(customMethod === 'duckdns' && { duckdns_token: duckdnsToken }),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setupAPI.complete(payload);
|
await setupAPI.complete(payload);
|
||||||
setDone(true);
|
setDone(true);
|
||||||
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSubmitError(
|
setSubmitError(
|
||||||
|
e?.response?.data?.errors?.join(' ') ||
|
||||||
e?.response?.data?.error ||
|
e?.response?.data?.error ||
|
||||||
'Setup could not be completed. Please check your entries and try again.'
|
'Setup could not be completed. Please check your entries and try again.'
|
||||||
);
|
);
|
||||||
@@ -642,7 +837,14 @@ export default function Setup() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const allFields = { cell_name: cellName, domain_type: domainType, ddns_provider: ddnsProvider, services, timezone };
|
const reviewFields = {
|
||||||
|
cell_name: cellName,
|
||||||
|
domain_type: domainType,
|
||||||
|
custom_domain: customDomain,
|
||||||
|
custom_method: customMethod,
|
||||||
|
services_enabled: services,
|
||||||
|
timezone,
|
||||||
|
};
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
@@ -659,7 +861,6 @@ export default function Setup() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
|
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
|
||||||
<div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
|
<div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
|
||||||
{/* Page title */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
|
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
|
||||||
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
|
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
|
||||||
@@ -684,7 +885,20 @@ export default function Setup() {
|
|||||||
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
|
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
|
||||||
)}
|
)}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} />
|
<Step4DomainConfig
|
||||||
|
domainType={domainType}
|
||||||
|
cellName={cellName}
|
||||||
|
customDomain={customDomain}
|
||||||
|
onCustomDomain={setCustomDomain}
|
||||||
|
customMethod={customMethod}
|
||||||
|
onCustomMethod={setCustomMethod}
|
||||||
|
cloudflareToken={cloudflareToken}
|
||||||
|
onCloudflareToken={setCloudflareToken}
|
||||||
|
duckdnsToken={duckdnsToken}
|
||||||
|
onDuckdnsToken={setDuckdnsToken}
|
||||||
|
onNext={goNext}
|
||||||
|
onBack={handleStep4Back}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 5 && (
|
{step === 5 && (
|
||||||
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
|
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
|
||||||
@@ -694,7 +908,7 @@ export default function Setup() {
|
|||||||
)}
|
)}
|
||||||
{step === 7 && (
|
{step === 7 && (
|
||||||
<Step7Review
|
<Step7Review
|
||||||
fields={allFields}
|
fields={reviewFields}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
|
|||||||
Reference in New Issue
Block a user