docs: update documentation and project structure
- Update AGENTS.md, README.md, and docs/* with revisions - Refactor lib/acme.py and lib/state.py - Add tests for acme module - Remove install.sh and restart-services.sh (moved to scripts/) - Normalize vendor files (acme.sh, htm.js)
This commit is contained in:
@@ -8,6 +8,12 @@ __pycache__/
|
||||
# Package build
|
||||
*.egg-info/
|
||||
|
||||
# vendor dirs
|
||||
vendor/*
|
||||
!vendor/.empty
|
||||
webui/static/vendor/*
|
||||
!webui/static/vendor/.empty
|
||||
|
||||
# Tool caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -24,74 +24,72 @@ through the daemon client over a Unix socket.
|
||||
|
||||
### Code Layout
|
||||
|
||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA catch-all renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja).
|
||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja). All other paths return 404.
|
||||
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
|
||||
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
|
||||
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh.
|
||||
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
|
||||
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
|
||||
- `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming an endpoint here auto-updates both server registry and client calls. All blueprints and handlers import from here.
|
||||
- `daemon/handlers/*.py` — Privileged operation handlers (all `sudo` calls live here).
|
||||
- `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
|
||||
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
|
||||
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast.
|
||||
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers.
|
||||
- `lib/logging.py` — Logging setup used by both webui and daemon.
|
||||
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
|
||||
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
|
||||
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
|
||||
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
|
||||
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
|
||||
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
|
||||
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth).
|
||||
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
||||
|
||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
|
||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`).
|
||||
`__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
|
||||
|
||||
### Frontend (hoover)
|
||||
|
||||
Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
|
||||
|
||||
Conventions:
|
||||
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
|
||||
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
|
||||
- All imports from `/static/hoover/index.js` (barrel export).
|
||||
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })`.
|
||||
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
|
||||
- `h()` builds VNodes; `html` tag (from htm) enables JSX-like templates; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
|
||||
- Events: `h()` uses `on:click` prefix. `html` templates use camelCase `onClick` (adapter translates to `on:click`).
|
||||
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
|
||||
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
|
||||
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
|
||||
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
|
||||
- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
|
||||
|
||||
### Daemon Endpoints
|
||||
|
||||
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET` env var)
|
||||
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state change notifications
|
||||
- Can be started as `python -m daemon.server` or via the `vacuum-walld` console script
|
||||
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET`)
|
||||
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state notifications
|
||||
- Periodic polling per subsystem via `lib.state._DEFAULT_POLL_INTERVALS`, overridable with `VACUUM_WALL_POLL_INTERVALS` env var (format `subsystem:seconds,subsystem:seconds`)
|
||||
- Start as `python -m daemon.server` or via the `vacuum-walld` console script
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `VACUUM_WALL_DEV` — dev mode flag; when set, disables aggressive static asset caching
|
||||
- `VACUUM_WALLD_SOCKET` — override daemon socket path
|
||||
- `VACUUM_WALL_DEV` — dev mode flag; disables aggressive static asset caching
|
||||
- `VACUUM_WALL_LOG_LEVEL` — log level (default `INFO`)
|
||||
- `VACUUM_WALLD_SOCKET` — override daemon socket path (default `data/daemon.sock`)
|
||||
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
|
||||
|
||||
## Deployment
|
||||
|
||||
`install.sh` installs only system components and configures them; the project serves from the repo root by default. Options via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override. Use `--dev` to auto-detect repo owner as service user; non-dev requires `--user`.
|
||||
|
||||
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs at `PROJECT_DIR/data/acme/`.
|
||||
|
||||
### Service Start Order
|
||||
|
||||
`firewalld` → `avahi-daemon` → `dnsmasq` → `vacuum-walld` → `vacuum-wall`
|
||||
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5`
|
||||
- `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler)
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
|
||||
# Setup
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
bash scripts/update-vendor.sh # fetches acme.sh + htm.js
|
||||
|
||||
# Start (Flask only, binds 127.0.0.1:9090)
|
||||
.venv/bin/python webui/server.py
|
||||
```
|
||||
|
||||
Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart).
|
||||
|
||||
In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking.
|
||||
## Blueprint / Handler / lib Mapping
|
||||
|
||||
## Blueprint ↔ Handler ↔ lib Mapping
|
||||
|
||||
| Blueprint | URL Prefix | Handler Module | lib Module |
|
||||
|-----------------------|---------------------|--------------------------|-----------------|
|
||||
| Blueprint | URL Prefix | Handler | lib Module |
|
||||
|-----------------------|---------------------|----------------------------|-----------------|
|
||||
| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` |
|
||||
| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` |
|
||||
| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` |
|
||||
@@ -99,6 +97,7 @@ In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`,
|
||||
| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard`| `lib.wireguard` |
|
||||
| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
|
||||
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
|
||||
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
|
||||
|
||||
## Privileged Operations
|
||||
|
||||
@@ -111,20 +110,25 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
||||
|
||||
## API Response Contract
|
||||
|
||||
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common` (Flask) or `ok(data)` from `daemon.server` (aiohttp).
|
||||
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common` or `error(msg, code)` from `daemon.server`.
|
||||
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except.
|
||||
- HTTP codes: `400` bad request, `404` not found, `500` internal failure.
|
||||
- Success: `{"ok": true, "data": <value>}` — `_ok(data)` (Flask) or `ok(data)` (aiohttp)
|
||||
- Error: `{"ok": false, "error": "msg"}` — `_error(msg, code)` (Flask) or `error(msg, code)` (aiohttp)
|
||||
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
|
||||
- HTTP codes: `400` bad request, `404` not found, `409` conflict, `500` internal failure
|
||||
|
||||
## Deploy
|
||||
|
||||
`install.sh` is the single deploy script. Run as root. Only `MGMT_PASS` is strictly required; `MGMT_DOMAIN` is auto-detected from hostname.
|
||||
`scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars.
|
||||
`MGMT_PASS` is strictly required; `MGMT_DOMAIN` auto-detected from hostname.
|
||||
|
||||
### Service Start Order
|
||||
|
||||
`firewalld` → `avahi-daemon` → `dnsmasq` → `vacuum-walld` → `vacuum-wall`
|
||||
|
||||
## Lint and Tests
|
||||
|
||||
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`.
|
||||
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
|
||||
|
||||
**Tests:** pytest in `tests/`. Tests mock out subprocess calls — no system services required.
|
||||
**Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
|
||||
|
||||
```bash
|
||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||
@@ -132,24 +136,16 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
||||
.venv/bin/python -m pytest tests/ -v # test
|
||||
```
|
||||
|
||||
Install dev tooling with `pip install -e ".[dev]"`.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/` contains the authoritative reference for each subsystem. **Before reasoning about any subsystem**, read the relevant doc(s) below.
|
||||
`docs/` contains the authoritative reference for each subsystem. Read the relevant doc before reasoning about a subsystem.
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management |
|
||||
| `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels |
|
||||
| `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting |
|
||||
| `docs/deployment.md` | Install script options, what scripts/install.sh does, post-install setup, troubleshooting |
|
||||
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
|
||||
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
|
||||
| `docs/hoover.md` | Custom frontend framework API reference |
|
||||
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
|
||||
|
||||
## Important Rules
|
||||
1. Ask, don't assume. If something is unclear, ask before writing a single line.
|
||||
2. Simplest solution first. Always implement the simplest thing that could work.
|
||||
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it.
|
||||
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so.
|
||||
|
||||
@@ -28,13 +28,13 @@ MGMT_DOMAIN=wall.example.com \
|
||||
MGMT_PASS="strongpassword" \
|
||||
MGMT_USER="admin" \
|
||||
ACME_EMAIL="admin@example.com" \
|
||||
bash install.sh
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
### Install (Development)
|
||||
|
||||
```bash
|
||||
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||
./scripts/install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||
```
|
||||
|
||||
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
|
||||
@@ -52,7 +52,7 @@ bash install.sh
|
||||
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
|
||||
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
|
||||
|
||||
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage.
|
||||
CLI flags take precedence over environment variables. Run `./scripts/install.sh --help` for full usage.
|
||||
|
||||
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
|
||||
|
||||
|
||||
+140
-42
@@ -376,6 +376,22 @@ Toggle masquerade (source NAT) for a zone.
|
||||
| `zone` | `string` | Zone name |
|
||||
| `masquerade` | `boolean` | Whether masquerade is now enabled |
|
||||
|
||||
### State
|
||||
|
||||
#### Get Firewall State
|
||||
|
||||
```
|
||||
GET /api/firewall/state
|
||||
```
|
||||
|
||||
Return current firewall state from the daemon state store. Provides live firewall state data including active zones, services, and interfaces as polled by the daemon.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `object` | Firewall state data from the state collector |
|
||||
|
||||
### Info
|
||||
|
||||
#### Available Services
|
||||
@@ -476,12 +492,7 @@ POST /api/dhcp/apply
|
||||
|
||||
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied` | `boolean` | Always `true` on success |
|
||||
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
### Status
|
||||
|
||||
@@ -777,20 +788,6 @@ Returns HTTP `400` if the domain is already configured.
|
||||
|
||||
---
|
||||
|
||||
#### Get Domain Details
|
||||
|
||||
```
|
||||
GET /api/proxy/domains/<domain>
|
||||
```
|
||||
|
||||
Return the configuration for a single proxy domain.
|
||||
|
||||
**Response (`data`):** Domain name plus backend configuration fields.
|
||||
|
||||
Returns HTTP `404` if the domain is not configured.
|
||||
|
||||
---
|
||||
|
||||
#### Update Domain
|
||||
|
||||
```
|
||||
@@ -902,15 +899,35 @@ Return details for a single certificate.
|
||||
|
||||
Returns HTTP `404` if no certificate is found for the domain.
|
||||
|
||||
### Validation
|
||||
|
||||
#### Validate Certificate Issuance
|
||||
|
||||
```
|
||||
POST /api/certs/validate
|
||||
```
|
||||
|
||||
Run pre-flight checks before certificate issuance. Verifies domain format, ACME account registration, DNS resolution, and port 80 accessibility.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | `string` | Yes | Domain to validate |
|
||||
|
||||
**Response (`data`):** Validation results object with per-check status.
|
||||
|
||||
Returns HTTP `400` if the domain is missing.
|
||||
|
||||
### Operations
|
||||
|
||||
#### Issue Certificate
|
||||
#### Start Certificate Issuance
|
||||
|
||||
```
|
||||
POST /api/certs/issue
|
||||
POST /api/certs/issue/start
|
||||
```
|
||||
|
||||
Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
|
||||
Create a new certificate issuance request. Issuance runs asynchronously in the background.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
@@ -920,12 +937,30 @@ Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
|
||||
| `email` | `string` | No | ACME contact email — **deprecated**, ignored in favor of the registered account email |
|
||||
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `request_id` | `string` | Unique identifier for polling issuance status |
|
||||
|
||||
Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
|
||||
|
||||
---
|
||||
|
||||
#### Poll Certificate Issuance Status
|
||||
|
||||
```
|
||||
GET /api/certs/issue/<request_id>
|
||||
```
|
||||
|
||||
Poll the status of a certificate issuance request started by `POST /api/certs/issue/start`.
|
||||
|
||||
**Response (`data`):** Issuance status object containing progress, logs, and result.
|
||||
|
||||
Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to repeatedly fetch this endpoint until issuance completes or fails.
|
||||
|
||||
---
|
||||
|
||||
#### Renew Certificate
|
||||
|
||||
```
|
||||
@@ -1125,12 +1160,7 @@ POST /api/wireguard/apply
|
||||
|
||||
Write the current configuration to `wg0.conf` and bring the tunnel up.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied` | `boolean` | Always `true` on success |
|
||||
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
@@ -1429,12 +1459,54 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics
|
||||
|-------|------|-------------|
|
||||
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
|
||||
|
||||
Returns HTTP `500` if the value cannot be verified after write.
|
||||
|
||||
---
|
||||
|
||||
## Status API
|
||||
|
||||
Endpoints prefixed with `/api/status/...`. Aggregate status across all subsystems.
|
||||
|
||||
### Pending Changes
|
||||
|
||||
#### Check All Pending Changes
|
||||
|
||||
```
|
||||
GET /api/status/pending
|
||||
```
|
||||
|
||||
Aggregate pending changes across all subsystems. Useful for the dashboard to show which subsystems need configuration applied.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `subsystems` | `object` | Map of subsystem name to pending status |
|
||||
| `total_changes` | `number` | Total count of pending changes across all subsystems |
|
||||
|
||||
---
|
||||
|
||||
#### Apply All Pending Changes
|
||||
|
||||
```
|
||||
POST /api/status/apply-all
|
||||
```
|
||||
|
||||
Apply pending changes for all subsystems in dependency order.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
||||
| `errors` | `[object, ...]` | Any errors encountered during apply |
|
||||
|
||||
### Sysctl
|
||||
|
||||
#### Set Kernel Parameter
|
||||
|
||||
```
|
||||
POST /api/sysctl/set
|
||||
POST /api/network/sysctl/set
|
||||
```
|
||||
|
||||
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
|
||||
@@ -1459,7 +1531,7 @@ Returns HTTP `500` if the value cannot be verified after write.
|
||||
|
||||
## Logs API
|
||||
|
||||
Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
|
||||
Endpoints prefixed with `/api/logs/...`. Return log lines as JSON strings, wrapped in the standard `{"ok": true, "data": ...}` response contract.
|
||||
|
||||
### System Journal
|
||||
|
||||
@@ -1469,9 +1541,13 @@ Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the s
|
||||
GET /api/logs/journal
|
||||
```
|
||||
|
||||
Return recent system journal entries as rendered HTML log lines.
|
||||
Return recent system journal entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw journal log text |
|
||||
|
||||
### Nginx Logs
|
||||
|
||||
@@ -1481,9 +1557,15 @@ Return recent system journal entries as rendered HTML log lines.
|
||||
GET /api/logs/nginx/access
|
||||
```
|
||||
|
||||
Return recent nginx access log entries as rendered HTML.
|
||||
Return recent nginx access log entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw access log text |
|
||||
|
||||
Returns HTTP `404` if the log file does not exist.
|
||||
|
||||
---
|
||||
|
||||
@@ -1493,9 +1575,15 @@ Return recent nginx access log entries as rendered HTML.
|
||||
GET /api/logs/nginx/error
|
||||
```
|
||||
|
||||
Return recent nginx error log entries as rendered HTML.
|
||||
Return recent nginx error log entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw error log text |
|
||||
|
||||
Returns HTTP `404` if the log file does not exist.
|
||||
|
||||
### Dnsmasq Log
|
||||
|
||||
@@ -1505,9 +1593,13 @@ Return recent nginx error log entries as rendered HTML.
|
||||
GET /api/logs/dnsmasq
|
||||
```
|
||||
|
||||
Return recent dnsmasq journal entries as rendered HTML.
|
||||
Return recent dnsmasq journal entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw dnsmasq journal text |
|
||||
|
||||
### Application Log
|
||||
|
||||
@@ -1517,9 +1609,15 @@ Return recent dnsmasq journal entries as rendered HTML.
|
||||
GET /api/logs/app
|
||||
```
|
||||
|
||||
Return recent application log entries as rendered HTML.
|
||||
Return recent application log entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw application log text |
|
||||
|
||||
Returns HTTP `404` if the log file does not exist.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,13 +54,13 @@ Vacuum Wall uses two distinct system users bridged by a shared group:
|
||||
|
||||
This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`.
|
||||
|
||||
**Dev mode variant**: When `install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
|
||||
**Dev mode variant**: When `scripts/install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
|
||||
|
||||
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
|
||||
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `scripts/install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
|
||||
|
||||
## Install-Time Templating
|
||||
|
||||
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
|
||||
System configuration files in `system/` are Jinja2 templates rendered by `scripts/install.sh` at install time:
|
||||
|
||||
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
|
||||
- **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user.
|
||||
|
||||
+9
-9
@@ -25,13 +25,13 @@ Download the Vacuum Wall repository onto the target machine, then run the instal
|
||||
# Production: all env vars
|
||||
MGMT_DOMAIN=wall.example.com \
|
||||
MGMT_PASS="strongpassword" \
|
||||
./install.sh --user vacuum-wall
|
||||
./scripts/install.sh --user vacuum-wall
|
||||
|
||||
# Dev mode: CLI flags, auto-detects repo owner
|
||||
./install.sh --dev --mgmt-pass strongpassword
|
||||
./scripts/install.sh --dev --mgmt-pass strongpassword
|
||||
|
||||
# mDNS (LAN-only, no DNS record needed)
|
||||
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
|
||||
./scripts/install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
|
||||
```
|
||||
|
||||
### Options
|
||||
@@ -51,7 +51,7 @@ All settings that can be passed as an environment variable also have a CLI flag
|
||||
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
|
||||
| `--force-venv` | — | No | Force recreation of the Python virtual environment. |
|
||||
|
||||
Run `./install.sh --help` for full usage.
|
||||
Run `./scripts/install.sh --help` for full usage.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +71,7 @@ In dev mode, the ownership model preserves the developer's ability to work with
|
||||
### Running the Installer in Dev Mode
|
||||
|
||||
```bash
|
||||
./install.sh --dev --mgmt-pass strongpassword
|
||||
./scripts/install.sh --dev --mgmt-pass strongpassword
|
||||
```
|
||||
|
||||
The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above.
|
||||
@@ -88,7 +88,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o
|
||||
|
||||
```bash
|
||||
# Docker volume mount example
|
||||
./install.sh --path /app/vacuum-wall --user ww-app \
|
||||
./scripts/install.sh --path /app/vacuum-wall --user ww-app \
|
||||
--mgmt-domain proxy.internal --mgmt-pass strongpassword
|
||||
```
|
||||
|
||||
@@ -96,7 +96,7 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te
|
||||
|
||||
---
|
||||
|
||||
## What install.sh Does
|
||||
## What scripts/install.sh Does
|
||||
|
||||
The installer performs the following steps automatically:
|
||||
|
||||
@@ -130,7 +130,7 @@ The installer performs the following steps automatically:
|
||||
|
||||
### Idempotent Re-Runs
|
||||
|
||||
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
|
||||
`scripts/install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
|
||||
|
||||
- Skips the Python venv (use `--force-venv` to rebuild)
|
||||
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
|
||||
@@ -138,7 +138,7 @@ The installer performs the following steps automatically:
|
||||
- Preserves existing `config.json` files (skips initial write if file exists)
|
||||
- Safely updates `htpasswd` (uses update mode instead of create mode)
|
||||
|
||||
This makes it safe for development workflows: simply run `bash install.sh` again to update an existing installation.
|
||||
This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-5
@@ -43,22 +43,24 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
|
||||
|
||||
## Quick Start
|
||||
|
||||
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables):
|
||||
To install Vacuum Wall on a Debian 13 system, run `scripts/install.sh` as root with required settings (CLI flags or environment variables):
|
||||
|
||||
```bash
|
||||
# Production
|
||||
./install.sh --mgmt-pass yourpassword
|
||||
./scripts/install.sh --mgmt-pass yourpassword
|
||||
|
||||
# Development (auto-detects your user)
|
||||
./install.sh --dev --mgmt-pass yourpassword
|
||||
./scripts/install.sh --dev --mgmt-pass yourpassword
|
||||
```
|
||||
|
||||
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options.
|
||||
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `scripts/install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./scripts/install.sh --help` for all options.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
│ └── update-vendor.sh # Download vendored libraries (acme.sh, htm)
|
||||
├── pyproject.toml # Project metadata + dependencies
|
||||
├── .venv/ # Python virtual environment
|
||||
├── config/ # Declarative JSON configuration (source of truth)
|
||||
@@ -136,6 +138,7 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── config.md
|
||||
│ └── hoover.md # Hoover SPA framework
|
||||
└── scripts/ # Utility scripts
|
||||
├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
└── update-vendor.sh # Download vendored libraries (acme.sh, htm)
|
||||
```
|
||||
|
||||
|
||||
-506
@@ -1,506 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Vacuum Wall - SSL Proxy Firewall Appliance Installer
|
||||
# Run as root on a fresh Debian 13 (trixie) system
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
|
||||
|
||||
# --- CLI argument parsing ---
|
||||
_cli_user=""
|
||||
_cli_is_dev=false
|
||||
_cli_path=""
|
||||
_cli_mgmt_pass=""
|
||||
_cli_mgmt_user=""
|
||||
_cli_mgmt_domain=""
|
||||
_cli_force_venv=false
|
||||
_cli_wan_iface=""
|
||||
_cli_lan_ifaces=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user|-u) _cli_user="$2"; shift 2 ;;
|
||||
--path|-p) _cli_path="$2"; shift 2 ;;
|
||||
--dev) _cli_is_dev=true; shift ;;
|
||||
--mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;;
|
||||
--mgmt-user) _cli_mgmt_user="$2"; shift 2 ;;
|
||||
--mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;;
|
||||
--force-venv) _cli_force_venv=true; shift ;;
|
||||
--wan-iface) _cli_wan_iface="$2"; shift 2 ;;
|
||||
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
printf '%s\n' \
|
||||
"Usage: install.sh [OPTIONS]" \
|
||||
"" \
|
||||
"Options:" \
|
||||
" --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \
|
||||
" --path, -p DIR Install directory (default: repo root)" \
|
||||
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
|
||||
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
||||
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
||||
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
||||
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
||||
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
||||
" -h, --help Show this help" \
|
||||
"" \
|
||||
"All options also have environment variable equivalents:" \
|
||||
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
|
||||
" MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \
|
||||
" CLI flags take precedence over env vars." \
|
||||
"" \
|
||||
"Example (dev):" \
|
||||
" ./install.sh --dev --mgmt-pass pass" \
|
||||
"" \
|
||||
"Example (prod):" \
|
||||
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $1 (use --help for usage)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Resolve config: CLI flag > env var > default ---
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Required settings (no defaults — must be provided)
|
||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||
# Optional settings with defaults
|
||||
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
|
||||
|
||||
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
|
||||
if [[ -n "$_cli_mgmt_domain" ]]; then
|
||||
DOMAIN="$_cli_mgmt_domain"
|
||||
elif [[ -n "${MGMT_DOMAIN:-}" ]]; then
|
||||
DOMAIN="$MGMT_DOMAIN"
|
||||
else
|
||||
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
|
||||
if [[ -z "$HOSTNAME_F" ]]; then
|
||||
err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
|
||||
fi
|
||||
DOMAIN="${HOSTNAME_F}.local"
|
||||
fi
|
||||
|
||||
# Install directory (CLI > env > repo root)
|
||||
INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}"
|
||||
if [[ -n "$INSTALL_DIR" ]]; then
|
||||
PROJECT_DIR="$INSTALL_DIR"
|
||||
else
|
||||
PROJECT_DIR="$REPO_DIR"
|
||||
fi
|
||||
|
||||
# Network interfaces (CLI > env — auto-detect happens later if still unset)
|
||||
WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}"
|
||||
LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
[[ $EUID -eq 0 ]] || err "This script must be run as root."
|
||||
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
||||
|
||||
# --- Validate required settings ---
|
||||
missing=()
|
||||
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
||||
|
||||
if (( ${#missing[@]} )); then
|
||||
echo -e "${RED}[!!]${NC} Missing required settings:"
|
||||
for v in "${missing[@]}"; do
|
||||
case "$v" in
|
||||
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
|
||||
esac
|
||||
done
|
||||
printf '\nTo run: MGMT_PASS=pass ./install.sh\n'
|
||||
exit 1
|
||||
fi
|
||||
ACME_HOME="$PROJECT_DIR/data/acme"
|
||||
|
||||
# Dev mode: auto-detect repo owner as service user
|
||||
if [[ "$_cli_is_dev" == true ]]; then
|
||||
_repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true
|
||||
if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then
|
||||
_cli_user="$_repo_owner"
|
||||
log "Dev mode: using repo owner '$_repo_owner' as service user"
|
||||
else
|
||||
err "Dev mode: cannot determine repo owner (root or unavailable)."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve USER_NAME: dev mode auto-detects, non-dev requires --user
|
||||
USER_NAME="${_cli_user:-${USER_NAME:-}}"
|
||||
if [[ -z "$USER_NAME" ]]; then
|
||||
err "WebUI user is required. Use --dev to auto-detect repo owner, or set --user / USER_NAME."
|
||||
fi
|
||||
# Daemon user name (derived from web UI user name)
|
||||
USER_DAEMON_NAME="${USER_NAME}d"
|
||||
|
||||
# --- Safety check: running service as a regular user ---
|
||||
if [[ "$_cli_is_dev" != true ]] && id "$USER_NAME" &>/dev/null; then
|
||||
_uid=$(id -u "$USER_NAME")
|
||||
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
|
||||
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then
|
||||
warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
|
||||
warn "This runs the web service as your login account."
|
||||
warn "Sudo access is held only by the daemon user ($USER_DAEMON_NAME)."
|
||||
fi
|
||||
fi
|
||||
# Create WebUI user if it does not exist
|
||||
if ! id "$USER_NAME" &>/dev/null; then
|
||||
log "Creating system user $USER_NAME..."
|
||||
useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin "$USER_NAME"
|
||||
fi
|
||||
# Shared group: use the WebUI user's primary group
|
||||
USER_GROUP=$(id -gn "$USER_NAME")
|
||||
|
||||
echo "============================================"
|
||||
echo " Vacuum Wall Appliance Installer"
|
||||
echo " Install dir: $PROJECT_DIR"
|
||||
echo " WebUI user: $USER_NAME"
|
||||
echo " Daemon user: $USER_DAEMON_NAME"
|
||||
echo " Shared group: $USER_GROUP"
|
||||
echo " Management domain: $DOMAIN"
|
||||
echo "============================================"
|
||||
|
||||
# --- 1. Install packages ---
|
||||
log "Installing system packages..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
firewalld \
|
||||
nginx \
|
||||
dnsmasq \
|
||||
wireguard-tools \
|
||||
python3 \
|
||||
python3-pip \
|
||||
jq \
|
||||
curl \
|
||||
iptables \
|
||||
nftables \
|
||||
apache2-utils \
|
||||
avahi-daemon
|
||||
|
||||
# --- 1b. Vendored libraries ---
|
||||
log "Downloading vendored libraries..."
|
||||
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
|
||||
|
||||
# --- 2. Setup users ---
|
||||
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
|
||||
|
||||
# --- 2a. Create daemon user (has sudo for privileged operations) ---
|
||||
if ! id "$USER_DAEMON_NAME" &>/dev/null; then
|
||||
log "Creating system user $USER_DAEMON_NAME..."
|
||||
useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin \
|
||||
--gid "$USER_GROUP" "$USER_DAEMON_NAME"
|
||||
else
|
||||
log "User $USER_DAEMON_NAME already exists."
|
||||
usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- 2b. Setup Python venv ---
|
||||
if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
|
||||
log "Python venv already exists, skipping (use --force-venv to recreate)."
|
||||
else
|
||||
log "Setting up Python virtual environment..."
|
||||
rm -rf "${PROJECT_DIR}/.venv"
|
||||
python3 -m venv "${PROJECT_DIR}/.venv"
|
||||
"${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}"
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv"
|
||||
chmod -R g+x "${PROJECT_DIR}/.venv"
|
||||
fi
|
||||
|
||||
# --- 2c. Install acme.sh (vendored) ---
|
||||
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
|
||||
log "Installing acme.sh (vendored)..."
|
||||
mkdir -p "$ACME_HOME"
|
||||
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
|
||||
chmod +x "$ACME_HOME/acme.sh"
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
|
||||
else
|
||||
log "acme.sh already installed."
|
||||
fi
|
||||
|
||||
# Install the deploy hook into acme.sh's deploy directory
|
||||
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
|
||||
mkdir -p "$ACME_HOME/deploy"
|
||||
cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh"
|
||||
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh"
|
||||
chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh"
|
||||
|
||||
# --- 3. Setup directories ---
|
||||
log "Creating config and data directories..."
|
||||
mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
|
||||
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
|
||||
mkdir -p /etc/wireguard
|
||||
mkdir -p /etc/dnsmasq
|
||||
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev
|
||||
if [[ "$_cli_is_dev" == true ]]; then
|
||||
_dev_owner="$USER_NAME"
|
||||
else
|
||||
_dev_owner="$USER_DAEMON_NAME"
|
||||
fi
|
||||
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
|
||||
chmod -R g+rwX "$PROJECT_DIR"
|
||||
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
|
||||
|
||||
# --- 4. Template rendering function ---
|
||||
# Renders Jinja2 templates by injecting env vars as template context.
|
||||
# Used for systemd units and sudoers files.
|
||||
render_template() {
|
||||
export USER_NAME USER_DAEMON_NAME USER_GROUP PROJECT_DIR ACME_HOME
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import sys, os
|
||||
from jinja2 import Template
|
||||
text = open(sys.argv[1]).read()
|
||||
env = {
|
||||
'USER_NAME': os.environ['USER_NAME'],
|
||||
'USER_DAEMON_NAME': os.environ.get('USER_DAEMON_NAME', ''),
|
||||
'USER_GROUP': os.environ.get('USER_GROUP', ''),
|
||||
'PROJECT_DIR': os.environ['PROJECT_DIR'],
|
||||
'ACME_HOME': os.environ['ACME_HOME'],
|
||||
}
|
||||
print(Template(text).render(**env), end='')
|
||||
" "$1"
|
||||
}
|
||||
|
||||
# --- 5. Install sudoers ---
|
||||
log "Installing sudoers whitelist..."
|
||||
export USER_DAEMON_NAME
|
||||
render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \
|
||||
| install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-walld
|
||||
|
||||
visudo -cf /etc/sudoers.d/vacuum-walld || err "Invalid sudoers file!"
|
||||
|
||||
# --- 6. Install systemd units ---
|
||||
log "Installing systemd units..."
|
||||
export USER_GROUP
|
||||
render_template "${PROJECT_DIR}/system/systemd/vacuum-walld.service" \
|
||||
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-walld.service
|
||||
|
||||
render_template "${PROJECT_DIR}/system/systemd/vacuum-wall.service" \
|
||||
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall.service
|
||||
|
||||
render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
|
||||
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service
|
||||
|
||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
|
||||
systemctl daemon-reload
|
||||
|
||||
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
|
||||
log "Enabling IP forwarding..."
|
||||
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
|
||||
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
|
||||
fi
|
||||
|
||||
# --- 8. Detect network interfaces ---
|
||||
log "Detecting network interfaces..."
|
||||
|
||||
# Auto-detect WAN (interface with default gateway)
|
||||
WAN_IFACE="${WAN_IFACE:-}"
|
||||
if [[ -z "$WAN_IFACE" ]]; then
|
||||
# Strip @if<port> suffix — physical port index can change on reboot
|
||||
DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' | cut -d'@' -f1)
|
||||
if [[ -n "$DETECTED_WAN" ]]; then
|
||||
WAN_IFACE="$DETECTED_WAN"
|
||||
log "Auto-detected WAN interface: $WAN_IFACE"
|
||||
else
|
||||
warn "Could not auto-detect WAN interface — set WAN_IFACE env var"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Auto-detect LAN (all non-loopback, non-Docker, non-WAN, non-virtual interfaces)
|
||||
LAN_IFACES="${LAN_IFACES:-}"
|
||||
if [[ -z "$LAN_IFACES" ]]; then
|
||||
DETECTED_LANS=$(ls /sys/class/net/ 2>/dev/null \
|
||||
| grep -vE "^(lo|docker|br-|virbr|${WAN_IFACE})$")
|
||||
if [[ -n "$DETECTED_LANS" ]]; then
|
||||
LAN_IFACES=$(echo "$DETECTED_LANS" | paste -sd ',' -)
|
||||
log "Auto-detected LAN interfaces: $LAN_IFACES"
|
||||
else
|
||||
warn "Could not auto-detect LAN interfaces — set LAN_IFACES env var"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 9. Enable and start core services ---
|
||||
log "Enabling services..."
|
||||
systemctl enable firewalld >/dev/null 2>&1 && log "Enabled firewalld" || warn "Could not enable firewalld"
|
||||
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
|
||||
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
|
||||
systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
|
||||
systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
|
||||
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
|
||||
systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
|
||||
|
||||
# Clean up old nginx bootstrap configs (replaced by daemon-generated config)
|
||||
rm -f /etc/nginx/conf.d/vacuum-wall-map.conf /etc/nginx/conf.d/vacuum-wall-mgmt.conf
|
||||
|
||||
# Stop all services to ensure clean start order
|
||||
systemctl stop vacuum-wall >/dev/null 2>&1 || true
|
||||
systemctl stop vacuum-walld >/dev/null 2>&1 || true
|
||||
|
||||
# Start services in dependency order
|
||||
systemctl start firewalld >/dev/null 2>&1 && log "Started firewalld" || warn "Could not start firewalld"
|
||||
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
|
||||
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
|
||||
|
||||
# Start daemon and wait for socket
|
||||
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
|
||||
|
||||
_SOCKET="$PROJECT_DIR/data/daemon.sock"
|
||||
for _i in $(seq 1 30); do
|
||||
[[ -S "$_SOCKET" ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ ! -S "$_SOCKET" ]]; then
|
||||
warn "Daemon socket not found at $_SOCKET — skipping API configuration"
|
||||
else
|
||||
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
|
||||
chmod 0660 "$_SOCKET" 2>/dev/null || true
|
||||
|
||||
# --- 10. Configure subsystems via daemon API ---
|
||||
log "Configuring subsystems via daemon API..."
|
||||
WAN_IFACE="$WAN_IFACE" \
|
||||
LAN_IFACES="$LAN_IFACES" \
|
||||
MGMT_DOMAIN="$DOMAIN" \
|
||||
MGMT_USER="$MGMT_USER" \
|
||||
MGMT_PASS="$MGMT_PASS" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import daemon.client as c
|
||||
from daemon.iface import (
|
||||
POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
|
||||
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
)
|
||||
import sys
|
||||
|
||||
domain = '${DOMAIN}'
|
||||
mgmt_user = '${MGMT_USER}'
|
||||
mgmt_pass = '${MGMT_PASS}'
|
||||
wan_iface = '${WAN_IFACE}'
|
||||
lan_ifaces = '${LAN_IFACES}'
|
||||
|
||||
# Self-signed cert for management domain
|
||||
try:
|
||||
res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365})
|
||||
print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}')
|
||||
except Exception as e:
|
||||
print(f' [cert] Warning: {e}', file=sys.stderr)
|
||||
|
||||
# Management proxy domain + htpasswd
|
||||
try:
|
||||
c.post(POST_NGINX_DOMAINS_ADD, {
|
||||
'domain': domain,
|
||||
'paths': {
|
||||
'/': {
|
||||
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
|
||||
'is_management': True,
|
||||
},
|
||||
'/ws': {
|
||||
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
|
||||
'is_websocket': True,
|
||||
},
|
||||
},
|
||||
'auth_user': mgmt_user,
|
||||
'auth_pass': mgmt_pass,
|
||||
})
|
||||
c.post(POST_NGINX_APPLY)
|
||||
print(f' [proxy] Management proxy configured for {domain}')
|
||||
except Exception as e:
|
||||
print(f' [proxy] Warning: {e}', file=sys.stderr)
|
||||
|
||||
# Firewall config (interface detection done in bash above)
|
||||
import json as _json
|
||||
zones = {}
|
||||
|
||||
if wan_iface:
|
||||
zones['public'] = {
|
||||
'target': 'DEFAULT',
|
||||
'interfaces': [i for i in wan_iface.split(',') if i],
|
||||
'services': ['http', 'https', 'ssh'],
|
||||
'masquerade': True,
|
||||
}
|
||||
|
||||
if lan_ifaces:
|
||||
zones['internal'] = {
|
||||
'target': 'ACCEPT',
|
||||
'interfaces': [i for i in lan_ifaces.split(',') if i],
|
||||
'services': ['dhcp', 'dns', 'ntp'],
|
||||
'masquerade': False,
|
||||
}
|
||||
|
||||
# Always create vpn zone skeleton for later WireGuard setup
|
||||
zones['vpn'] = {
|
||||
'target': 'ACCEPT',
|
||||
'interfaces': [],
|
||||
'services': [],
|
||||
'masquerade': False,
|
||||
}
|
||||
|
||||
try:
|
||||
c.post(POST_FIREWALL_CONFIG, {'zones': zones})
|
||||
c.post(POST_FIREWALL_CONFIG_APPLY)
|
||||
print(' [firewall] Zones configured and applied')
|
||||
except Exception as e:
|
||||
print(f' [firewall] Warning: {e}', file=sys.stderr)
|
||||
|
||||
# IP forwarding
|
||||
try:
|
||||
c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'})
|
||||
print(' [network] IP forwarding enabled')
|
||||
except Exception as e:
|
||||
print(f' [network] Warning: {e}', file=sys.stderr)
|
||||
|
||||
# Infer DHCP ranges (logged for user reference)
|
||||
try:
|
||||
ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES)
|
||||
for iface, rng in ranges.get('ranges', {}).items():
|
||||
print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}')
|
||||
except Exception:
|
||||
pass
|
||||
"
|
||||
log "Subsystem configuration complete"
|
||||
fi
|
||||
|
||||
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
|
||||
|
||||
nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
|
||||
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
|
||||
warn "Could not restart nginx (check config)"
|
||||
|
||||
# --- Done ---
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo " Management UI: https://$DOMAIN"
|
||||
echo " User: $MGMT_USER"
|
||||
echo " Daemon service: vacuum-walld.service"
|
||||
echo " WebUI service: vacuum-wall.service"
|
||||
echo " ACME renewal: vacuum-wall-acme.timer"
|
||||
echo ""
|
||||
echo " Firewall zones:"
|
||||
if [[ -n "$WAN_IFACE" ]]; then
|
||||
echo " public (WAN) → $WAN_IFACE"
|
||||
else
|
||||
echo " public (WAN) → not assigned"
|
||||
fi
|
||||
if [[ -n "$LAN_IFACES" ]]; then
|
||||
echo " internal (LAN) → $LAN_IFACES"
|
||||
else
|
||||
echo " internal (LAN) → not assigned"
|
||||
fi
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Register your ACME account at https://$DOMAIN/certs"
|
||||
echo " 2. Verify zone assignments at https://$DOMAIN/interfaces"
|
||||
echo " 3. Configure DHCP ranges for your LAN"
|
||||
echo " 4. Add proxy domains with ACME certificates"
|
||||
echo " 5. Set up WireGuard tunnel (optional)"
|
||||
echo ""
|
||||
echo " NOTE: A self-signed certificate was generated."
|
||||
echo " From the WebUI, issue a real certificate for $DOMAIN"
|
||||
echo " when DNS points to this appliance."
|
||||
echo ""
|
||||
+55
-4
@@ -248,7 +248,7 @@ def list_certs() -> list[dict]:
|
||||
A list of dicts, one per certificate, with keys matching
|
||||
the cert-info schema (domain, ca, cert_path, etc.).
|
||||
"""
|
||||
raw = _run_acme(["--list"])
|
||||
raw = _run_acme(["--list", "--listraw"])
|
||||
certs: list[dict] = []
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
@@ -472,7 +472,7 @@ def deploy(domain: str) -> None:
|
||||
|
||||
def _split_line(line: str, separator: str | None) -> list[str]:
|
||||
"""Split a line by *separator*, falling back to whitespace for column output."""
|
||||
if separator in line:
|
||||
if separator is not None and separator in line:
|
||||
return line.split(separator)
|
||||
return line.split()
|
||||
|
||||
@@ -503,8 +503,8 @@ def _parse_list_output(raw: str) -> list[dict]:
|
||||
headers = _split_line(header_line, "\t")
|
||||
separator = "\t"
|
||||
else:
|
||||
headers = _split_line(header_line, None) # whitespace
|
||||
separator = None
|
||||
# Column-aligned: use position-based parsing via helper
|
||||
return _parse_column_aligned(header_line, lines[1:])
|
||||
|
||||
if "Main_Domain" not in headers:
|
||||
raise ValueError(
|
||||
@@ -526,6 +526,57 @@ def _parse_list_output(raw: str) -> list[dict]:
|
||||
return entries
|
||||
|
||||
|
||||
def _find_header_positions(header_line: str):
|
||||
"""Find start positions of each header word in a column-aligned header."""
|
||||
names: list[str] = []
|
||||
starts: list[int] = []
|
||||
i = 0
|
||||
while i < len(header_line):
|
||||
while i < len(header_line) and header_line[i] == " ":
|
||||
i += 1
|
||||
j = i
|
||||
while j < len(header_line) and header_line[j] != " ":
|
||||
j += 1
|
||||
if j > i:
|
||||
names.append(header_line[i:j])
|
||||
starts.append(i)
|
||||
i = j
|
||||
return names, starts
|
||||
|
||||
|
||||
def _parse_column_aligned(header_line: str, data_lines: list[str]) -> list[dict]:
|
||||
"""Parse column-aligned output using header positions to locate fields.
|
||||
|
||||
Unlike simple whitespace splitting, this preserves empty fields by using
|
||||
character positions rather than token counts. Empty columns (e.g. missing
|
||||
Profile or SAN_Domains) are correctly handled.
|
||||
"""
|
||||
names, starts = _find_header_positions(header_line)
|
||||
|
||||
if "Main_Domain" not in names:
|
||||
raise ValueError(
|
||||
f"acme.sh --list output is not in expected format: {header_line!r}"
|
||||
)
|
||||
|
||||
# Column ends: midpoint before next header starts (or end of line for last)
|
||||
ends: list[int] = len(starts) * [len(header_line)]
|
||||
for i in range(len(starts) - 1):
|
||||
ends[i] = (starts[i] + starts[i + 1]) // 2
|
||||
|
||||
entries: list[dict] = []
|
||||
for line in data_lines:
|
||||
line = line.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
for k, name in enumerate(names):
|
||||
s, e = starts[k], ends[k]
|
||||
cell = line[s:e] if len(line) > s else ""
|
||||
entry[name.lower()] = cell.strip().strip('"')
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _days_until(date_str: str) -> int | None:
|
||||
"""Parse an ISO date string and return days until that date from now."""
|
||||
if not date_str:
|
||||
|
||||
+2
-40
@@ -818,48 +818,10 @@ def _collect_acme() -> dict[str, Any]:
|
||||
"""
|
||||
email = _get_acme_email()
|
||||
|
||||
certs: list[dict[str, Any]] = []
|
||||
try:
|
||||
from lib.acme import (
|
||||
_days_until,
|
||||
_has_auto_renew,
|
||||
_parse_list_output,
|
||||
_run_acme,
|
||||
)
|
||||
from lib.acme import list_certs
|
||||
|
||||
raw = _run_acme(["--list"])
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
acme_home = Path(acme_home_env)
|
||||
for entry in entries:
|
||||
main = entry.get("main_domain", "")
|
||||
if not main:
|
||||
continue
|
||||
san_domains = [
|
||||
d.strip()
|
||||
for d in entry.get("san_domains", "").split(",")
|
||||
if d.strip() and d.strip().lower() != "no"
|
||||
]
|
||||
cert_dir = acme_home / main
|
||||
days = _days_until(entry.get("renew", ""))
|
||||
certs.append(
|
||||
{
|
||||
"domain": main,
|
||||
"issuer": entry.get("ca", ""),
|
||||
"expiry": entry.get("renew", ""),
|
||||
"days_remaining": days,
|
||||
"expired": days is not None and days <= 0,
|
||||
"cert_path": str(cert_dir / "fullchain.cer"),
|
||||
"key_path": str(cert_dir / f"{main}.key"),
|
||||
"ca_path": str(cert_dir / "ca.cer"),
|
||||
"issued_at": entry.get("created", ""),
|
||||
"expires_at": entry.get("renew", ""),
|
||||
"days_until_expiry": days,
|
||||
"auto_renew": _has_auto_renew(main),
|
||||
"san_domains": san_domains,
|
||||
}
|
||||
)
|
||||
certs = list_certs()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ACME state collection failed, returning empty cert list",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "systemctl restart nginx"
|
||||
systemctl restart nginx
|
||||
sleep 1
|
||||
echo "systemctl restart vacuum-walld"
|
||||
systemctl restart vacuum-walld
|
||||
sleep 1
|
||||
echo "systemctl restart vacuum-wall"
|
||||
systemctl restart vacuum-wall
|
||||
|
||||
# Verify services are running
|
||||
failed=0
|
||||
for svc in nginx vacuum-walld vacuum-wall; do
|
||||
if ! systemctl is-active --quiet "$svc"; then
|
||||
echo "ERROR: $svc is not running" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failed" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -117,6 +117,20 @@ class TestParseListOutput:
|
||||
result = acme._parse_list_output(raw)
|
||||
assert result == []
|
||||
|
||||
def test_pipe_separated_raw_format(self):
|
||||
"""Pipe-separated output with empty fields (what --listraw produces)."""
|
||||
raw = (
|
||||
"Main_Domain|KeyLength|SAN_Domains|Profile|CA|Created|Renew\n"
|
||||
'example.com|"ec-256"|no||ZeroSSL.com|2026-01-01|2026-07-01\n'
|
||||
)
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["main_domain"] == "example.com"
|
||||
assert result[0]["profile"] == ""
|
||||
assert result[0]["ca"] == "ZeroSSL.com"
|
||||
assert result[0]["created"] == "2026-01-01"
|
||||
assert result[0]["renew"] == "2026-07-01"
|
||||
|
||||
|
||||
class TestDaysUntil:
|
||||
def test_future_date(self):
|
||||
|
||||
Vendored
-8326
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
acme-3.1.3.sh
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
// htm mini (no caching) — https://github.com/developit/htm
|
||||
// Vendored from: htm@3.1.1/mini/index.module.js
|
||||
// License: Apache-2.0
|
||||
export default function(n){for(var l,e,s=arguments,t=1,r="",u="",a=[0],c=function(n){1===t&&(n||(r=r.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?a.push(n?s[n]:r):3===t&&(n||r)?(a[1]=n?s[n]:r,t=2):2===t&&"..."===r&&n?a[2]=Object.assign(a[2]||{},s[n]):2===t&&r&&!n?(a[2]=a[2]||{})[r]=!0:t>=5&&(5===t?((a[2]=a[2]||{})[e]=n?r?r+s[n]:s[n]:r,t=6):(n||r)&&(a[2][e]+=n?r+s[n]:r)),r=""},h=0;h<n.length;h++){h&&(1===t&&c(),c(h));for(var i=0;i<n[h].length;i++)l=n[h][i],1===t?"<"===l?(c(),a=[a,"",null],t=3):r+=l:4===t?"--"===r&&">"===l?(t=1,r=""):r=l+r[0]:u?l===u?u="":r+=l:'"'===l||"'"===l?u=l:">"===l?(c(),t=1):t&&("="===l?(t=5,e=r,r=""):"/"===l&&(t<5||">"===n[h][i+1])?(c(),3===t&&(a=a[0]),t=a,(a=a[0]).push(this.apply(null,t.slice(1))),t=0):" "===l||"\t"===l||"\n"===l||"\r"===l?(c(),t=2):r+=l),3===t&&"!--"===r&&(t=4,a=a[0])}return c(),a.length>2?a.slice(1):a[1]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
htm-3.1.1.js
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
../../../vendor/htm.js
|
||||
../../../vendor/htm-3.1.1.js
|
||||
Reference in New Issue
Block a user