diff --git a/.env.example b/.env.example index ac1baa1..5ffcd2c 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,7 @@ LOXY_HAPROXY_RUN_DIR=/run/haproxy # haproxy binary used for `haproxy -c` validation. LOXY_HAPROXY_BIN=haproxy -# IPv6 listeners for "*" binds: auto (probe the host), on or off. +# IPv6 listeners for service ports and domains (:80/:443): auto (probe the host), on or off. LOXY_IPV6=auto # Stats poll interval and log level (debug|info|warn|error). diff --git a/.gitignore b/.gitignore index 3a556f9..314d8a0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ internal/webui/dist/* !internal/webui/dist/.gitkeep bin/ *.test +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 2be933a..af6640d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Loxy — notes for coding agents -Loxy is a lightweight, self-hosted management panel for HAProxy 3.4 LTS (frontends, backends, servers, certificates, DNS). +Loxy is a lightweight, self-hosted load-balancer panel for HAProxy 3.4 LTS (projects with targets, services and domains). **The full build spec is `INSTRUCTIONS.md`. Read all of it before writing code, and treat it as the source of truth.** diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index a44d173..09a1ef2 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -1,4 +1,4 @@ -# Loxy — a self‑hosted management panel for HAProxy +# Loxy — a self‑hosted load‑balancer panel for HAProxy **Build instructions for the implementing agent** @@ -7,8 +7,8 @@ | Name | **Loxy** — repo `github.com/2shiraz/loxy`, Go module `github.com/2shiraz/loxy`, binary `loxy`, image `ghcr.io/2shiraz/loxy`, env prefix `LOXY_`, DB file `loxy.db` | | Data plane | **HAProxy 3.4 LTS** (official Docker image `haproxy:3.4.x-alpine`, patch version pinned) | | Control plane | Single Go binary with embedded web UI, SQLite, no other services | -| Purpose | Manage the **frontends, backends, servers, certificates and DNS** of HAProxy safely: every change is validated (`haproxy -c`), versioned as a revision, applied without dropping traffic (runtime API or hitless reload) and observable (health, graphs, logs, activity). A replacement for hand‑editing `haproxy.cfg` and for heavier HAProxy GUIs. | -| Spec date | 2026‑10‑14 (product direction: ADR 0005) | +| Purpose | Run simple **load balancers** on your own servers. A **project** is one load balancer: it sends traffic to **targets**, listens on **services** (ports) and can be reached on **domains** with automatic HTTPS. Every change is validated (`haproxy -c`), versioned as a revision, applied without dropping traffic (runtime API or hitless reload) and observable (health, graphs, logs, activity). | +| Spec date | 2026‑10‑14 (product model: ADR 0006, which supersedes ADR 0005) | --- @@ -20,70 +20,71 @@ 4. Items tagged **VERIFY** are HAProxy behaviours that must be proven by an automated integration test against the pinned HAProxy image *before* code relies on them. If the test disproves the statement, the observed behaviour wins: change the code, keep the test, and note it in `docs/haproxy-notes.md`. 5. Do not add runtime dependencies beyond §4 without an ADR. "Lightweight and fast" is a hard requirement, not a preference. 6. Never let a rendered HAProxy config that has not passed `haproxy -c` become the live config (§7.7). +7. The product model is **projects → targets, services, domains** (ADR 0006). HAProxy frontends and backends are an *internal* rendering detail (§2, §7) and never appear as user objects in the UI or the API. --- ## 1. Product summary ### 1.1 What we are building -A self‑hosted control panel + REST API for one or more Linux servers running HAProxy. The operator works with HAProxy's own concepts, presented safely: - -- **Frontends** — where traffic enters: a mode (`http | tcp`), one or more **binds** (`address:port`, address `*` = all interfaces), optional **TLS termination** with certificates, an optional HTTP→HTTPS redirect listener, a **default backend** and (http) **host/path routing rules** to other backends, timeouts, `maxconn`, forwarded headers, enable/maintenance switches and labels. -- **Backends** — where traffic goes: a mode, a load‑balancing **algorithm**, a default server port, **health checks**, sticky sessions, PROXY protocol, timeouts and labels. A backend can be used by many frontends. -- **Servers** of a backend — a raw **IP**, a **host** from the inventory, or a **label selector** that matches many hosts; weight, backup flag and admin state (ready / drain / maint), added and removed at runtime. -- **Hosts** (inventory of machines), **Certificates** (uploaded or managed via ACME), **Domains & DNS** (records at external DNS providers). -- The panel shows **health** per server and backend, **live and historical graphs** per frontend and backend (open connections, connections/s, requests/s, bandwidth, sessions, queue, response time, HTTP status classes, errors), live logs and an activity/audit trail. +A self‑hosted control panel + REST API for one or more Linux servers running HAProxy. The owner's guiding requirement: **it must be simple.** The user thinks in load balancers, not in `haproxy.cfg` sections. + +- **Projects** — one project is one load balancer. A user can have many. Each project has an **Overview**, **Graphs**, **Targets**, **Services**, **Domains**, **Logs** and **Settings**, plus a load‑balancing **algorithm** (`roundrobin | leastconn | source | random`), a **maintenance** switch (http → 503 page, tcp → connection rejected) and **labels**. +- **Targets** — where traffic goes: an **IP** (optionally `ip:port`), a **host** from the inventory, or a **label selector** over host labels. Each target has a weight, a backup flag and an admin state (ready / drain / maintenance), is added and removed at runtime, and is health‑checked by every service of the project. +- **Services** — the ports a project listens on: protocol `http | https | tcp`, **listen port → destination port**, a health check (`tcp | http | https`), sticky sessions (http/https), PROXY protocol v1/v2, a timeout and, for https, certificates. A new project automatically gets an **http service on its own free port** (from 20000 up); the listen port can also be chosen explicitly. Listen ports are unique across all projects. Ports **80 and 443 are reserved for domains**. +- **Domains** — attach a hostname to a project (optionally to one of its services; default: the project's first http/https service). Traffic for that hostname on the shared ports 80/443 goes to the project. **HTTPS is automatic:** once the name resolves to the server, Loxy obtains a certificate (Let's Encrypt HTTP‑01 through :80, or DNS‑01 through a DNS provider) and redirects :80 → :443. Loxy shows the required DNS records (the node's addresses) and can manage A/AAAA records automatically through a DNS provider (libdns). +- **Hosts** (inventory of machines), **Certificates** (uploaded or managed via ACME), **DNS providers**. +- The panel shows **health** per target, service and project, **live and historical graphs** per project and per service (open connections, connections/s, requests/s, bandwidth, sessions, queue, response time, HTTP status classes, errors), live logs per project and an activity/audit trail. - Every change is applied to HAProxy automatically, validated, versioned, and **without dropping traffic** (runtime API where possible, hitless reload otherwise). ### 1.2 Goals -1. **Cover the everyday HAProxy job without touching `haproxy.cfg`:** frontends, backends, servers, TLS, routing, health checks, certificates, DNS (§8), plus the extras in §20. +1. **Simple:** "create project → add targets → reach it on its port or attach a domain" covers the everyday job. No HAProxy knowledge needed; no `haproxy.cfg` editing. 2. **Minimal install:** `docker compose up -d` with two containers (official HAProxy + `loxy`). No external database, queue, Prometheus or Redis required. 3. **Fast and light:** budgets in §1.4 are acceptance criteria. 4. **Safe:** invalid config can never reach the live path; every applied state is a numbered revision that can be diffed and rolled back. 5. **API‑first:** the UI uses only the public REST API, so everything is scriptable. ### 1.3 Non‑goals (v1) -Being a cloud load‑balancer *product* (no "create load balancer" objects, sizes/plans, rescaling, prices, billing or traffic quotas); managing NGINX/Apache; Kubernetes ingress; a WAF rule engine; anycast/multi‑region traffic steering; UDP load balancing; provider‑specific cloud integrations (inventory sync, floating‑IP APIs); **running an authoritative DNS server** (Loxy manages records at external DNS providers through their APIs, §8.9; it never answers DNS queries itself); exposing every HAProxy keyword (Loxy is opinionated; raw config import is a backlog item, §20). +Sizes/plans, rescaling, prices, billing or traffic quotas; managing NGINX/Apache; Kubernetes ingress; a WAF rule engine; anycast/multi‑region traffic steering; UDP load balancing; provider‑specific cloud integrations (inventory sync, floating‑IP APIs); **running an authoritative DNS server** (Loxy manages records at external DNS providers through their APIs, §8.12; it never answers DNS queries itself); exposing HAProxy frontends/backends or arbitrary HAProxy keywords (Loxy is opinionated; raw config import is a backlog item, §20); per‑address binds (services listen on all addresses of the node). ### 1.4 Performance budgets (acceptance criteria) | Item | Budget | |---|---| -| Controller resident memory, idle, 50 frontends / 200 backends / 1 000 servers | ≤ 60 MB | +| Controller resident memory, idle, 50 projects / 200 services / 1 000 targets | ≤ 60 MB | | Controller CPU, idle (1 s stats polling) | < 1 % of one core | | API read p95 (served from in‑memory snapshot) | < 10 ms | -| Write that is applied via Runtime API (e.g. add server), request → active in HAProxy | < 200 ms | -| Write that needs a reload (validate + swap + reload), 50 frontends / 200 backends | < 1.5 s | -| UI initial load, all JS + CSS gzip | ≤ 150 KB (charts lazy‑loaded on the Graphs tabs) | +| Write that is applied via Runtime API (e.g. add target), request → active in HAProxy | < 200 ms | +| Write that needs a reload (validate + swap + reload), 50 projects / 200 services | < 1.5 s | +| UI initial load, all JS + CSS gzip | ≤ 150 KB (charts lazy‑loaded on the Graphs tab) | | Live graph latency (counter change → pixel) | ≤ 2 s | | Failed client requests during 100 consecutive reloads under a 2 000 rps load test | **0** | -| Failed client requests while adding/removing 50 servers under load | **0** | +| Failed client requests while adding/removing 50 targets under load | **0** | --- ## 2. Concept mapping (Loxy → HAProxy) +Users see projects, targets, services and domains. The renderer compiles them into the frontend/backend sections that were verified in Phases 0–1 (ADR 0004, ADR 0006). + | Loxy object | Table(s) | HAProxy construct | |---|---|---| -| Frontend (name, mode, binds, TLS, options) | `frontends`, `frontend_binds`, `frontend_certificates` | `frontend fe_ from loxy_http|loxy_tcp` with one `bind` per address:port (IPv6 as `ipv6@addr:port`, `*` → `:port` / `ipv6@:::port`) | -| HTTP→HTTPS redirect (tls only) | `frontends.http_redirect` | Extra `frontend fe__rd` on the same addresses:80 with the ACME HTTP‑01 rule and `http-request redirect scheme https code 301` | -| TLS termination + certificates | `frontends.tls`, `frontend_certificates` | `bind … ssl crt-list fe.lst alpn h2,http/1.1` (`crt-base` + relative entries, ADR 0004); certs hot‑swapped through the Runtime API | -| Default backend | `frontends.default_backend_id` | `default_backend be_` | -| Routing rule (hosts, path prefix → backend; http only) | `routes` (ordered by `position`) | `use_backend be_ if { req.hdr(host),field(1,:),lower -m str|-m end … } { path_beg … }` in route order | -| Maintenance switch | `frontends.maintenance` | `opt@maint.map` keyed by frontend id: http → `http-request return status 503`, tcp → `tcp-request connection reject` | -| Enabled switch | `frontends.enabled` | Frontend not rendered when disabled | -| Backend (mode, algorithm, port, options) | `backends` | `backend be_ from loxy_http|loxy_tcp`, `balance` **always explicit** (§7.9) | -| Health check (protocol, port, interval, timeout, retries, domain, path, response, status codes, TLS verify) | columns on `backends` | `option httpchk`, `http-check send/expect`, `check port inter rise fall`, `timeout check`, `check-ssl` | -| Sticky sessions (cookie name, lifetime) | backend columns | `cookie insert indirect nocache httponly [secure] maxlife s` + `cookie t[x]` per server | -| PROXY protocol | backend column | `send-proxy`/`send-proxy-v2` **and** `check-send-proxy` on server lines | -| Server: IP / host / label selector | `backend_servers`, resolved into `server_slots` | `server t` (IP) or `server tx` (host / selector match) with `id `; added/removed at runtime as dynamic servers | -| Host (inventory) | `hosts` | Address source for host/selector servers only | -| Labels | JSON column on frontends, backends, hosts, certificates | Selectors, list filters and alert‑rule filters only; never rendered | -| Node addresses | `node_addresses` | Not rendered; bind‑address suggestions and DNS required records for `*` binds | -| Metrics | collector + rollup tables (scope frontend / backend) | Deltas of `show stat` counters | -| System | — | `sys_panel` (+ `sys_panel_be`), `sys_stats`, `sys_acme_fe` (ACME HTTP‑01 :80 listener for a TLS frontend without redirect) | - -The metric names of the API (`open_connections`, `connections_per_second`, `requests_per_second`, `bandwidth` with `in`/`out` series, plus backend `sessions`, `queue`, `response_time_ms`, `http_*xx`, `errors`) are stable contract (§10.5). +| Project (name, algorithm, maintenance, labels) | `balancers` (§6 naming) | No section of its own. `algorithm` → `balance` in every `be_` of the project (**always explicit**, §7.9); `maintenance` → one `opt@maint.map` entry per service of the project | +| Service (protocol, listen → destination port, options) | `services` | `frontend fe_ from loxy_http|loxy_tcp` with `bind 0.0.0.0:` (+ `ipv6@::: v6only` when the host has IPv6, ADR 0004 §9) and `default_backend be_`; `backend be_ from loxy_http|loxy_tcp` | +| https service + certificates | `services.protocol = https`, `service_certificates` | `bind … ssl crt-list svc.lst alpn h2,http/1.1` (`crt-base` + relative entries, ADR 0004); targets get plain HTTP (TLS termination); certs hot‑swapped through the Runtime API | +| Health check (protocol, port, interval, timeout, retries, domain, path, response, status codes, TLS verify) | columns on `services` | In `be_`: `option httpchk`, `http-check send/expect`, `check port inter rise fall`, `timeout check`, `check-ssl` | +| Sticky sessions (cookie name, lifetime; http/https) | service columns | `cookie insert indirect nocache httponly [secure] maxlife s` in `be_` (`secure` on https services) + `cookie ` per server | +| PROXY protocol | service column | `send-proxy`/`send-proxy-v2` **and** `check-send-proxy` on the server lines of `be_` | +| Target: IP / host / label selector | `targets`, resolved into `target_slots` | In **every** `be_` of the project: `server s` (IP) or `server sx` (host / selector match) with `id `, port = target port override or the service's destination port; added/removed at runtime as dynamic servers | +| Domain (hostname → project / service, https) | `domains` | Shared `frontend sys_http` (:80) and `frontend sys_https` (:443): `use_backend be_ if { req.hdr(host),field(1,:),lower -m str }`; ACME HTTP‑01 answers and 301 → https in `sys_http`; domain certificates in `sys_https.lst` | +| Maintenance | `balancers.maintenance` | `opt@maint.map` keyed by service id: http/https → `http-request return status 503` in `be_` (covers the service port and domain traffic); tcp → `tcp-request connection reject` in `fe_` | +| Host (inventory) | `hosts` | Address source for host/selector targets only | +| Labels | JSON column on projects, hosts, certificates | Selectors, list filters and alert‑rule filters only; never rendered | +| Node addresses | `node_addresses` | Not rendered; endpoint URLs and DNS required records for domains | +| Metrics | collector + rollup tables (scopes `fe`, `be`, `pr`) | Deltas of `show stat` counters of `fe_`/`be_`; project (`pr`) = sum of its service backends | +| System | — | `sys_panel` (+ `sys_panel_be`), `sys_stats`, `sys_http`, `sys_https` | + +The metric names of the API (`open_connections`, `connections_per_second`, `requests_per_second`, `bandwidth` with `in`/`out` series, plus `sessions`, `queue`, `response_time_ms`, `http_*xx`, `errors`) are stable contract (§10.5). --- @@ -105,7 +106,7 @@ The metric names of the API (`open_connections`, `connections_per_second`, `requ ▼ ▼ HAProxy 3.4 official image, master‑worker, network_mode: host │ - Clients ⇄ bind IP:port ⇄ HAProxy (frontends → backends) ⇄ Servers + Clients ⇄ : or :80/:443 (domains) ⇄ HAProxy (project services) ⇄ Targets ``` ### 3.2 Process modes (one binary) @@ -130,7 +131,7 @@ type NodeTransport interface { 4. It **always** renders the full config tree for the new version into a new revision directory and validates it with `haproxy -c` (§7.2, §7.7). 5. If the plan contains any `reload` op → atomically swap `live` → master CLI `reload`. Otherwise → swap `live` (no reload) and execute the runtime ops. If a runtime op fails → fall back to a reload of the same revision. 6. Store a `config_revisions` row (result, duration, HAProxy output), set `applied_version`, emit events. -7. On any failure the previous good revision stays (or is put back) as `live`, the affected frontends/backends and System show an `apply_error` banner with the HAProxy output, and a notification fires. +7. On any failure the previous good revision stays (or is put back) as `live`, the affected projects and System show an `apply_error` banner with the HAProxy output, and a notification fires. **Invariant:** the live config tree on disk always fully describes the state HAProxy is running, so a cold restart of the HAProxy container reproduces exactly what runtime operations created. Runtime API calls are an optimisation to avoid reloads, never the only record of a change. @@ -147,7 +148,7 @@ type NodeTransport interface { | HTTP server | stdlib `net/http` (method+path patterns) with small middleware; handler interfaces generated by `oapi-codegen` from `api/openapi.yaml` (OpenAPI 3.0.3) | Contract‑first, typed, no framework weight | | Database | SQLite in WAL mode via `modernc.org/sqlite` (pure Go, no CGO); typed queries with `sqlc`; migrations embedded and run at start | Zero ops, microsecond reads | | ACME | `github.com/go-acme/lego/v5` used as a library | HTTP‑01 and DNS‑01, custom challenge providers, renewal info (ARI) | -| DNS providers | `github.com/libdns/libdns` interfaces + provider modules (the first ones are listed in §21 Q4; ADR 0003) | One small, uniform record API (`GetRecords`/`AppendRecords`/`SetRecords`/`DeleteRecords`, `ListZones`) for many providers; the same credentials drive ACME DNS‑01 through a lego `challenge.Provider` adapter (§8.9) | +| DNS providers | `github.com/libdns/libdns` interfaces + provider modules (ADR 0003) | One small, uniform record API (`GetRecords`/`AppendRecords`/`SetRecords`/`DeleteRecords`, `ListZones`) for many providers; the same credentials drive ACME DNS‑01 through a lego `challenge.Provider` adapter (§8.12) | | Crypto | `golang.org/x/crypto/argon2` (passwords), AES‑256‑GCM (secrets at rest), TOTP lib for optional 2FA | | | Logging | `log/slog` JSON to stdout | | | Frontend | Svelte 5 + SvelteKit with `adapter-static` (SPA mode, `ssr = false`), TypeScript, Vite | Smallest runtime of the mainstream frameworks | @@ -184,10 +185,10 @@ loxy/ │ ├── config/ # env + flag parsing, defaults, validation │ ├── model/ # domain types, enums, validation rules (§14.4) │ ├── store/ -│ │ ├── migrations/ # 0001_init.sql, ... +│ │ ├── migrations/ # 0001_init.sql, 0002_projects.sql, ... │ │ ├── queries/ # sqlc input │ │ └── db/ # sqlc output (generated, committed) -│ ├── state/ # immutable NodeState snapshots, label-selector resolution, server slots +│ ├── state/ # immutable NodeState snapshots, label-selector resolution, target slots, port allocation │ ├── reconcile/ # diff, op classification, apply plan, retries │ ├── haproxy/ │ │ ├── render/ # text/template renderer + templates/*.tmpl + golden tests @@ -198,13 +199,13 @@ loxy/ │ ├── metrics/ # collector, delta + reset logic, ring buffers, rollups, queries │ ├── health/ # per-check status, aggregation, transitions → events │ ├── certs/ # PEM parsing/validation, storage, lego integration, renew scheduler -│ ├── dns/ # libdns provider registry, lego DNS-01 adapter, domain verifier, record sync (§8.9) +│ ├── dns/ # libdns provider registry, lego DNS-01 adapter, domain verifier, record sync (§8.12) │ ├── api/ # handler implementations, middleware (auth, CSRF, rate-limit, request-id) │ ├── auth/ # sessions, argon2id, API tokens, RBAC, TOTP │ ├── events/ # in-process pub/sub + SSE hub │ ├── audit/ │ ├── notify/ # webhook, email (SMTP), Telegram, Slack -│ ├── logrecv/ # UDP syslog receiver + per-frontend ring buffers (live logs) +│ ├── logrecv/ # UDP syslog receiver + per-project ring buffers (live logs) │ ├── agent/ # node-side applier + NodeTransport (in-process now, network in Phase 3) │ ├── cluster/ # panel-side node registry, enrollment, transport server (Phase 3) │ └── webui/ # go:embed of web/build (precompressed .br/.gz) @@ -213,11 +214,11 @@ loxy/ │ ├── src/lib/components/ # StatBar, StatCard, Donut, Chart, Tabs, Table, Modal, Dropdown, Toast, LabelChips, PortField, ... │ ├── src/lib/stores/ # session, sse, snapshot cache │ ├── src/lib/styles/tokens.css -│ └── src/routes/ # /login, /dashboard, /frontends/[id]/{overview,routing,graphs,logs,config}, /backends/[id]/{servers,settings,graphs}, /hosts, /certificates, /domains/{,providers,zones}, /activity, /system, /settings/* +│ └── src/routes/ # /login, /dashboard, /projects, /projects/new, /projects/[id]/{overview,graphs,targets,services,domains,logs,settings}, /hosts, /certificates, /domains/{,providers,zones}, /activity, /system, /settings/* ├── deploy/ │ ├── docker/Dockerfile │ ├── compose/compose.yml # production all-in-one -│ ├── compose/compose.dev.yml # dev: haproxy + whoami servers + pebble ACME + loxy +│ ├── compose/compose.dev.yml # dev: haproxy + whoami targets + pebble ACME + loxy │ ├── compose/compose.agent.yml # Phase 3: node agent only │ ├── haproxy/bootstrap.cfg # minimal config written on first start │ ├── keepalived/ # Phase 3 templates + notify scripts @@ -228,7 +229,7 @@ loxy/ │ ├── e2e/ # Playwright │ └── load/ # oha / k6 scripts + reload-under-load harness └── docs/ - ├── adr/ # architecture decision records + ├── adr/ # architecture decision records (0006: projects model) ├── haproxy-notes.md # verified behaviours, version quirks └── operations.md # backup, restore, upgrade, break-glass ``` @@ -237,46 +238,50 @@ loxy/ ## 6. Data model (SQLite) -Conventions: `INTEGER PRIMARY KEY` ids (shown in UI as `#42`), timestamps as unix milliseconds, JSON stored as `TEXT CHECK(json_valid(col))`, `ON DELETE` rules explicit, `PRAGMA foreign_keys=ON`, `journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=5000`. All resources belong to a `project` (one default project in v1; keeps the door open for multi‑tenant use). +Conventions: `INTEGER PRIMARY KEY` ids (shown in UI as `#42`), timestamps as unix milliseconds, JSON stored as `TEXT CHECK(json_valid(col))`, `ON DELETE` rules explicit, `PRAGMA foreign_keys=ON`, `journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=5000`. + +**Naming:** the user‑facing *project* (one load balancer) is stored in the table **`balancers`**, because `projects` is the pre‑existing **tenancy** table (one row, id 1, kept for possible multi‑tenant use). Foreign keys to a load balancer are therefore called `balancer_id`; the API and UI call it `project` / `project_id`. Tenancy‑scoped tables (`hosts`, `certificates`, `dns_providers`, `api_tokens`, `balancers`, `domains`, `events`) keep their `project_id`, which is always 1 in v1 — that is why hosts, certificates and DNS providers are usable by every load‑balancer project. | Table | Key columns | |---|---| -| `projects` | id, name, created_at | | `users` | id, email (unique), password_hash (argon2id), role (`owner|admin|operator|viewer`), totp_secret_enc, disabled, created_at, last_login_at | -| `project_members` | project_id, user_id, role | -| `sessions` | id_hash, user_id, created_at, expires_at, ip, user_agent | -| `api_tokens` | id, project_id, name, prefix, secret_hash, role, expires_at, last_used_at | +| `sessions` | id_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent | +| `projects` (tenancy) | id, name, created_at — one row (id 1) in v1 | +| `api_tokens` | id, project_id (tenancy), name, prefix, secret_hash, role, expires_at, last_used_at, created_at | | `nodes` | id, name, mode (`local|remote`), status, haproxy_version, loxy_version, features JSON (`QUIC`, `PROMEX`, …), last_seen_at, desired_version, applied_version | -| `node_addresses` | id, node_id, address, family (4/6), label — the node's own addresses: bind‑address suggestions and DNS required records for frontends that bind `*` | +| `node_addresses` | id, node_id, address, family (4/6), label — the node's own addresses: endpoint URLs (`http://:`) and DNS required records for domains | | `node_groups` | id, name, kind (`single|ha_pair`), vip_mode (`none|vrrp`), virtual_ips JSON — Phase 3 | -| `frontends` | id, project_id, name (unique per project), description, node_id / node_group_id, mode (`http|tcp`), tls, http_redirect, default_backend_id (nullable, `ON DELETE RESTRICT`), timeout_client_s, maxconn (0 = unlimited), forwarded_headers, enabled, maintenance, labels JSON, status (`active|applying|apply_error`), last_error, options JSON (extras, §20), created_at, updated_at, version | -| `frontend_binds` | frontend_id, address (IP or `*`), port — UNIQUE(frontend_id, address, port); cross‑frontend collisions are checked in the API (§8.3) | -| `frontend_certificates` | frontend_id, certificate_id, position | -| `routes` | id, frontend_id, position, hosts JSON (exact or `*.example.com`), path_prefix, backend_id (`ON DELETE RESTRICT`) | -| `backends` | id, project_id, name (unique per project), description, mode (`http|tcp`), algorithm (`roundrobin|leastconn|source|random`), port, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, proxy_protocol (`none|v1|v2`), timeout_server_s, hc_protocol (`tcp|http|https`), hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes JSON, hc_tls_verify, labels JSON, options JSON, created_at, updated_at, version | -| `backend_servers` | id, backend_id, type (`ip|host|label_selector`), ip, host_id, label_selector, port (nullable override), use_private_ip, weight (1–256, default 100), backup, admin_state (`ready|drain|maint`), removing, created_at | -| `server_slots` | id (stable HAProxy server `id`), backend_id, backend_server_id, host_id NULL, address, port — one row per *resolved* server, so ids survive reloads | -| `hosts` (inventory) | id, project_id, name, ipv4, ipv6, private_ipv4, labels JSON, notes | -| `certificates` | id, project_id, name, type (`uploaded|managed`), domains JSON, key_type (`ecdsa256|rsa2048|rsa4096`), challenge (`http01|dns01`), dns_provider_id (→ `dns_providers`), cert_pem, chain_pem, key_pem_enc, fingerprint_sha256, not_before, not_after, status (`pending|issued|renewing|failed`), last_error, renew_after, labels JSON | +| `balancers` (API: project) | id, project_id (tenancy, default 1), node_id, name (UNIQUE(project_id, name)), description, algorithm (`roundrobin|leastconn|source|random`, default roundrobin), labels JSON, maintenance, status (`applying|ready|error`), last_error, version, created_at, updated_at | +| `services` | id, balancer_id (`ON DELETE CASCADE`), protocol (`http|https|tcp`), listen_port (**UNIQUE** — across all projects), destination_port, proxy_protocol (`none|v1|v2`), timeout_s (default 60), sticky_enabled, sticky_cookie_name (default `LOXYSTICKY`), sticky_cookie_lifetime_s (default 300), hc_protocol (`tcp|http|https`), hc_port (0 = target port), hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes JSON, hc_tls_verify, version, created_at. The project's first http/https service (lowest id) is the domain default | +| `service_certificates` | service_id (`ON DELETE CASCADE`), certificate_id (`ON DELETE RESTRICT`), position — PK(service_id, certificate_id) | +| `targets` | id, balancer_id (`ON DELETE CASCADE`), type (`ip|host|label_selector`), ip, host_id (`ON DELETE CASCADE`; the API refuses deleting a directly referenced host, §8.5), label_selector, port (nullable override), use_private_ip, weight (1–256, default 100), backup, admin_state (`ready|drain|maint`), removing, created_at | +| `target_slots` | id (stable HAProxy server `id`), balancer_id, target_id (`ON DELETE CASCADE`), host_id NULL, address, UNIQUE(target_id, host_id, address) — one row per *resolved* target (an IP target has one slot, a host one, a selector one per matching host), so ids survive reloads. The same slot id is used in every `be_` of the project (server ids are unique per backend) | +| `hosts` (inventory) | id, project_id (tenancy), name, ipv4, ipv6, private_ipv4, labels JSON, notes | +| `certificates` | id, project_id (tenancy), name, type (`uploaded|managed`), domains JSON, key_type (`ecdsa256|rsa2048|rsa4096`), challenge (`http01|dns01`), dns_provider_id (→ `dns_providers`), cert_pem, chain_pem, key_pem_enc, fingerprint_sha256, not_before, not_after, status (`pending|issued|renewing|failed`), last_error, renew_after, labels JSON | | `acme_accounts` | id, directory_url, email, key_pem_enc, registration JSON, eab_kid, eab_hmac_enc | -| `dns_providers` | id, project_id, name, kind (registry key, e.g. `cloudflare`), credentials_enc (AES‑256‑GCM JSON), status (`ok|error|unknown`), last_error, last_checked_at, created_at | -| `domains` | id, project_id, fqdn (unique per project, RFC 1123, lowercase, `*.` allowed only with a DNS provider), frontend_id (nullable, `ON DELETE SET NULL`), dns_provider_id (nullable), zone (detected apex, e.g. `example.com`), auto_records (bool, default false), ttl (default 300), status (`unknown|ok|wrong_ip|missing|error`), observed JSON (last A/AAAA answers), checked_at, certificate_id (nullable), managed_record_ids JSON, created_at, updated_at | +| `dns_providers` | id, project_id (tenancy), name, kind (registry key), credentials_enc (AES‑256‑GCM JSON), status (`ok|error|unknown`), last_error, last_checked_at, created_at | +| `domains` | id, project_id (tenancy), fqdn (UNIQUE(project_id, fqdn) — i.e. unique in the installation; RFC 1123, lowercase, `*.` allowed only with a DNS provider), balancer_id (nullable, `ON DELETE SET NULL` — a detached domain is kept but not rendered), service_id (nullable, `ON DELETE SET NULL`; NULL = the project's first http/https service), https (default 1), dns_provider_id (nullable), zone (detected apex, e.g. `example.com`), auto_records (default false), ttl (default 300), status (`unknown|ok|wrong_ip|missing|error`), status_detail, observed JSON (last A/AAAA answers), checked_at, certificate_id (nullable; managed automatically or an uploaded cert that covers the fqdn), managed_record_ids JSON, created_at, updated_at | | `config_revisions` | id, node_id, revision (monotonic), desired_version, sha256, kind (`runtime|reload|rollback|bootstrap`), result (`ok|failed`), duration_ms, haproxy_output, created_by, created_at, pinned | -| `metrics_1m`, `metrics_5m`, `metrics_1h` | scope (`frontend|backend`), scope_id, ts, open_conn_avg, open_conn_max, cps, rps, bytes_in, bytes_out, sessions_avg, queue_max, resp_time_ms_avg, http_2xx, http_3xx, http_4xx, http_5xx, errors — PK(scope, scope_id, ts) | -| `events` | id, ts, project_id, frontend_id NULL, backend_id NULL, kind, severity, message, data JSON | +| `metrics_1m`, `metrics_5m`, `metrics_1h` | scope (`fe` = `fe_` frontend, id 0 = node total; `be` = `be_` service backend; `pr` = project), scope_id, ts, open_conn_avg, open_conn_max, cps, rps, bytes_in, bytes_out, sessions_avg, queue_max, resp_time_ms_avg, http_2xx, http_3xx, http_4xx, http_5xx, errors — PK(scope, scope_id, ts) | +| `events` | id, ts, project_id (tenancy, default 1), balancer_id NULL, kind, severity, message, data JSON — indexes on `ts` and `(balancer_id, ts)` | | `audit_log` | id, ts, actor_user_id, actor_token_id, ip, action, resource_type, resource_id, before JSON, after JSON | | `notification_channels` | id, kind (`webhook|email|telegram|slack`), config_enc, enabled | -| `alert_rules` | id, event_kinds JSON, label_filter (label selector matched against frontend/backend labels), channel_id, min_severity, cooldown_s | +| `alert_rules` | id, event_kinds JSON, label_filter (label selector matched against **project** labels; node‑wide events such as apply and backup are not filtered by it), channel_id, min_severity, cooldown_s | | `settings` | key, value JSON | Indexes on every foreign key, on `events(ts)`, `audit_log(ts)`, metrics PKs. Keep the whole desired state (minus metrics/events/audit) in an in‑memory snapshot rebuilt on change; API reads hit the snapshot, not SQLite. +**Migrations.** +- `0001_init.sql` — the frontend/backend schema of the previous model (ADR 0005). Kept unchanged so existing databases migrate forward. +- `0002_projects.sql` — moves to this model (ADR 0006). The tenancy `projects` table is left unchanged, and hosts, certificates, DNS providers, users, API tokens, ACME accounts, revisions, audit log and settings keep their rows (and their `project_id`). It creates `balancers`, `services`, `service_certificates`, `targets` and `target_slots`; re‑creates `domains` with `balancer_id`, `service_id` and `https` and copies every domain **detached** (`balancer_id`/`service_id` NULL, `https` 1); re‑creates `events` with `balancer_id` and copies the rows without their frontend/backend ids; drops `routes`, `frontend_certificates`, `frontends`, `server_slots`, `backend_servers` and `backends` (frontend/backend definitions are **not** converted — they don't map 1:1 onto projects); and clears `metrics_1m`. Runs after the automatic pre‑migration backup (§16.6). +- Only a database from the **pre‑release cloud‑load‑balancer model** (before ADR 0005, recognised by its `load_balancers` table) is **refused** at start with a clear error telling the operator to start from an empty data directory — never migrated silently. + --- ## 7. HAProxy integration (the core) ### 7.1 Process model, sockets, container -- HAProxy runs in its **own container** from the **official image**, `network_mode: host` (frontends add listeners on arbitrary addresses and ports, which Docker port publishing cannot follow, and host networking avoids the userland proxy/NAT hop). +- HAProxy runs in its **own container** from the **official image**, `network_mode: host` (services add listeners on arbitrary ports, which Docker port publishing cannot follow, and host networking avoids the userland proxy/NAT hop). - Start command (override the image entrypoint so the flags are explicit): `haproxy -W -db -S /run/haproxy/master.sock,mode,660,gid,1500 -f /usr/local/etc/haproxy/live/haproxy.cfg` - `-W` master‑worker; `-db` foreground; `-S` master CLI socket. **VERIFY** the `-S` bind‑option syntax and resulting socket permissions. @@ -303,8 +308,9 @@ Indexes on every foreign key, on `events(ts)`, `audit_log(ts)`, metrics PKs. Kee ├── haproxy.check.cfg # same content rendered with Root=, used only for `haproxy -c` ├── certs/.pem # leaf + chain + key, mode 0640 ├── certs/_placeholder.pem # self-signed, served until a managed cert is issued - ├── crt-lists/fe.lst # entries relative to crt-base (ADR 0004) - ├── maps/maint.map # loaded with opt@, keyed by frontend id, updated at runtime too + ├── crt-lists/svc.lst # https services; entries relative to crt-base (ADR 0004) + ├── crt-lists/sys_https.lst # issued domain certificates, entries relative to crt-base + ├── maps/maint.map # loaded with opt@, keyed by service id, updated at runtime too ├── maps/panel_allow.lst └── errors/503.http, maint.http /var/lib/haproxy/server-state # dump of `show servers state`, written before each reload @@ -315,18 +321,22 @@ Indexes on every foreign key, on `events(ts)`, `audit_log(ts)`, metrics PKs. Kee - **Atomic swap:** create `live.tmp` → `revs/N`, then `rename(live.tmp, live)`, then fsync the directory. - Unchanged files are **hard‑linked** from the previous revision (cheap, fast). - Garbage collection keeps the last 20 revisions plus any `pinned` (rollback) ones. -- Runtime‑only changes (server add, cert renewal) still create a new revision so disk always equals memory. +- Runtime‑only changes (target add, cert renewal) still create a new revision so disk always equals memory. ### 7.3 Naming and stable ids -- Frontend `fe_{F}`; redirect frontend `fe_{F}_rd`; backend `be_{B}`; system sections `sys_panel`, `sys_panel_be`, `sys_stats`, `sys_acme_fe{F}`. +- Per service `S`: frontend `fe_{S}`, backend `be_{S}` (`S` = `services.id`). System sections: `sys_panel`, `sys_panel_be`, `sys_stats`, `sys_http`, `sys_https`. - Frontend and backend names MUST differ (HAProxy 3.3+ rejects a frontend and backend sharing a name) — guaranteed by the `fe_`/`be_` prefixes. -- Server names: `t{T}` for an IP server, `t{T}x{H}` for a host or label‑selector expansion (`T` = `backend_servers.id`, `H` = `hosts.id`). Server `id` = `server_slots.id` (stable), so `load-server-state-from-file` re‑applies weight/admin/health state after reloads. -- Because a server belongs to exactly one backend, a runtime op is one command per resolved server — it never multiplies across frontends. -- Names in HAProxy use ids only; display names, descriptions and labels never appear in the config. +- Server names: `s{T}` for an IP target, `s{T}x{H}` for a host or label‑selector expansion (`T` = `targets.id`, `H` = `hosts.id`). Server `id` = `target_slots.id` (stable), so `load-server-state-from-file` re‑applies weight/admin/health state after reloads. +- A target is rendered in **every** `be_` of its project, so a target runtime op is one command per resolved slot **per service** of the project. +- Names in HAProxy use ids only; project names, descriptions, labels and domains as display text never appear in the config (domain FQDNs appear only as validated ACL patterns, §14.4). ### 7.4 Rendered config (reference output) -Templates live in `internal/haproxy/render/templates/`. Output MUST be deterministic (sorted by id; routes by position) so revisions diff cleanly. Reference rendering for a TLS http frontend with redirect and host/path routing to two backends, plus a tcp frontend/backend pair (deviations recorded in ADR 0004 are already applied): +Templates live in `internal/haproxy/render/templates/`. Output MUST be deterministic (projects, services and targets sorted by id, domains by FQDN with exact before wildcard) so revisions diff cleanly. Reference rendering (deviations recorded in ADR 0004 are already applied; the host has IPv6): + +- Project **#1 "shop"** (roundrobin): targets #101 IP `10.0.0.11`, #102 label selector `role=web` matching hosts #7 (`10.0.0.12`) and #8 (`10.0.0.13`), #103 IP `10.0.0.14` (backup). Service **#1** http `:20001 → 8080` with an http health check; service **#2** https `:20002 → 8080` with certificate #12, tcp health check and sticky sessions. +- Project **#2 "db"** (leastconn, in maintenance): target #120 IP `10.0.0.41`; service **#3** tcp `:20003 → 5432` with PROXY protocol v2. +- Domains: `shop.example.com` → project #1 (default service #1), https on, certificate #21 issued; `beta.example.com` → project #1, https on, certificate still pending. ```haproxy # Generated by loxy. DO NOT EDIT. node=1 revision=123 desired_version=456 @@ -393,96 +403,132 @@ frontend sys_stats from loxy_http stats uri /stats stats refresh 5s -# ---------- frontend 3 "web" (http, tls, redirect on) ---------- -frontend fe_3 from loxy_http - bind 0.0.0.0:443 ssl crt-list /usr/local/etc/haproxy/live/crt-lists/fe3.lst alpn h2,http/1.1 - bind ipv6@:::443 ssl crt-list /usr/local/etc/haproxy/live/crt-lists/fe3.lst alpn h2,http/1.1 - maxconn 10000 # omitted when maxconn = 0 - timeout client 50s +# ---------- domains (shared :80 / :443) ---------- +frontend sys_http from loxy_http + bind 0.0.0.0:80 + bind ipv6@:::80 v6only + http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/),map(virt@acme_http01)]" if { path_beg /.well-known/acme-challenge/ } { path,field(-1,/),map(virt@acme_http01) -m found } + http-request redirect scheme https code 301 if { req.hdr(host),field(1,:),lower -m str shop.example.com } + option forwardfor + http-request set-header X-Forwarded-Proto http + http-request return status 404 content-type text/plain string "Unknown host" unless { req.hdr(host),field(1,:),lower -m str beta.example.com shop.example.com } + use_backend be_1 if { req.hdr(host),field(1,:),lower -m str beta.example.com shop.example.com } + +frontend sys_https from loxy_http + bind 0.0.0.0:443 ssl crt-list /usr/local/etc/haproxy/live/crt-lists/sys_https.lst alpn h2,http/1.1 + bind ipv6@:::443 v6only ssl crt-list /usr/local/etc/haproxy/live/crt-lists/sys_https.lst alpn h2,http/1.1 option forwardfor http-request set-header X-Forwarded-Proto https - http-request set-header X-Forwarded-Port %[dst_port] - http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(3),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } - use_backend be_5 if { req.hdr(host),field(1,:),lower -m str app.example.com } { path_beg /api } - use_backend be_6 if { req.hdr(host),field(1,:),lower -m end .static.example.com } - default_backend be_4 + http-request return status 404 content-type text/plain string "Unknown host" unless { req.hdr(host),field(1,:),lower -m str beta.example.com shop.example.com } + use_backend be_1 if { req.hdr(host),field(1,:),lower -m str beta.example.com shop.example.com } -frontend fe_3_rd from loxy_http - bind 0.0.0.0:80 - bind ipv6@:::80 - http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/),map(virt@acme_http01)]" if { path_beg /.well-known/acme-challenge/ } { path,field(-1,/),map(virt@acme_http01) -m found } - http-request redirect scheme https code 301 +# ---------- project 1 "shop" · service 1 (http :20001 -> 8080) ---------- +frontend fe_1 from loxy_http + bind 0.0.0.0:20001 + bind ipv6@:::20001 v6only + timeout client 60s + option forwardfor + http-request set-header X-Forwarded-Proto http + http-request set-header X-Forwarded-Port %[dst_port] + http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(1),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } + default_backend be_1 -backend be_4 from loxy_http +backend be_1 from loxy_http balance roundrobin - timeout server 50s + timeout server 60s + http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(1),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } # only because domains route to be_1 option httpchk - http-check send meth GET uri /health ver HTTP/1.1 hdr Host app.example.com + http-check send meth GET uri /health ver HTTP/1.1 hdr Host shop.example.com http-check expect status 200-399 - http-check expect string 'OK' # VERIFY AND-semantics, see §7.5 - timeout check 13s - server t101 10.0.0.11:9000 id 101 check inter 15s rise 3 fall 3 - server t102x7 10.0.0.12:9000 id 102 check inter 15s rise 3 fall 3 - server t102x8 10.0.0.13:9000 id 103 check inter 15s rise 3 fall 3 backup - -backend be_5 from loxy_http - balance leastconn - server t110 10.0.0.21:8080 id 110 check inter 15s rise 3 fall 3 + http-check expect string 'OK' + timeout check 10s + server s101 10.0.0.11:8080 id 101 check inter 15s rise 3 fall 3 + server s102x7 10.0.0.12:8080 id 102 check inter 15s rise 3 fall 3 + server s102x8 10.0.0.13:8080 id 103 check inter 15s rise 3 fall 3 + server s103 10.0.0.14:8080 id 104 check inter 15s rise 3 fall 3 backup + +# ---------- project 1 "shop" · service 2 (https :20002 -> 8080) ---------- +frontend fe_2 from loxy_http + bind 0.0.0.0:20002 ssl crt-list /usr/local/etc/haproxy/live/crt-lists/svc2.lst alpn h2,http/1.1 + bind ipv6@:::20002 v6only ssl crt-list /usr/local/etc/haproxy/live/crt-lists/svc2.lst alpn h2,http/1.1 + timeout client 60s + option forwardfor + http-request set-header X-Forwarded-Proto https + http-request set-header X-Forwarded-Port %[dst_port] + http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(2),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } + default_backend be_2 -backend be_6 from loxy_http +backend be_2 from loxy_http balance roundrobin - server t111 10.0.0.31:80 id 111 check inter 15s rise 3 fall 3 - -# ---------- frontend 9 "postgres" (tcp) ---------- -frontend fe_9 from loxy_tcp - bind 10.0.0.5:5432 - maxconn 2000 - tcp-request connection reject if { str(9),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } - default_backend be_12 + timeout server 60s + cookie LOXYSTICKY insert indirect nocache httponly secure maxlife 300s + server s101 10.0.0.11:8080 id 101 check inter 15s rise 3 fall 3 cookie s101 + server s102x7 10.0.0.12:8080 id 102 check inter 15s rise 3 fall 3 cookie s102x7 + server s102x8 10.0.0.13:8080 id 103 check inter 15s rise 3 fall 3 cookie s102x8 + server s103 10.0.0.14:8080 id 104 check inter 15s rise 3 fall 3 cookie s103 backup + +# ---------- project 2 "db" · service 3 (tcp :20003 -> 5432) ---------- +frontend fe_3 from loxy_tcp + bind 0.0.0.0:20003 + bind ipv6@:::20003 v6only + timeout client 60s + tcp-request connection reject if { str(3),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } + default_backend be_3 -backend be_12 from loxy_tcp +backend be_3 from loxy_tcp balance leastconn - server t120 10.0.0.41:5432 id 120 check inter 15s rise 3 fall 3 send-proxy-v2 check-send-proxy + timeout server 60s + server s120 10.0.0.41:5432 id 120 check inter 15s rise 3 fall 3 send-proxy-v2 check-send-proxy ``` +`crt-lists/svc2.lst` contains `12.pem [alpn h2,http/1.1]`; `crt-lists/sys_https.lst` contains `21.pem [alpn h2,http/1.1]`; `maps/maint.map` contains `3 1` (project #2 is in maintenance). + Notes: - No `master-worker` keyword (deprecated since 3.3; `-W` on the command line is enough). `crt-base` + relative crt‑list entries make each revision validate against its own files (ADR 0004). -- Bind address `*` renders as `0.0.0.0:` + `ipv6@:::`; explicit IPv6 addresses use the `ipv6@addr:port` prefix form (unambiguous). -- Route conditions: each host becomes one `-m str` (exact) or `-m end` (`*.example.com` → `.example.com`) pattern on the lower‑cased Host without port; multiple hosts in a route are OR‑ed (one ACL with several patterns); `path_prefix` adds `{ path_beg }` (AND). A route with only a path prefix matches any host. Routes are evaluated in `position` order; first match wins, then `default_backend`. A frontend without a default backend answers unmatched requests with 503. -- The ACME HTTP‑01 rule is rendered in every `fe__rd` frontend. If a TLS frontend has a managed HTTP‑01 certificate but no redirect, render `sys_acme_fe` on that frontend's addresses:80 that answers challenges and returns 404 otherwise (port collisions with other frontends are rejected by the API, §8.3). -- `maint.map` is an `opt@` map keyed by frontend id: present on disk in the revision **and** updated at runtime with `add map`/`del map`, so maintenance mode is instant and survives reloads. HTTP frontends return the maintenance page with 503; TCP frontends reject the connection. `virt@acme_http01` is a virtual (memory‑only) map because challenge tokens are ephemeral; the reconciler re‑pushes pending tokens after every reload. -- There is no global/per‑object connection cap other than the frontend `maxconn` (0 = not rendered, the global `maxconn` applies). +- Every service listens on all addresses: `0.0.0.0:` plus `ipv6@::: v6only` only when the host supports IPv6 (startup probe or `LOXY_IPV6`, ADR 0004 §9). The same applies to `sys_http` / `sys_https`. +- `sys_http` is rendered whenever at least one domain is attached to a project **or** any managed certificate uses HTTP‑01 (incl. the panel certificate for `LOXY_PANEL_DOMAIN`). Rule order: ACME answer, 301 for https domains whose certificate is issued, `http-request return status 404` for any Host that is not an attached domain, then `use_backend be_` by Host (a redirected domain never reaches it; http‑only domains and https domains still waiting for their certificate do). +- `sys_https` is rendered once at least one attached domain has an issued certificate (a bind with an empty crt‑list is invalid, so no placeholder is needed). Its crt‑list holds the issued domain certificates; clients without SNI get the first entry. Same 404 rule and `use_backend` by Host as `sys_http`. +- Unknown hosts get 404 from `http-request return` (evaluated before `use_backend`), so no extra backend section is needed. +- Host conditions: each FQDN becomes one `-m str` pattern on the lower‑cased Host without port; `*.example.com` becomes `-m end .example.com`. Domains of the same service share one `use_backend` condition (patterns OR‑ed, sorted by FQDN). `use_backend` rules with only exact patterns MUST come before rules with wildcard patterns, so an exact domain of one project wins over a wildcard of another. +- A domain routes to `be_` of its service (default: the project's first http/https service by `position`). Domains of a project without an http/https service, and detached domains, are not rendered. +- `maint.map` is an `opt@` map keyed by **service id**: present on disk in the revision **and** updated at runtime with `add map`/`del map`, so maintenance is instant and survives reloads. Turning a project's maintenance on adds one entry per service. http/https services answer 503 from `be_`, which covers the service port and traffic arriving through `sys_http`/`sys_https` (verified, `docs/haproxy-notes.md`); tcp services reject the connection in `fe_`. `virt@acme_http01` is a virtual (memory‑only) map because challenge tokens are ephemeral; the reconciler re‑pushes pending tokens after every reload. +- `timeout client` / `timeout server` are always rendered from the service's `timeout_s` (default 60). +- Targets are rendered in every service backend of their project, with port = the target's port override, else the service's `destination_port`. ### 7.5 Field → directive mapping -**Frontend fields** +**Project fields** -| Field | http | tcp | -|---|---|---| -| mode | `mode http` (via `from loxy_http`) | `mode tcp` (via `from loxy_tcp`); no header injection; use for TLS passthrough | -| binds [{address, port}] | one `bind` per entry; `*` → all IPv4 + IPv6 | same | -| tls + certificates | `bind … ssl crt-list fe.lst alpn h2,http/1.1`; backends get plain HTTP (TLS termination) | not allowed | -| http_redirect (tls only) | extra `fe__rd` on the same addresses:80 with ACME rule + `http-request redirect scheme https code 301` | not allowed | -| default_backend_id | `default_backend be_` | same | -| routes | `use_backend be_ if …` in position order (§7.4) | not allowed | -| timeout_client_s | `timeout client s` | same | -| maxconn | `maxconn ` (omitted when 0) | same | -| forwarded_headers | `option forwardfor`, `X-Forwarded-Proto` (`https` when tls, else `http`), `X-Forwarded-Port` = `%[dst_port]` | not allowed | -| enabled | frontend (and its `_rd` / `sys_acme_fe`) not rendered when false | same | -| maintenance | `http-request return status 503 … maint.http` if id in `maint.map` | `tcp-request connection reject` if id in `maint.map` | - -**Backend fields** - -| Field | http | tcp | -|---|---|---| -| algorithm | `balance roundrobin|leastconn|source|random` — rendered **explicitly** | same | -| port | default server port (a server's `port` overrides it) | same | -| sticky (cookie name, lifetime) | `cookie insert indirect nocache httponly maxlife s` (+ `secure` when every frontend using the backend has tls), each server `cookie ` | not allowed | -| proxy_protocol v1/v2 | `send-proxy` / `send-proxy-v2` **and** `check-send-proxy` on every server line (otherwise servers that require PROXY reject the health check) | same | -| timeout_server_s | `timeout server s` | same | -| server weight / backup / admin_state | `weight `, `backup`; admin state applied at runtime and kept by server‑state (ADR 0004) | same | +| Field | Rendering | +|---|---| +| algorithm | `balance roundrobin|leastconn|source|random` in every `be_` of the project — rendered **explicitly** | +| maintenance | one `maint.map` entry per service; 503 rule in `be_` for http/https, connection reject in `fe_` for tcp (§7.4) | +| name, description, labels | never rendered | + +**Service fields** + +| Field | http | https | tcp | +|---|---|---|---| +| protocol | `fe_`/`be_` `from loxy_http` | as http + TLS termination | `from loxy_tcp`; no header injection; use for TLS passthrough | +| listen_port | `bind 0.0.0.0:

` (+ `ipv6@:::

v6only`) | `… ssl crt-list svc.lst alpn h2,http/1.1` | as http | +| destination_port | default port of every server line | same | same | +| certificates | not allowed | crt‑list `svc.lst` (≥ 1) | not allowed | +| forwarded headers | always: `option forwardfor`, `X-Forwarded-Proto http`, `X-Forwarded-Port %[dst_port]` | same with `X-Forwarded-Proto https` | none | +| timeout_s | `timeout client` in `fe_`, `timeout server` in `be_` | same | same | +| sticky (cookie name, lifetime) | `cookie insert indirect nocache httponly maxlife s`, each server `cookie ` | same + `secure` | not allowed | +| proxy_protocol v1/v2 | `send-proxy` / `send-proxy-v2` **and** `check-send-proxy` on every server line (otherwise targets that require PROXY reject the health check) | same | same | + +**Target fields** (rendered once per service of the project) + +| Field | Rendering | +|---|---| +| ip / host / label selector | `server s` / `sx` `

: id ` — address: IP, host `ipv4` (or `private_ipv4` with `use_private_ip`, else `ipv6`), one server per matching host for selectors | +| port | overrides the service destination port | +| weight | `weight ` (omitted when 100) | +| backup | `backup` | +| admin_state | applied at runtime (`set server … state`) and kept across reloads by server‑state (ADR 0004 §6, §8). `render.ServerArgs` (shared with runtime `add server`) never carries it; the backend renderer appends `disabled` to the server line of a target in `maint`, so a cold start keeps maintenance. `drain` is runtime‑only | -**Health check (backend)** +**Health check (service)** | Field | Rendering | |---|---| @@ -500,36 +546,45 @@ Status‑code conversion (pure function, table‑tested): each item matches `^[1 **VERIFY (important):** that two consecutive `http-check expect` rules after one `send` are both evaluated against the same response (status AND body). Test: server returns 200 without the string → must be DOWN; returns 500 with the string → must be DOWN; 200 with string → UP. If HAProxy evaluates only one, fall back to a raw `option tcp-check` sequence for that backend (send an HTTP/1.1 request, `tcp-check expect rstring ^HTTP/1\.[01] (2|3)[0-9][0-9]`, then `tcp-check expect string `), and document it in `docs/haproxy-notes.md`. +**Domain fields** + +| Field | Rendering | +|---|---| +| fqdn + project/service | Host condition in `sys_http` (until redirected) and `sys_https` → `use_backend be_` | +| https | `sys_https` routing; once the certificate is issued: certificate in `sys_https.lst` and 301 in `sys_http` | +| certificate (managed HTTP‑01) | ACME answers through `virt@acme_http01` in `sys_http` | +| dns_provider, auto_records, ttl | not rendered (DNS only, §8.12) | + ### 7.6 Change classification (runtime vs reload) | Change | Path | Commands / notes | |---|---|---| -| Add server (any type) when algorithm ∈ {roundrobin, leastconn, random} | runtime | `add server be_4/t105 1.2.3.4:9000 id 105 check inter 15s rise 3 fall 3 [weight n] [backup] [cookie t105] [send-proxy-v2 check-send-proxy]` → `enable server …` → `enable health …` (dynamic servers start in maintenance with checks off; `default-server` settings are **not** inherited, so render every keyword explicitly; the `enabled` keyword is rejected) | -| Remove server | runtime | `set server / state drain` → wait for `scur=0` or drain timeout (default 30 s) → `set server … state maint` → `wait 5s srv-removable /; del server /` | -| Weight change | runtime | `set server / weight ` | -| Drain / maintenance / ready | runtime | `set server / state drain|maint|ready` | +| Add target (any type) when algorithm ∈ {roundrobin, leastconn, random} | runtime | for every service `S` of the project and every resolved slot: `add server be_/s105 1.2.3.4:8080 id 105 check inter 15s rise 3 fall 3 [weight n] [backup] [cookie s105] [send-proxy-v2 check-send-proxy]` → `enable server …` → `enable health …` (dynamic servers start in maintenance with checks off; `default-server` settings are **not** inherited, so render every keyword explicitly; the `enabled` keyword is rejected) | +| Remove target | runtime | per service: `set server / state drain` → wait for `scur=0` on all services or drain timeout (default 30 s) → `set server … state maint` → `wait 5s srv-removable /; del server /` | +| Weight change | runtime | `set server be_/ weight ` for every service | +| Drain / maintenance / ready | runtime | `set server be_/ state drain|maint|ready` for every service | | Label‑selector match set changes (host labels or addresses edited) | runtime | treated as add/remove | -| Server changes in `source` (hash) backends | **reload** | dynamic servers only work with roundrobin, leastconn, first, random | -| Frontend maintenance on/off | runtime | `add map /usr/local/etc/haproxy/live/maps/maint.map 1` / `del map … ` (+ file in revision). The `opt@` prefix is not part of a map's name, so runtime commands use the plain path; `virt@` *is* part of the name | +| Target changes in a `source` (hash) project | **reload** | dynamic servers only work with roundrobin, leastconn, first, random | +| Project maintenance on/off | runtime | `add map /usr/local/etc/haproxy/live/maps/maint.map 1` / `del map … ` per service (+ file in revision). The `opt@` prefix is not part of a map's name, so runtime commands use the plain path; `virt@` *is* part of the name | | Certificate renewed / replaced (same file) | runtime | `set ssl cert <<\n\n` → `commit ssl cert ` | | ACME HTTP‑01 token | runtime | `add map virt@acme_http01 ` / `del map …` | -| Add / edit / delete / enable / disable frontend | reload | new or changed listeners need a new worker | -| Binds, TLS, redirect, routes, default backend, timeouts, maxconn, forwarded headers | reload | | -| Certificate attached to / detached from a frontend | reload | any crt‑list change is reload‑class (ADR 0004); MAY later use `add/del ssl crt-list` | -| Add / edit / delete backend (algorithm, health check, sticky, proxy protocol, timeouts, port) | reload | | +| Create / delete project, algorithm change | reload | | +| Add / edit / delete service (any field) | reload | new or changed listeners need a new worker | +| Certificate attached to / detached from an https service | reload | any crt‑list change is reload‑class (ADR 0004); MAY later use `add/del ssl crt-list` | +| Add / delete / move domain, toggle `https`, domain certificate issued (adds the cert to `sys_https.lst` and turns on the 301) | reload | `sys_http`/`sys_https` rules change | | Panel allowlist | reload (rare) | MAY use `add acl` at runtime | Every runtime op also produces the new on‑disk revision. If any runtime command fails, the reconciler falls back to a reload of that revision. -HAProxy 3.4 also allows creating, publishing and deleting whole **backends** at runtime (`add backend … from `, `publish backend`, `unpublish backend`, `del backend`). v1 doesn't need it (backends become reachable only through a frontend change, which is a reload anyway); MAY be used later to make "add backend + route" reload‑free once `use_backend` rules can also change at runtime. +HAProxy 3.4 also allows creating, publishing and deleting whole **backends** at runtime (`add backend … from `, `publish backend`, `unpublish backend`, `del backend`). v1 doesn't need it (a new service always needs a new listener, which is a reload anyway); MAY be used later. ### 7.7 Reload procedure 1. `show servers state` → write `/var/lib/haproxy/server-state` atomically. 2. `haproxy -c -f revs/N/haproxy.check.cfg` (inside the loxy container, same HAProxy binary). Non‑zero exit → abort, keep current `live`, record the output. 3. Swap `live` → `revs/N`. 4. Master CLI `reload`; read until EOF; require `Success=1`. -5. On `Success=0`: swap `live` back to the previous revision (so a future cold start is safe), record the startup log, mark the affected frontends/backends `apply_error`, notify. Old workers keep serving the previous config. -6. Post‑checks: `show proc` lists a new worker; `show stat` contains every expected frontend/backend; otherwise treat as failed. +5. On `Success=0`: swap `live` back to the previous revision (so a future cold start is safe), record the startup log, mark the affected projects `error`, notify. Old workers keep serving the previous config. +6. Post‑checks: `show proc` lists a new worker; `show stat` contains every expected frontend/backend section; otherwise treat as failed. 7. Re‑push ephemeral runtime state (pending ACME tokens in `virt@acme_http01`). 8. Rate limits: coalesce via the reconciler debounce; at most one reload per second per node; a reload storm queues. 9. Old workers are bounded by `hard-stop-after` (setting, default 15 m) and `mworker-max-reloads` (default 50), so long‑lived connections cannot pile up memory forever. @@ -547,110 +602,143 @@ On first start loxy writes `revs/000001/` from `deploy/haproxy/bootstrap.cfg` (g - 3.4 changed compression syntax: `filter comp-res` / `filter comp-req`; `compression direction` is deprecated. - 3.4 stats page hides the version unless `stats show-version`. - Native ACME inside HAProxy is still marked experimental and keeps renewed certs in memory only; loxy uses lego instead (§11) and MAY switch later. +- `ipv6@:::` makes the worker exit at startup on hosts without IPv6 although `haproxy -c` passes (ADR 0004 §9). --- ## 8. Feature specification (screens, behaviour, validation, acceptance) -Global navigation: **Dashboard · Frontends · Backends · Hosts · Certificates · Domains · Activity · System · Settings** (Domains has sub‑pages *Domains*, *DNS providers*, *Zones*). Top bar: project switcher (hidden while only one project exists), global apply status (applying / last error with "Retry", `POST /apply/retry`), user menu, live connection indicator (SSE state). +Global navigation: **Dashboard · Projects · Hosts · Certificates · Domains · Activity · System · Settings** (Domains has sub‑pages *Domains*, *DNS providers*, *Zones*). Top bar: global apply status (applying / last error with "Retry", `POST /apply/retry`), user menu, live connection indicator (SSE state). + +All destructive actions use a confirm modal; deleting a project requires typing its name. Every mutation returns an **action** object (§12.4) whose progress is shown as a toast and in Activity. An apply error shows a banner on the affected project and on System with the HAProxy output and "Retry" / "Roll back to last good revision". -All destructive actions use a confirm modal; deleting a frontend or backend requires typing its name. Every mutation returns an **action** object (§12.4) whose progress is shown as a toast and in Activity. An apply error shows a banner on the affected frontend/backend and on System with the HAProxy output and "Retry" / "Roll back to last good revision". +**Reserved ports** (used by every port validation below): **80 and 443** (domains), the panel port (`LOXY_PANEL_PORT`, default 8443), **8405** (stats), **5514** (log receiver) and the port of `LOXY_LISTEN` (default 9090). `LOXY_PANEL_PORT` set to 80 or 443 is refused at start. ### 8.1 Dashboard (`/dashboard`) -- Stat row (live via SSE): frontends (enabled / total, in maintenance), backends by health (healthy / degraded / unhealthy / unknown), servers (healthy / total, draining, in maintenance), certificates expiring < 21 d, current open connections and requests/s across all frontends, bandwidth in/out. -- **Needs attention** list: unhealthy/degraded backends with the first failing checks ("10.0.0.12:9000 · Layer7 wrong status 503"), frontends with apply errors, expiring or failed certificates, domains not resolving correctly. -- Traffic sparkline per top‑5 frontend (last 5 min), last 10 events, node status (HAProxy version, uptime, last reload result). +- Stat row (live via SSE): projects (total, by health: healthy / degraded / unhealthy, in maintenance, with apply errors), targets (healthy / total, draining, in maintenance), certificates expiring < 21 d or failed, current open connections and requests/s across all projects, bandwidth in/out. +- **Needs attention** list: unhealthy/degraded projects with the first failing checks ("shop · 10.0.0.12:8080 on :20001 · Layer7 wrong status 503"), projects with apply errors, expiring or failed certificates, domains not resolving correctly. +- Traffic sparkline per top‑5 project (last 5 min), last 10 events, node status (HAProxy version, uptime, last reload result). - Acceptance: renders from `GET /overview` + one SSE stream in < 100 ms after data arrives; no per‑card polling. -### 8.2 Frontends list (`/frontends`) -- Table: status dot (apply status + maintenance + disabled), name, `#id`, mode badge (`HTTP`, `HTTPS` when tls, `TCP`), binds (`*:443`, `10.0.0.5:5432`, "+2"), default backend, routes count, backend health aggregate, live open connections sparkline (last 5 min), req/s, labels. -- Filters: text search, label selector (`env=prod,team!=ops`), mode, status. Sort by name/created/connections. -- **Create frontend** button → form (§8.3). Row ⋯ menu: Edit · Maintenance on/off · Enable/Disable · Duplicate · Delete. -- Empty state explains the model in one line ("A frontend listens on addresses and ports and sends traffic to backends.") with "Create frontend" and "Create backend" actions. -- Acceptance: 100 frontends render in < 100 ms after data arrives; sparklines update via one SSE stream, not per‑row polling. - -### 8.3 Frontend create / edit form -Single page with sections; the same form edits an existing frontend (PUT with `If-Match`). -1. **Name, description, labels.** -2. **Mode & TLS** — mode `http | tcp` (segmented control); for http a **TLS termination** toggle; when on: **certificates** picker (chips; existing certificates or create a managed certificate inline from the hostnames of the routes, or upload) and **HTTP → HTTPS redirect (301)** checkbox (default on). -3. **Binds** editor — rows of address + port; the address field is a combobox with `*` (all addresses) and the node addresses (§8.10) as suggestions, any IPv4/IPv6 literal allowed; add/remove rows; templates for the port: 80, 443, custom. Inline collision warnings while typing. -4. **Default backend** — select (filtered to backends of the same mode) or "Create backend…" (opens the backend form in a modal, returns its id). -5. **Routing rules** (http only) — table of rules, drag handle to reorder (order = evaluation order, first match wins): hosts (chip input, exact or `*.example.com`), path prefix (`/api`), backend select; "Add rule". A preview line under each rule reads like the rendered condition ("Host is app.example.com and path starts with /api → api‑backend"). -6. **Options** — client timeout (s, default 60), max connections (0 = unlimited), forwarded headers (http, default on), enabled (default on). -- Footer: "Show config preview" (renders the would‑be `fe_` sections via `loxy render --dry-run` logic, read‑only `CodeView`), Cancel, Save. Creation of a frontend with a new default backend happens in one transaction → one reload. +### 8.2 Projects list (`/projects`) +- Cards or table (toggle, remembered per browser): health badge, name, `#id`, **endpoints** (`:20001`, `https://shop.example.com`, "+2", copy on click), protocols (`HTTP`, `HTTPS`, `TCP` badges), targets healthy/total, live open connections sparkline (last 5 min), req/s, maintenance badge, labels. +- Filters: text search, label selector (`env=prod,team!=ops`), health. Sort by name/created/connections. +- **Create project** button → wizard (§8.3). Row ⋯ menu: Open · Maintenance on/off · Copy ID · Delete. +- Empty state explains the model in one line ("A project is a load balancer: it listens on a port or a domain and spreads traffic over your targets.") with "Create project". +- Acceptance: 50 projects render in < 100 ms after data arrives; sparklines update via one SSE stream, not per‑row polling. + +### 8.3 Create project wizard (`/projects/new`) +One page, three short steps with a summary; only the name is required. Submitting creates everything in **one transaction → one reload** (`POST /projects` with `targets` and `service`). +1. **Name** (required), description, labels, algorithm (Round Robin default; Least Connections, Source, Random). +2. **Targets** (optional, "Skip for now"): the same add‑targets control as §8.4 Targets (IP list / hosts / label selector). +3. **Service:** protocol `http` (default) | `https` | `tcp` (segmented control); **listen port**: "Automatic" (default; shows the port that will be taken, e.g. "next free: 20003") or a custom value; **destination port** (default 80 for http/https, required for tcp); certificates for https (picker; upload or create managed). Advanced (collapsed): health check, sticky sessions, PROXY protocol, timeout — with the defaults of §8.4 Services. +4. Optional: **Attach a domain** (FQDN; shown only for http/https). +- On success the project Overview opens with its endpoints ("Reachable at `http://203.0.113.10:20003`") and, if a domain was added, its required DNS records. +- Validation: as §8.4 Services/Targets and §8.4 Domains. +- Acceptance: creating a project with only a name gives an http service on the lowest free port in 20000–29999; with 2 IP targets it is reachable and both targets are healthy within 3 s of submit on a dev box; exactly one reload. + +### 8.4 Project (`/projects/[id]/…`) +**Header (all tabs):** editable name (inline), health badge, `#id`, endpoints (copy on click), maintenance toggle (confirm), ⋯ menu: Export JSON · Copy ID · Delete. + +Tabs: **Overview · Graphs · Targets · Services · Domains · Logs · Settings**. Every tab is a route (`/projects/1/targets`). + +**Overview** +- Endpoints card: each service as `protocol listen → destination` with its URL (`http://:`), each domain as `https://fqdn` (or `http://fqdn` until the certificate is issued) with DNS and certificate status. +- Stat cards (live): open connections, conn/s, req/s, bandwidth in/out. +- **Health** card: donut of healthy targets / total, badge `Healthy | Degraded | Unhealthy | Unknown`, first 3 failing checks with service and reason. +- Targets and services summaries (counts, link to tabs); **Labels** row (chips + editor, §14.4 rules); getting‑started hints while the project has no targets or no domain. + +**Graphs** +- Range selector **Live** (rolling 5 min, 1 s points, auto‑updating), 30 min, 1 h, 6 h, 24 h, 7 d, 30 d; filter: whole project / one service. +- Charts: **Open connections**, **Connections per second**, **HTTP requests per second** (http/https), **Bandwidth** (in/out; units auto‑scale B/s → GB/s); "Details" (collapsed): **Sessions**, **Queue**, **Average response time (ms)**, **HTTP responses per second by class** (2xx/3xx/4xx/5xx), **Errors per second** (connection, response, retries/redispatches, denied). +- uPlot with synced cursors; tooltip with exact values and timestamp in the browser's timezone; Live pauses while hovering; no false spikes or dips across reloads (§10.3). +- Acceptance: switching ranges renders in < 150 ms for 30 d; Live updates every second with ≤ 2 s lag; browser CPU < 5 % on a laptop. + +**Targets** +- **Add targets** dropdown: **IP** — one or many (paste, one per line, optional `:port`, IPv6 as `[addr]:port`); rejects loopback (unless `LOXY_DEV=true`), link‑local, multicast, unspecified. **Host** — multi‑select from the inventory with search and label filter; "Use private IP" toggle. **Label selector** — input with syntax `key=value`, `key!=value`, `key in (a,b)`, `key` (exists), comma = AND; live preview "matches N hosts" (`GET /label_selector/preview`). Each with optional port override, weight (default 100) and backup flag. +- **Grouped / List** toggle. Grouped: each label selector is a group (tag icon, "N hosts", aggregate health, ⋯); host targets grouped as "Hosts"; IP targets as "IP addresses". List: flat table of resolved targets. +- Row columns: name (host name or "—"), address (port override shown when set), **status** per service (one chip per service: `:20001 healthy`, with check detail tooltip `L7OK/200 in 3 ms` or failure reason; the row status is healthy if healthy on all services, degraded if mixed, unhealthy if on none), weight, backup, sessions, ⋯ menu: Drain · Maintenance · Ready · Set weight · Remove. +- Removal is graceful (drain → maint → delete, §7.6) and shows progress (`removing`). +- Empty state: "No targets yet — add the servers that should receive traffic." with the three add options. +- Validation: ≤ 1 000 targets per installation budget (§1.4); duplicate IP targets (same address and port) within a project refused; a host may be referenced directly only once per project; weight 1–256; port 1–65535. +- Acceptance: adding 25 IP targets to a project with one service results in 25 runtime `add server` ops, **no reload**, all active in < 1 s, zero failed requests under load. + +**Services** +- List: protocol badge, `listen → destination`, URL, health check summary (`HTTP GET /health · 15 s`), sticky / PROXY badges, certificates (https), domains routed to it, health (healthy targets / total for this service). Row ⋯: Edit · Delete. **Add service** button. +- Form: protocol `http | https | tcp`; listen port ("Automatic" or custom); destination port; certificates (https; ≥ 1, pending managed allowed — the placeholder is served until issuance); **health check**: protocol (tcp/http/https), port (default = target port), interval (s), timeout (s), retries, domain, path, response, status codes, TLS verify (https checks); **sticky sessions** (http/https): checkbox + cookie name + lifetime (s); **PROXY protocol**: none/v1/v2 with a red warning that enabling it for targets that don't accept PROXY makes the service unreachable; timeout (s). "Show config preview" renders the would‑be `fe_`/`be_` (read‑only `CodeView`). +- Defaults: http, automatic port, destination 80; health check tcp for tcp services and http (`GET /`, status codes `2??, 3??`) for http/https, interval 15 s, timeout 10 s, retries 3; timeout 60 s; sticky cookie `LOXYSTICKY`, lifetime 300 s; PROXY none. - Validation (server side, mirrored client side): - - ≥ 1 bind; port 1–65535; address `*` or a valid IP literal (`net/netip`); no duplicate binds within the frontend. - - **Port collisions across frontends:** a bind collides with another frontend's bind on the same port when the addresses are equal or either is `*`; the check includes every frontend's redirect listener (:80 on its addresses when `http_redirect`), every `sys_acme_fe` :80 listener, and reserved system ports (panel port, `127.0.0.1:8405`, `127.0.0.1:5514`, `127.0.0.1:9090`). Disabled frontends still reserve their ports (so enabling never fails on a collision). - - `tls` requires mode http and ≥ 1 certificate (a pending managed cert is allowed; a placeholder self‑signed cert is served until issuance). - - `http_redirect` only with tls, and only when :80 is free on all of the frontend's addresses. - - Routes require mode http; every route needs ≥ 1 host or a path prefix; hosts are RFC 1123 (`*.` prefix allowed), lowercase, ≤ 20 per route; path prefix matches the path rule of §14.4; ≤ 200 routes per frontend. - - The default backend and every route's backend must exist and have the **same mode** as the frontend. - - `timeout_client_s` 1–3600; `maxconn` 0–1 000 000. -- Acceptance: creating an http frontend on `*:8080` with a default backend of 2 IP servers → reachable, both servers healthy, within 3 s of submit on a dev box; a TLS frontend with a managed cert on a domain pointing to the node yields a valid certificate (Pebble in CI) and a 301 from :80 within 60 s, with only one reload. - -### 8.4 Frontend detail (`/frontends/[id]/…`) -**Header (all tabs):** editable name (inline), status, `#id`, mode badge, binds (copy on click), maintenance toggle (confirm), ⋯ menu: Edit · Enable/Disable · Duplicate · Export JSON · Copy ID · Delete. - -Tabs: **Overview · Routing · Graphs · Logs · Config**. -- **Overview:** stat cards (open connections, conn/s, req/s, bandwidth in/out, live); **Health** card: donut of healthy servers / total across the backends this frontend uses, badge `Healthy | Degraded | Unhealthy | Unknown`, first 3 failing checks with reason; **Binds** card; **TLS** card (certificates with expiry, redirect on/off); **Labels** row (chips + editor, §14.4 rules); **Domains** attached to this frontend with their DNS status (§8.9). -- **Routing:** read view of the default backend and the ordered rules, each with the target backend's health; "Edit routing" opens the routing section of §8.3. Backends link to §8.6. -- **Graphs:** range selector **Live** (rolling 5 min, 1 s points, auto‑updating), 30 min, 1 h, 6 h, 24 h, 7 d, 30 d. Charts: **Open connections**, **Connections per second**, **HTTP requests per second** (http only), **Bandwidth** (in/out; units auto‑scale B/s → GB/s); "Details" (collapsed): HTTP responses per second by class (2xx/3xx/4xx/5xx), denied/errored requests. uPlot with synced cursors; tooltip with exact values and timestamp in the browser's timezone; Live pauses while hovering; no false spikes or dips across reloads (§10.3). Acceptance: switching ranges renders in < 150 ms for 30 d; Live updates every second with ≤ 2 s lag; browser CPU < 5 % on a laptop. -- **Logs:** live log stream of this frontend (sampled, off by default, §20): pause, filter text, status class filter; columns time, client, method + path (http) / bytes, backend/server, status, timings. -- **Config:** read‑only rendered sections of this frontend (`fe_`, `fe__rd`, `sys_acme_fe`, and the backends it uses) from `GET /frontends/{id}/config`, with "Copy" and "Download". -- Delete: confirm by typing the name; removes the frontend sections (reload), detaches its domains (`frontend_id` set NULL) and certificates (certificates are kept), keeps metrics for 7 days then purges, audit entry. Backends are untouched. - -### 8.5 Backends list (`/backends`) -- Table: health badge, name, `#id`, mode, algorithm, servers healthy/total (with draining/maint counts), used by (frontend names), sessions, queue, avg response time, labels. -- Filters: text, label selector, mode, health. **Create backend** → form (name, description, mode, algorithm, default port, health check, then add servers right away). -- Acceptance: 200 backends with 1 000 servers render in < 150 ms after data arrives. - -### 8.6 Backend detail (`/backends/[id]/…`) -Header: name, `#id`, mode, algorithm, health badge, "Used by" chips linking to frontends, ⋯ menu: Duplicate · Export JSON · Copy ID · Delete. - -Tabs: **Servers · Settings · Graphs**. -- **Servers:** - - **Add servers** dropdown: **IP** — one or many (paste, one per line, optional `:port`); IPv4/IPv6; rejects loopback (unless `LOXY_DEV=true`), link‑local, multicast, unspecified. **Host** — multi‑select from the inventory with search and label filter; "Use private IP" toggle. **Label selector** — input with syntax `key=value`, `key!=value`, `key in (a,b)`, `key` (exists), comma = AND; live preview "matches N hosts" (`GET /label_selector/preview`). Each with optional port override, weight (default 100) and backup flag. - - **Grouped / List** toggle. Grouped: each label selector is a group (tag icon, "N hosts", aggregate health, ⋯); host servers grouped as "Hosts"; IP servers as "IP servers". List: flat table of resolved servers. - - Row columns: name (host name or "—"), address:port, **status** (healthy / unhealthy / draining / maintenance / unknown, with check detail tooltip `L7OK/200 in 3 ms` or failure reason), weight, backup, sessions, ⋯ menu: Drain · Maintenance · Ready · Set weight · Show in graphs · Remove. - - Removal is graceful (drain → maint → delete, §7.6) and shows progress (`removing`). - - Empty state: "No servers yet." with the three add options. - - Acceptance: adding 25 IP servers results in 25 runtime `add server` ops, **no reload**, all active in < 1 s, zero failed requests under load. -- **Settings:** algorithm (Round Robin default, Least Connections, Source, Random; note that Source makes server changes reload‑class), default port; **health check**: protocol (tcp/http/https), port, interval (s), timeout (s), retries, domain, path, response, status codes, TLS verify (https checks); **sticky sessions**: checkbox + cookie name + lifetime (s); **PROXY protocol**: none/v1/v2 with a red warning that enabling it for servers that don't accept PROXY makes the backend unreachable; server timeout (s); labels. - - Defaults: algorithm roundrobin; hc tcp for tcp backends and http for http backends, interval 15 s, timeout 10 s, retries 3, path `/`, status codes `2??, 3??`; server timeout 60 s; sticky cookie `LOXYSTICKY`, lifetime 300 s; health‑check port = server port. - - Validation: port 1–65535; `hc_timeout < hc_interval`; interval 3–3600; timeout 1–3600; retries 1–10; http/https checks require a path and are allowed on tcp backends too; HTTP fields are returned as null for tcp checks; sticky only for http mode; changing `mode` is refused while the backend is used by a frontend of the other mode. -- **Graphs:** same range selector as §8.4; charts **Sessions** (current), **Queue**, **Average response time (ms)**, **HTTP responses per second by class** (http), **Errors per second** (connection, response, retries/redispatches); filter: whole backend / one server. -- Delete: refused with `409 conflict` while the backend is a default backend or a route target of any frontend (message lists them); otherwise confirm by typing the name → reload, server slots released, metrics kept 7 days. - -### 8.7 Hosts (inventory, `/hosts`) + - listen port 1–65535, **not reserved** (§8 header) and **unique across all projects** (409 names the project using it: "port 20001 is used by project shop (#1)"); automatic allocation picks the lowest free port in **20000–29999** inside the same transaction ("no free port left" error when exhausted; explicit ports outside the range are allowed). + - destination port 1–65535. + - certificates only for https and ≥ 1 there; sticky only for http/https; `hc_timeout < hc_interval`; interval 3–3600; timeout 1–3600; retries 1–10; http/https checks require a path and are allowed on tcp services too; HTTP fields are returned as null for tcp checks; `timeout_s` 1–3600. + - ≤ 20 services per project. + - Changing a service to `tcp` or deleting it is refused with `409 conflict` while it is the **only** http/https service of a project that has domains (message lists the domains); domains pinned to a deleted service fall back to the default service. +- Acceptance: adding an https service with a managed certificate (Pebble in CI) serves a valid certificate within 60 s with only one reload; changing the health check path takes effect after one reload. + +**Domains** +- The project's domains: FQDN, service (default or pinned), HTTPS on/off, DNS status (**Resolves correctly** / **Wrong IP** / **Missing** / **Unknown**), certificate (issued / pending / failed / none), URL. ⋯: Check now · Edit · Detach · Delete. +- **Add domain**: FQDN (§14.4), service (only http/https services; default "First HTTP service"), **HTTPS** toggle (default on), optional DNS provider (auto‑detected when a connected provider owns a matching zone), "Manage A/AAAA records automatically" (only with a provider; Phase 2). Shows the **required records** immediately (§8.12). +- Validation: FQDN unique across the installation (409 names the project using it); wildcard `*.example.com` only with a DNS provider (DNS‑01 required); the project needs ≥ 1 http/https service; ≤ 100 domains per project. +- Acceptance: see §8.12. + +**Logs** — live log stream of this project (sampled, off by default, §20): pause, filter text, service filter, status class filter; columns time, client, service, method + path (http) / bytes, target, status, timings. + +**Settings** +- Name, description, labels, algorithm (note that Source makes target changes reload‑class), maintenance mode (explains: http 503 page, tcp connections rejected; applies to ports and domains). +- **Config** (read‑only): rendered sections of this project (`fe_`, `be_` of all services, plus the `sys_http`/`sys_https` lines for its domains) from `GET /projects/{id}/config`, with "Copy" and "Download". +- **Danger zone:** Delete project — confirm by typing the name; removes all services, targets and its domains (reload; auto‑managed DNS records are removed first; `ON DELETE SET NULL` on `domains.balancer_id` remains only as a safety net), keeps metrics for 7 days then purges, audit entry. Certificates are kept. + +### 8.5 Hosts (inventory, `/hosts`) - CRUD of hosts: name, IPv4, IPv6, private IPv4, labels, notes. Bulk import from CSV (`name,ipv4,ipv6,private_ipv4,labels`). -- Shows where each host is used (backends, directly or via a label selector) and its current health there. -- Editing a host's labels or addresses updates every label‑selector / host server at runtime (§7.6). Deleting a host that is referenced directly by a server is refused; selector matches simply drop out (graceful removal). +- Shows where each host is used (projects, directly or via a label selector) and its current health there. +- Editing a host's labels or addresses updates every label‑selector / host target at runtime (§7.6). Deleting a host that is referenced directly by a target is refused; selector matches simply drop out (graceful removal). -### 8.8 Certificates (`/certificates`) -- List: name, type (Uploaded/Managed), domains, status (Pending/Issued/Renewing/Failed), expires in (amber < 21 d, red < 7 d), used by N frontends, labels. +### 8.6 Certificates (`/certificates`) +- List: name, type (Uploaded/Managed), domains, status (Pending/Issued/Renewing/Failed), expires in (amber < 21 d, red < 7 d), used by (services and domains), labels. - **Upload**: paste or file for certificate, chain, private key. Validate: key matches cert, not expired, chain completes to a root in the system bundle (warn only), reorder chain if needed. Key stored encrypted at rest. -- **Create managed**: domains (≤ 100), challenge HTTP‑01 (default; domains must resolve to a frontend that serves the challenge on :80 — its redirect frontend or `sys_acme_fe`) or DNS‑01 (pick a connected DNS provider from §8.9 — credentials are not entered again; required for wildcards), key type ECDSA P‑256 (default) / RSA 2048/4096. -- Detail drawer: SANs, issuer, serial, fingerprint, validity, last issuance log, renewal schedule, frontends using it. Actions: Renew now · Download chain (never the key unless owner role + re‑auth) · Delete (blocked while attached to a frontend). +- **Create managed**: domains (≤ 100), challenge HTTP‑01 (default; the names must resolve to the node — Loxy answers on :80 through `sys_http`) or DNS‑01 (pick a connected DNS provider from §8.12 — credentials are not entered again; required for wildcards), key type ECDSA P‑256 (default) / RSA 2048/4096. +- Certificates created automatically for domains appear here with a "Domain" badge and are deleted with their domain. +- Detail drawer: SANs, issuer, serial, fingerprint, validity, last issuance log, renewal schedule, services/domains using it. Actions: Renew now · Download chain (never the key unless owner role + re‑auth) · Delete (blocked while used by a service or domain). + +### 8.7 Domains — global list (`/domains`) +- All domains of the installation: FQDN, project (or **Detached**), service, HTTPS, DNS provider (or "manual"), DNS status, last checked, certificate, auto‑managed records on/off. +- Filters: project, status, detached only. **Add domain** (as §8.4 Domains, with a project picker). Detached domains (e.g. after a project was deleted or after migration `0002`) offer **Attach to project**. +- Sub‑pages *DNS providers* and *Zones* (§8.12). + +### 8.8 System (`/system`) +- **Node** card (all‑in‑one shows the local node): status, HAProxy version and feature flags (`haproxy -vv`: QUIC, PROMEX, OpenSSL version), loxy version, HAProxy uptime, reload count and last reload result/duration, current connections, CPU/memory of both containers, IPv6 listener status; warning when the bundled `haproxy -v` differs from the running one. +- **Node addresses**: list of the node's IPv4/IPv6 addresses with an optional label (`POST/DELETE /nodes/{id}/ips`); detected interface addresses are offered for one‑click adding. Used for endpoint URLs and as DNS required records for domains (§8.12). Changing them never changes the HAProxy config by itself. +- **Ports in use**: every listen port with its project and service, plus the reserved ports. +- **Config revisions**: list (revision, kind runtime/reload/rollback/bootstrap, result, duration, author, time, pinned), diff between any two (`CodeView`), "Roll back to this revision" (break‑glass swap + reload of the on‑disk revision, see ADR 0004 §7; the next mutation re‑applies desired state, which the UI states), pin/unpin, HAProxy output of failed applies. +- Phase 3 adds enrollment, multiple nodes and HA groups (§15). -### 8.9 Domains & DNS (`/domains`) +### 8.9 Activity (`/activity`) +- **Events** feed (target/project health transitions, apply/reload results, cert issued/failed/expiring, domain status changes, node offline) with filters by project/kind/severity. +- **Audit log** (who, what, when, before/after diff, IP, token) — read‑only, exportable CSV. + +### 8.10 Settings (`/settings/*`) +Users & roles · API tokens · ACME (account email, directory: Let's Encrypt production / staging / custom, EAB) · Notification channels & alert rules (event kinds, label filter on **project labels** — node‑wide events like apply and backup are not filtered, min severity, cooldown, "Send test") · Two‑factor authentication (own TOTP; enforcement for all users) · Panel (domain, port, allowlist, session lifetime) · System (hard‑stop‑after, drain timeout, reload min interval, metrics retention, live‑log sampling) · Backup (schedule, retention, optional S3‑compatible target). + +### 8.11 Automatic HTTPS for domains +- A domain with `https` on gets a managed certificate automatically: each verification (`VerifyDomainNow` — on add, "Check now" and the periodic checks of §8.12) that finds the name resolving to the node's addresses issues a managed certificate for the FQDN, if the domain has none yet — HTTP‑01 through `sys_http`, or DNS‑01 when the domain has a DNS provider (required for wildcards) — and links it via `domains.certificate_id`. A domain that already has a certificate is left as is. +- Until the certificate is issued, the domain is served over http on :80 (no redirect); only once it is issued the certificate is added to `sys_https.lst` (rendering `sys_https` if it is the first) and :80 answers `301` to `https://` (one reload). Renewals are runtime (§11.2). +- Failures (rate limits, wrong DNS) show on the domain with the ACME error and retry with the backoff of §11.2; the domain keeps working over http meanwhile. +- `https` off: the domain is served over http on :80 only and no certificate is requested. -Loxy helps operators point names at their frontends and keeps DNS in sync, **without running its own DNS server** (non‑goal, §1.3). All record changes go through the APIs of external DNS providers using the **libdns** ecosystem (`github.com/libdns/*`). +### 8.12 DNS: required records, verification, providers, zones -**Domains page** (`/domains`) -- List: FQDN, attached frontend, DNS provider (or "manual"), status badge (**Resolves correctly** / **Wrong IP** / **Missing** / **Unknown**), last checked, certificate (issued / pending / none), auto‑managed records on/off. -- **Add domain**: FQDN (validated per §14.4), choose a frontend (required), optional DNS provider (auto‑detected when a connected provider owns a matching zone), "Manage A/AAAA records automatically" checkbox (only with a provider; Phase 2). -- **Required records** are derived from the frontend's binds: each explicit bind address gives `A → ` / `AAAA → `; when the frontend binds `*`, the node's addresses (§8.10, public ones only — loopback/private are skipped unless they are the only ones) are used instead. TTL 300, with copy buttons. -- Detail drawer: required records table; **live verification**: Loxy resolves the name (A and AAAA, via the system resolver and, when a provider is attached, also by reading the provider's records) and shows per family: ✓ resolves correctly, ✗ wrong IP (shows the observed addresses), ✗ missing. -- Verification runs on add, on "Check now", every 5 minutes for unverified domains and every hour for verified ones, and whenever the frontend's binds or the node addresses change. Status changes emit `domain.status.changed` events. -- **Issue certificate** (one click) is enabled once the domain is verified: creates a managed certificate for the FQDN (HTTP‑01 if the name resolves to the frontend and it has a :80 listener, or DNS‑01 if a provider is attached — required for wildcards) and attaches it to the frontend when it terminates TLS. +Loxy helps operators point names at their projects and keeps DNS in sync, **without running its own DNS server** (non‑goal, §1.3). All record changes go through the APIs of external DNS providers using the **libdns** ecosystem (`github.com/libdns/*`). + +- **Required records** of a domain are the node's addresses (§8.8; public ones only — loopback/private are skipped unless they are the only ones): `A → ` / `AAAA → `, TTL 300, with copy buttons. (Phase 3: the HA group's virtual IPs.) +- **Live verification**: Loxy resolves the name (A and AAAA, via the system resolver and, when a provider is attached, also by reading the provider's records) and shows per family: ✓ resolves correctly, ✗ wrong IP (shows the observed addresses), ✗ missing. Runs on add, on "Check now", every 5 minutes for unverified domains and every hour for verified ones, and whenever the node addresses change. Status changes emit `domain.status.changed` events. **DNS providers** (`/domains/providers`) -- Connect provider accounts through libdns provider modules (first: the providers listed in §21 Q4; each with its own token fields, e.g. a scoped API token with zone‑read and DNS‑edit rights). The registry is a map `kind → {fields schema, constructor}` so new libdns providers are added in one file; `GET /dns_providers` also returns the available kinds and their fields. +- Connect provider accounts through libdns provider modules (the registry's kinds, §21 Q4; each with its own token fields, e.g. a scoped API token with zone‑read and DNS‑edit rights). The registry is a map `kind → {fields schema, constructor}` so new libdns providers are added in one file; `GET /dns_providers` also returns the available kinds and their fields. - Credentials are encrypted at rest with AES‑256‑GCM (`LOXY_SECRET_KEY`, §14.5), never returned by the API (only `"configured": true`), and tested on save with a `ListZones` call (status `ok|error`). -- **ACME DNS‑01 reuses the same credentials**: `internal/dns` implements lego's `challenge.Provider` (`Present`/`CleanUp`) on top of the libdns `RecordAppender`/`RecordDeleter` interfaces, writing `_acme-challenge. TXT ` (TTL 60) into the right zone and deleting exactly that record afterwards. Certificates reference a `dns_provider_id` instead of carrying their own credentials, so credentials live in one place. +- **ACME DNS‑01 reuses the same credentials**: `internal/dns` implements lego's `challenge.Provider` (`Present`/`CleanUp`) on top of the libdns `RecordAppender`/`RecordDeleter` interfaces, writing `_acme-challenge. TXT ` (TTL 60) into the right zone and deleting exactly that record afterwards. Certificates reference a `dns_provider_id` instead of carrying their own credentials. **Auto‑managed records** (Phase 2) -- Opt‑in per domain. When on and a provider is connected, Loxy creates the A/AAAA records for the FQDN pointing at the required addresses, **updates** them when they change (frontend binds edited, node addresses edited, domain moved to another frontend), and **removes** them when the domain is detached or deleted or its frontend is deleted. +- Opt‑in per domain. When on and a provider is connected, Loxy creates the A/AAAA records for the FQDN pointing at the required addresses, **updates** them when the node addresses change, and **removes** them when the domain is detached or deleted or its project is deleted. - Loxy only touches records it created (their provider IDs are tracked in `domains.managed_record_ids`); pre‑existing records with the same name are reported as a conflict and never overwritten without explicit confirmation. - Sync runs through the reconciler's event stream (debounced) and is retried with backoff; failures surface as `domain.sync.failed` events. @@ -658,25 +746,12 @@ Loxy helps operators point names at their frontends and keeps DNS in sync, **wit - List zones from all connected providers; open a zone to list and edit records: types **A, AAAA, CNAME, TXT, MX, CAA, SRV, NS**; columns type, name (relative, `@` for apex), value, TTL; add / edit / delete with confirm. - Server‑side validation per type: A/AAAA parsed with `net/netip`; CNAME/NS/MX targets are RFC 1123 hostnames; MX priority 0–65535; SRV `priority weight port target`; CAA `flags tag "value"` with tag ∈ `issue|issuewild|iodef`; TXT ≤ 255 chars per string (longer values are split); TTL 60–86400; CNAME cannot coexist with other records of the same name. Records managed by Loxy are marked and edits to them warn. -**API** (§12.2): `/domains`, `/domains/{id}/actions/{verify,issue_certificate}`, `/dns_providers`, `/dns_providers/{id}/zones`, `/dns_providers/{id}/zones/{zone}/records`. - **Acceptance** -- Adding a domain whose A record points to the frontend's bind address shows "Resolves correctly" within one check; a wrong A record shows "Wrong IP" with the observed address; for a `*` frontend the node addresses are the expected values. +- Adding a domain whose A record points to the node shows "Resolves correctly" within one check; a wrong A record shows "Wrong IP" with the observed address. +- A verified https domain gets a certificate (Pebble in CI) and `http://fqdn` answers 301 → `https://fqdn`, which reaches the project's targets, within 60 s, with at most two reloads (attach + issued). +- Two projects with different domains on the shared :443 each receive only their own traffic; an unknown Host gets 404. - With a DNS provider connected, a managed DNS‑01 certificate for `*.example.com` is issued (Pebble + a libdns test double in CI) without entering credentials again. -- With auto‑records on, changing the frontend's bind IPv4 updates the A record within 10 s; deleting the domain removes exactly the records Loxy created. - -### 8.10 System (`/system`) -- **Node** card (all‑in‑one shows the local node): status, HAProxy version and feature flags (`haproxy -vv`: QUIC, PROMEX, OpenSSL version), loxy version, HAProxy uptime, reload count and last reload result/duration, current connections, CPU/memory of both containers; warning when the bundled `haproxy -v` differs from the running one. -- **Node addresses**: list of the node's IPv4/IPv6 addresses with an optional label (`POST/DELETE /nodes/{id}/ips`); detected interface addresses are offered for one‑click adding. Used as bind‑address suggestions (§8.3) and as DNS required records for `*` binds (§8.9). Changing them never changes the HAProxy config by itself. -- **Config revisions**: list (revision, kind runtime/reload/rollback/bootstrap, result, duration, author, time, pinned), diff between any two (`CodeView`), "Roll back to this revision" (break‑glass swap + reload of the on‑disk revision, see ADR 0004 §7; the next mutation re‑applies desired state, which the UI states), pin/unpin, HAProxy output of failed applies. -- Phase 3 adds enrollment, multiple nodes and HA groups (§15). - -### 8.11 Activity (`/activity`) -- **Events** feed (server/backend health transitions, apply/reload results, cert issued/failed/expiring, domain status changes, node offline) with filters by frontend/backend/kind/severity. -- **Audit log** (who, what, when, before/after diff, IP, token) — read‑only, exportable CSV. - -### 8.12 Settings (`/settings/*`) -Users & roles · API tokens · ACME (account email, directory: Let's Encrypt production / staging / custom, EAB) · Notification channels & alert rules (event kinds, label filter on frontend/backend labels, min severity, cooldown, "Send test") · Two‑factor authentication (own TOTP; enforcement per project) · Panel (domain, port, allowlist, session lifetime) · System (hard‑stop‑after, drain timeout, reload min interval, metrics retention, live‑log sampling) · Backup (schedule, retention, optional S3‑compatible target). +- With auto‑records on, changing a node IPv4 updates the A record within 10 s; deleting the domain removes exactly the records Loxy created. --- @@ -684,7 +759,7 @@ Users & roles · API tokens · ACME (account email, directory: Let's Encrypt pro Source: `show stat` server rows (`status`, `check_status`, `check_code`, `check_duration`, `check_desc`/`last_chk`, `lastchg`). -| HAProxy server `status` | loxy server status | +| HAProxy server `status` | loxy check status | |---|---| | `UP` | healthy | | `UP n/m` (going down) | healthy (transitioning) | @@ -695,45 +770,36 @@ Source: `show stat` server rows (`status`, `check_status`, `check_code`, `check_ | `DRAIN` | draining | | `no check` | unknown | -Aggregation: health is tracked **per resolved server** (one check each, since a server belongs to one backend). Backend status = `Healthy` if all active (non‑maintenance) servers are healthy, `Degraded` if some are unhealthy but ≥ 1 is healthy, `Unhealthy` if none is healthy, `Unknown` without servers or checks. A frontend's health summary aggregates the backends it uses (default + routes). Transitions produce `server.health.changed` / `backend.health.changed` events (with check reason) used by notifications; debounce flapping with the alert‑rule cooldown. Optionally enable `option log-health-checks` and parse health transitions from the UDP log stream for sub‑second detection. +Aggregation: health is checked **per resolved target per service** (one server line per `be_`). A resolved target is `healthy` if healthy on all services, `degraded` if healthy on some, `unhealthy` if on none, `unknown` without checks. A service's health counts its servers; a project is `Healthy` if all active (non‑maintenance) checks are healthy, `Degraded` if some are unhealthy but every service has ≥ 1 healthy target, `Unhealthy` if any service has no healthy target, `Unknown` without targets or services. Transitions produce `target.health.changed` events carrying the `project_id` (stored as `events.balancer_id`), service and check reason, used by notifications; debounce flapping with the alert‑rule cooldown. Optionally enable `option log-health-checks` and parse health transitions from the UDP log stream for sub‑second detection. --- ## 10. Metrics pipeline ### 10.1 Collection -Every 1 s per node: `show stat` (CSV; parse the header line to map columns by name, never by position) and `show info`. Frontend rows (`fe_`, plus `fe__rd` summed into its frontend) → frontend metrics; backend rows (`be_`) → backend metrics; server rows → per‑server metrics and health. One `show stat` call returns everything; it is cheap even with thousands of servers. - -### 10.2 Derived series +Every 1 s per node: `show stat` (CSV; parse the header line to map columns by name, never by position) and `show info`. `be_` rows carry all traffic of a service (from its own port **and** from domains through `sys_http`/`sys_https`) → service metrics; `fe_` rows add frontend‑only counters (denied/errored requests on the port); server rows → per‑target metrics and health. Project metrics are a separate scope `pr`: the sum of the project's service backends (`be_`), so domain traffic is included. The node total (scope `fe`, id 0) also counts `sys_http`/`sys_https`. One `show stat` call returns everything; it is cheap even with thousands of servers. -**Per frontend** +### 10.2 Derived series (per service backend; project scope `pr` = sum over its services) | Series | Derivation | |---|---| -| open_connections | frontend `scur` | -| connections_per_second | Δ frontend `conn_tot` / Δt (fallback `conn_rate`) | -| requests_per_second | Δ frontend `req_tot` / Δt (http frontends only) | -| bandwidth_in / bandwidth_out | Δ frontend `bin` / Δt, Δ `bout` / Δt (bytes/s) | -| http_1xx…5xx per second | Δ frontend `hrsp_*` / Δt | -| denied / errors per second | Δ frontend `dreq` + `ereq` / Δt | - -**Per backend (and per server)** - -| Series | Derivation | -|---|---| -| sessions | backend/server `scur` | -| queue | backend `qcur` (server `qcur`) | -| response_time_ms | backend `rtime` (moving average HAProxy maintains), `ttime` | -| http_1xx…5xx per second | Δ backend `hrsp_*` / Δt | -| errors per second | Δ backend `econ` + `eresp` / Δt; retries `wretr`, redispatches `wredis` | -| bandwidth_in / bandwidth_out | Δ backend `bin` / `bout` / Δt | +| open_connections | `be_` `scur` | +| connections_per_second | Δ `be_` `stot` / Δt | +| requests_per_second | Δ `be_` `req_tot` / Δt (http/https only) | +| bandwidth_in / bandwidth_out | Δ `be_` `bin` / Δt, Δ `bout` / Δt (bytes/s) | +| sessions | `be_` / server `scur` | +| queue | `be_` `qcur` (server `qcur`) | +| response_time_ms | `be_` `rtime` (moving average HAProxy maintains), `ttime` | +| http_1xx…5xx per second | Δ `be_` `hrsp_*` / Δt | +| errors per second | Δ `be_` `econ` + `eresp` / Δt; retries `wretr`, redispatches `wredis` | +| denied per second | Δ `fe_` `dreq` + `ereq` / Δt | ### 10.3 Counter resets New workers start counters at zero. Detect a new worker via `show info` `Pid`/`Uptime_sec` change or any counter decreasing; for that sample use `delta = current` (not negative, not a spike). Connections still served by old workers during drain are not counted — accepted. HAProxy 3.3+ has an experimental `shm-stats-file` that can preserve counters across reloads; MAY be enabled behind a setting after a **VERIFY** test. ### 10.4 Storage and retention -- In memory: ring buffer of 1 s points for the last 60 min per frontend and per backend (≈ 3 600 × ~12 series × 8 B ≈ 350 KB per object at worst; keep per‑server series only for the last 15 min). -- SQLite rollups written in one transaction per minute: `metrics_1m` (scope `frontend|backend`, scope_id), with 5 m / 1 h resolutions aggregated on read or written by a later rollup job (ADR 0001 §5). Each rollup stores avg and max where meaningful. +- In memory: ring buffer of 1 s points for the last 60 min per service and per project (≈ 3 600 × ~12 series × 8 B ≈ 350 KB per object at worst; keep per‑target series only for the last 15 min). +- SQLite rollups written in one transaction per minute: `metrics_1m` (scope `fe|be|pr`, scope_id), with 5 m / 1 h resolutions aggregated on read or written by a later rollup job (ADR 0001 §5). Each rollup stores avg and max where meaningful. - Range → resolution: Live/30 min → 1 s (downsample to ≤ 1 800 points), 1 h → 10 s (from memory), 6 h/24 h → 1 m, 7 d → 5 m, 30 d → 1 h. ### 10.5 API format @@ -744,10 +810,10 @@ Columnar, uPlot‑native: "series": { "open_connections": [2210, 2215, ...], "connections_per_second": [41, 18, ...], "requests_per_second": [8820, 8790, ...], "bandwidth.in": [4.1e6, ...], "bandwidth.out": [4.3e6, ...] } } ``` -History: `GET /api/v1/frontends/{id}/metrics?type=…` and `GET /api/v1/backends/{id}/metrics?type=sessions,queue,response_time_ms,…`. Live: `GET /api/v1/frontends/{id}/metrics/stream` (SSE) pushes one compact point per second. +History: `GET /api/v1/projects/{id}/metrics?type=…` returns the project (`pr`) series; `&service_id=` returns that service's backend (`be_`) series. Live: `GET /api/v1/projects/{id}/metrics/stream` (SSE) pushes one compact point per second. ### 10.6 Optional exports -- `sys_stats` frontend on `127.0.0.1:8405`: HAProxy's built‑in Prometheus exporter at `/metrics` (render only if `haproxy -vv` shows PROMEX) and the native stats page at `/stats`. Because names are `fe_`/`be_`, loxy's own `/metrics` exposes an info series mapping ids to display names and labels. +- `sys_stats` frontend on `127.0.0.1:8405`: HAProxy's built‑in Prometheus exporter at `/metrics` (render only if `haproxy -vv` shows PROMEX) and the native stats page at `/stats`. Because names are `fe_`/`be_`, loxy's own `/metrics` exposes an info series mapping service ids to project ids, names and labels. - loxy's own `/metrics` (controller health: reload durations, apply errors, poll latency, ACME results). --- @@ -755,14 +821,14 @@ History: `GET /api/v1/frontends/{id}/metrics?type=…` and `GET /api/v1/backends ## 11. Certificates and ACME ### 11.1 Storage -Certificates are project‑level and reusable across frontends. Private keys are encrypted at rest (AES‑256‑GCM, key from `LOXY_SECRET_KEY`). The rendered revision necessarily contains plaintext PEMs (HAProxy reads them); the config volume is mode 0750, files 0640, group `loxy` (§16). +Certificates belong to the single tenancy project and are reusable across all load‑balancer projects, services and domains. Private keys are encrypted at rest (AES‑256‑GCM, key from `LOXY_SECRET_KEY`). The rendered revision necessarily contains plaintext PEMs (HAProxy reads them); the config volume is mode 0750, files 0640, group `loxy` (§16). ### 11.2 Managed certificates with lego - One ACME account per directory URL (`acme_accounts`), created lazily with the configured email (and EAB when required by the CA). -- **HTTP‑01**: implement lego's `challenge.Provider` (`Present(ctx, domain, token, keyAuth)` / `CleanUp(...)`). `Present` pushes `add map virt@acme_http01 ` to **every node** that hosts a frontend serving that domain, waits for acknowledgement, then returns; `CleanUp` deletes the entry. HAProxy answers the challenge itself with the `http-request return … map(virt@acme_http01)` rule in `fe__rd` / `sys_acme_fe` (§7.4) — no extra hop, no port juggling. -- **DNS‑01**: a lego `challenge.Provider` implemented on top of the libdns provider of a connected DNS provider account (§8.9), so DNS credentials are stored once (encrypted) and shared by record management and ACME; required for wildcard domains. +- **HTTP‑01**: implement lego's `challenge.Provider` (`Present(ctx, domain, token, keyAuth)` / `CleanUp(...)`). `Present` pushes `add map virt@acme_http01 ` to **every node** that serves the domain, waits for acknowledgement, then returns; `CleanUp` deletes the entry. HAProxy answers the challenge itself with the `http-request return … map(virt@acme_http01)` rule in `sys_http` (§7.4) — no extra hop, no port juggling. The virtual map only exists once a loaded config references it, so issuance waits until `sys_http` is live. +- **DNS‑01**: a lego `challenge.Provider` implemented on top of the libdns provider of a connected DNS provider account (§8.12), so DNS credentials are stored once (encrypted) and shared by record management and ACME; required for wildcard domains. - Key type default ECDSA P‑256; RSA optional. -- Issuance runs as an async action; failures record the lego error and retry with backoff (1 m, 5 m, 30 m, 2 h, then every 6 h) without blocking the frontend (placeholder cert keeps serving). +- Issuance runs as an async action; failures record the lego error and retry with backoff (1 m, 5 m, 30 m, 2 h, then every 6 h) without blocking the service (placeholder cert keeps serving) or the domain (stays on http). - **Renewal** scheduler: honour ACME Renewal Information (ARI) when the CA offers it; otherwise renew at 2/3 of the lifetime (≈ 30 days before expiry for 90‑day certs). Jitter start times. On success: new revision with the new PEM + runtime `set ssl cert` / `commit ssl cert` (no reload). - Use Let's Encrypt **staging** automatically in dev and CI (Pebble in tests). - HAProxy's native ACME (`acme` section, 3.2+) MAY replace lego later once it's no longer experimental; keep the `CertIssuer` interface abstract so that's a swap, not a rewrite. @@ -777,11 +843,11 @@ Same runtime hot‑swap path when replaced. Expiry warnings at 21/7/1 days. The contract is `api/openapi.yaml` (OpenAPI 3.0.3); this section summarises it. When they disagree, fix one of them in the same PR. ### 12.1 Conventions -- Base path `/api/v1`, JSON, `snake_case`, plural resource names (`frontends`, `backends`, `servers`, `hosts`, `certificates`, `domains`, `actions`, `metrics`). +- Base path `/api/v1`, JSON, `snake_case`, plural resource names (`projects`, `services`, `targets`, `hosts`, `certificates`, `domains`, `actions`, `metrics`). - Auth: session cookie (UI) or `Authorization: Bearer loxy__` (API tokens). - Pagination: `?page=&per_page=` (max 100) with `meta.pagination`. Filtering: `label_selector=`, `name=`. Sorting: `sort=name:asc`. - Optimistic concurrency: every mutable resource has `version`; PUT/PATCH honour `If-Match` (ADR 0001 §6); mismatch → `409 conflict`. -- Errors: `{ "error": { "code": "invalid_input", "message": "...", "details": { "fields": [{ "name": "binds[0].port", "messages": ["already used by frontend web (#3)"] }] } } }`. Codes: `invalid_input`, `conflict` (incl. in‑use deletes), `protected`, `not_found`, `forbidden`, `unauthorized`, `rate_limited`, `apply_failed`. +- Errors: `{ "error": { "code": "invalid_input", "message": "...", "details": { "fields": [{ "name": "service.listen_port", "messages": ["port 20001 is used by project shop (#1)"] }] } } }`. Codes: `invalid_input`, `conflict` (incl. port/domain in use and in‑use deletes), `protected`, `not_found`, `forbidden`, `unauthorized`, `rate_limited`, `apply_failed`. - Idempotency: POSTs accept `Idempotency-Key`. ### 12.2 Endpoints @@ -789,25 +855,23 @@ The contract is `api/openapi.yaml` (OpenAPI 3.0.3); this section summarises it. | Method & path | Purpose | |---|---| | `POST /auth/login`, `POST /auth/logout`, `GET /auth/me`, `POST /auth/password`, `POST /auth/totp/{setup,enable,disable}` | Session auth, password change, 2FA | -| `GET /overview` | Dashboard counts: frontends, backends, servers, certificates, node, traffic | -| `GET/POST /frontends`, `GET/PUT/PATCH/DELETE /frontends/{id}` | Frontend CRUD (PUT: full object incl. binds, certificates, routes; PATCH: name, description, labels, enabled, maintenance) | -| `GET /frontends/{id}/config` | Rendered HAProxy sections for this frontend and the backends it uses (read‑only) | -| `GET /frontends/{id}/metrics?type=…&start=&end=&step=`, `GET /frontends/{id}/metrics/stream` | History (§10.5), SSE live points | -| `GET /frontends/{id}/logs/stream` | SSE live logs (when enabled) | -| `GET/POST /backends`, `GET/PUT/DELETE /backends/{id}` | Backend CRUD (DELETE → `409` while in use) | -| `GET /backends/{id}/status` | Health: summary + resolved servers with check details | -| `GET /backends/{id}/metrics?type=…` | Backend history | -| `GET/POST /backends/{id}/servers`, `PATCH/DELETE /backends/{id}/servers/{sid}` | Servers (POST takes a list; PATCH: weight, admin_state, backup; DELETE is graceful) | +| `GET /overview` | Dashboard counts: projects, targets, certificates, node, traffic | +| `GET/POST /projects`, `GET/PATCH/DELETE /projects/{id}` | Project CRUD. POST takes `name` plus optional `targets[]` and `service` (default: http, automatic port, destination 80) in one transaction; PATCH: name, description, algorithm, labels, maintenance | +| `GET /projects/{id}/config` | Rendered HAProxy sections of this project (read‑only, text) | +| `GET /projects/{id}/metrics?type=…&start=&end=&step=&service_id=`, `GET /projects/{id}/metrics/stream` | History (§10.5), SSE live points | +| `GET /projects/{id}/logs/stream` | SSE live logs (when enabled) | +| `GET/POST /projects/{id}/services`, `PUT/DELETE /projects/{id}/services/{sid}` | Services (omit `listen_port` for automatic allocation; DELETE → `409` when it is the last http/https service with domains) | +| `GET/POST /projects/{id}/targets`, `PATCH/DELETE /projects/{id}/targets/{tid}` | Targets (POST takes a list; PATCH: weight, admin_state, backup; DELETE is graceful) with resolved slots and per‑service checks | | `GET/POST /hosts`, `PATCH/DELETE /hosts/{id}` | Inventory | | `GET /label_selector/preview?selector=` | Hosts matched by a selector | | `GET/POST /certificates`, `GET/PATCH/DELETE /certificates/{id}`, `POST /certificates/{id}/actions/renew` | Certificates | -| `GET/POST /domains`, `GET/PATCH/DELETE /domains/{id}`, `POST /domains/{id}/actions/verify`, `POST /domains/{id}/actions/issue_certificate` | Domains (§8.9) | -| `GET/POST /dns_providers`, `DELETE /dns_providers/{id}`, `GET /dns_providers/{id}/zones`, `GET/POST/DELETE /dns_providers/{id}/zones/{zone}/records` | DNS providers (incl. available kinds) and zone editor (§8.9; records Phase 2) | +| `GET/POST /domains`, `GET/PATCH/DELETE /domains/{id}`, `POST /domains/{id}/actions/verify`, `POST /domains/{id}/actions/issue_certificate` | Domains (`project_id`, `service_id`, `https`; filter `?project_id=`; §8.11–8.12) | +| `GET/POST /dns_providers`, `DELETE /dns_providers/{id}`, `GET /dns_providers/{id}/zones`, `GET/POST/DELETE /dns_providers/{id}/zones/{zone}/records` | DNS providers (incl. available kinds) and zone editor (§8.12; records Phase 2) | | `GET /nodes`, `PATCH /nodes/{id}`, `POST /nodes/{id}/ips`, `DELETE /nodes/{id}/ips/{ipid}` | Node info and node addresses | | `GET /nodes/{id}/revisions`, `GET /nodes/{id}/revisions/{rev}/diff?against=`, `POST /nodes/{id}/revisions/{rev}/actions/rollback` | Config revisions | | `POST /apply/retry` | Re‑run the reconciler after an apply error | | `GET /actions`, `GET /actions/{id}` | Async action status | -| `GET /events` (SSE), `GET /events/history` | Event stream & history | +| `GET /events` (SSE), `GET /events/history` | Event stream & history (filter `?project_id=`) | | `GET /audit` | Audit log | | `GET/POST/DELETE /users`, `/api_tokens`, `/notification_channels` (+ `POST …/{id}/actions/test`), `/alert_rules`, `GET/PATCH /settings` | Admin | | `GET /healthz`, `GET /readyz`, `GET /metrics` | Ops (no auth on healthz; readyz checks DB + HAProxy socket) | @@ -815,34 +879,55 @@ The contract is `api/openapi.yaml` (OpenAPI 3.0.3); this section summarises it. ### 12.3 Example resources ```json { - "frontend": { - "id": 3, "name": "web", "description": "public site", "version": 14, - "mode": "http", "tls": true, "http_redirect": true, "certificates": [12], - "binds": [ { "address": "*", "port": 443 } ], - "default_backend_id": 4, - "routes": [ - { "id": 1, "hosts": ["app.example.com"], "path_prefix": "/api", "backend_id": 5 }, - { "id": 2, "hosts": ["*.static.example.com"], "path_prefix": "", "backend_id": 6 } + "project": { + "id": 1, "name": "shop", "description": "web shop", "version": 9, + "algorithm": "roundrobin", "maintenance": false, "labels": { "env": "prod" }, + "status": "ready", "last_error": null, "created_at": 1760412300000, + "endpoints": [ + { "kind": "port", "url": "http://203.0.113.10:20001", "service_id": 1 }, + { "kind": "port", "url": "https://203.0.113.10:20002", "service_id": 2 }, + { "kind": "domain", "url": "https://shop.example.com", "service_id": 1, "domain_id": 4 } ], - "timeout_client_s": 50, "maxconn": 10000, "forwarded_headers": true, - "enabled": true, "maintenance": false, "labels": { "env": "prod" }, - "status": "active", "last_error": null, "created_at": 1760412300000, - "stats": { "open_connections": 2210, "requests_per_second": 8820, "bytes_out_per_second": 4.3e6 }, - "health": { "status": "healthy", "healthy": 4, "total": 4, "messages": [] } + "counts": { "targets": 3, "services": 2, "domains": 2 }, + "health": { "status": "healthy", "healthy": 4, "total": 4, "messages": [] }, + "stats": { "open_connections": 2210, "requests_per_second": 8820, "bytes_out_per_second": 4.3e6 } } } ``` ```json { - "backend": { - "id": 4, "name": "app", "version": 7, "mode": "http", "algorithm": "roundrobin", "port": 9000, - "health_check": { "protocol": "http", "port": 9000, "interval_s": 15, "timeout_s": 13, "retries": 3, - "http": { "domain": "app.example.com", "path": "/health", "response": "OK", "status_codes": ["2??", "3??"], "tls_verify": false } }, + "service": { + "id": 1, "project_id": 1, "protocol": "http", "listen_port": 20001, "destination_port": 8080, + "proxy_protocol": "none", "timeout_s": 60, "certificates": [], "sticky_sessions": false, "cookie_name": "LOXYSTICKY", "cookie_lifetime_s": 300, - "proxy_protocol": "none", "timeout_server_s": 50, "labels": { "env": "prod" }, - "used_by": [3], "servers_count": 3, - "health": { "status": "healthy", "healthy": 3, "total": 3, "messages": [] }, - "stats": { "sessions": 180, "queue": 0, "response_time_ms": 12 } + "health_check": { "protocol": "http", "port": null, "interval_s": 15, "timeout_s": 10, "retries": 3, + "http": { "domain": "shop.example.com", "path": "/health", "response": "OK", "status_codes": ["2??", "3??"], "tls_verify": false } }, + "health": { "status": "healthy", "healthy": 4, "total": 4, "messages": [] }, + "domains": ["shop.example.com", "beta.example.com"] + } +} +``` +```json +{ + "target": { + "id": 102, "project_id": 1, "type": "label_selector", "label_selector": "role=web", + "port": null, "use_private_ip": false, "weight": 100, "backup": false, "admin_state": "ready", "removing": false, + "resolved": [ + { "slot_id": 102, "host_id": 7, "name": "web-1", "address": "10.0.0.12", "status": "healthy", + "checks": [ { "service_id": 1, "status": "healthy", "check": "L7OK/200 in 3 ms" }, + { "service_id": 2, "status": "healthy", "check": "L4OK in 1 ms" } ] } + ] + } +} +``` +```json +{ + "domain": { + "id": 4, "fqdn": "shop.example.com", "project_id": 1, "service_id": null, "https": true, + "dns_provider_id": null, "auto_records": false, "status": "ok", + "required_records": [ { "type": "A", "name": "shop.example.com", "value": "203.0.113.10", "ttl": 300 } ], + "observed": { "a": ["203.0.113.10"], "aaaa": [] }, "certificate_id": 21, + "url": "https://shop.example.com" } } ``` @@ -851,7 +936,7 @@ The contract is `api/openapi.yaml` (OpenAPI 3.0.3); this section summarises it. Long or multi‑step operations return `202` with `{ "action": { "id", "command", "status": "running|success|error", "progress": 0-100, "resources": [...], "error": null, "started", "finished" } }`. The UI follows actions over the `/events` SSE stream (no polling). ### 12.5 SSE event kinds -`frontend.updated`, `backend.updated`, `apply.started|succeeded|failed`, `server.health.changed`, `backend.health.changed`, `certificate.updated`, `domain.status.changed`, `domain.sync.failed`, `action.updated`, `node.status.changed`, `metrics.tick` (only on metric streams). Reconnect with `Last-Event-ID`; server keeps a 2‑minute replay buffer. +`project.updated`, `apply.started|succeeded|failed`, `target.health.changed` (with `project_id`), `certificate.updated`, `domain.status.changed`, `domain.sync.failed`, `action.updated`, `node.status.changed`, `metrics.tick` (only on metric streams). Reconnect with `Last-Event-ID`; server keeps a 2‑minute replay buffer. ### 12.6 Codegen `make gen` runs `oapi-codegen` (Go server interfaces + models) and `openapi-typescript` (web types). CI fails if generated files are stale. @@ -861,10 +946,11 @@ Long or multi‑step operations return `202` with `{ "action": { "id", "command" ## 13. Frontend implementation - SvelteKit SPA (`adapter-static`, fallback `index.html`, `ssr = false`), served by loxy from `go:embed` with precompressed `.br`/`.gz` assets, `Cache-Control: public, max-age=31536000, immutable` for hashed files and `no-cache` for `index.html`. -- Routes mirror the tabs so every tab is linkable (`/frontends/3/routing`, `/backends/4/servers`). +- Routes mirror the tabs so every tab is linkable (`/projects/1/targets`, `/projects/1/domains`). - Data: one session‑scoped snapshot store fed by REST on navigation and kept fresh by the single `/events` SSE connection; mutations are optimistic where safe (labels, names) and action‑driven otherwise. -- Charts: `Chart.svelte` wraps uPlot, dynamically imported only on the Graphs tabs (sparklines on the Dashboard and lists use a 1 KB SVG polyline component, not uPlot). -- Components: `StatBar`, `StatCard`, `Donut`, `HealthBadge`, `Tabs`, `Table` (virtualised above 200 rows), `Modal`, `Dropdown`, `Toast`, `LabelChips`, `LabelSelectorInput`, `PortField`, `BindsEditor`, `RouteTable` (drag to reorder), `BackendSelect`, `CertPicker`, `HostPicker`, `CopyText`, `ConfirmTyped`, `CodeView` (read‑only config/diff viewer, no heavy editor). +- Charts: `Chart.svelte` wraps uPlot, dynamically imported only on the Graphs tab (sparklines on the Dashboard and lists use a 1 KB SVG polyline component, not uPlot). +- Components: `StatBar`, `StatCard`, `Donut`, `HealthBadge`, `Tabs`, `Table` (virtualised above 200 rows), `Modal`, `Dropdown`, `Toast`, `LabelChips`, `LabelSelectorInput`, `PortField` (automatic / custom), `EndpointList`, `TargetAdder`, `ServiceForm`, `DomainForm`, `RequiredRecords`, `CertPicker`, `HostPicker`, `CopyText`, `ConfirmTyped`, `CodeView` (read‑only config/diff viewer, no heavy editor). +- Wording: the UI says project, target, service, domain, port — never frontend/backend/server (except in the read‑only Config view, which shows HAProxy text). - Design tokens in `tokens.css`; dark theme default, light theme via `prefers-color-scheme` + toggle; accent colour configurable in Settings: ```css :root { --bg:#141414; --surface:#1f1f1f; --surface-2:#272727; --border:#343434; @@ -884,11 +970,11 @@ Long or multi‑step operations return `202` with `{ "action": { "id", "command" - First start: create the owner account with a random password printed once to the logs (`loxy admin reset-password` regenerates it). - Passwords: argon2id (memory 64 MB, iterations 3, parallelism 2), minimum length 12. - Sessions: 256‑bit random id, stored hashed; cookie `__Host-loxy_session; Secure; HttpOnly; SameSite=Strict; Path=/`; idle timeout 12 h, absolute 7 d; rotate on login. -- Optional TOTP 2FA, enforceable per project. +- Optional TOTP 2FA, enforceable for all users. - Login rate limit: 5 attempts / 15 min per IP + per account, exponential backoff. ### 14.2 Authorization -Roles: **owner** (everything incl. users, secrets), **admin** (all resources, settings except owner transfer), **operator** (frontends, backends, servers, hosts, certificates, domains; no users/settings), **viewer** (read‑only). API tokens carry a role and optional expiry; shown once at creation; stored as SHA‑256 hash with a visible prefix. +Roles: **owner** (everything incl. users, secrets), **admin** (all resources, settings except owner transfer), **operator** (projects, services, targets, hosts, certificates, domains; no users/settings), **viewer** (read‑only). API tokens carry a role and optional expiry; shown once at creation; stored as SHA‑256 hash with a visible prefix. ### 14.3 Web hardening CSRF: SameSite=Strict cookies **and** a required `X-LOXY-CSRF` header token on mutating requests. Headers: strict CSP (`default-src 'self'`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, `Frame-Options: DENY`, HSTS on the panel. Request body limit 1 MB (5 MB for cert upload). @@ -901,17 +987,16 @@ User values end up inside `haproxy.cfg`. **Never** interpolate raw text. Validat | Resource display names | 1–64 chars, any printable; **never rendered** into config | | Label keys | `^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?[a-zA-Z0-9]([-a-zA-Z0-9_.]{0,61}[a-zA-Z0-9])?$` | | Label values | `^([a-zA-Z0-9]([-a-zA-Z0-9_.]{0,61}[a-zA-Z0-9])?)?$` | -| Domains (health check Host, cert SANs, route hosts) | RFC 1123 hostname, lowercase, ≤ 253; `*.` prefix only for DNS‑01 certificates and route hosts | -| Health check path, route path prefix | `^/[A-Za-z0-9._~!&()*+,;=:@%/?-]{0,254}$` (no spaces, quotes, `#`, `$`, `\`) | +| Domains (domain FQDNs, health check Host, cert SANs) | RFC 1123 hostname, lowercase, ≤ 253; `*.` prefix only for domains/certificates using DNS‑01 | +| Health check path | `^/[A-Za-z0-9._~!&()*+,;=:@%/?-]{0,254}$` (no spaces, quotes, `#`, `$`, `\`) | | Health check response string | printable ASCII except `'"\#$`, 1–128 chars | | Cookie name | `^[A-Za-z0-9_-]{1,64}$` | -| Ports | integers 1–65535 | -| Bind address | `*` or an IP literal parsed with `net/netip` (zone ids rejected) | -| IPs / CIDRs | parsed with `net/netip`; servers reject loopback, link‑local, multicast, unspecified | +| Ports | integers 1–65535; listen ports additionally not reserved (§8) | +| IPs / CIDRs, target `ip[:port]` | parsed with `net/netip` (`netip.ParseAddrPort` for the port form, IPv6 as `[addr]:port`); zone ids rejected; targets reject loopback (unless dev), link‑local, multicast, unspecified | | Status code items | `^[1-5][0-9?]{2}$`, ≤ 20 items | | Label selector | parsed by our own small parser into an AST; never passed to HAProxy | -Tests: a Go fuzz target generates random valid models and asserts the rendered config (a) passes `haproxy -c`, (b) contains no line not produced by a known template block, (c) never contains a newline inside a value. +Tests: a Go fuzz target generates random valid models (projects, services, targets, domains) and asserts the rendered config (a) passes `haproxy -c`, (b) contains no line not produced by a known template block, (c) never contains a newline inside a value. ### 14.5 Secrets at rest `LOXY_SECRET_KEY` (32 random bytes, base64) in `.env` (mode 0600). Encrypts private keys, DNS credentials, TOTP secrets, notification credentials, ACME account keys. Key rotation command: `loxy admin rotate-secret`. @@ -931,13 +1016,13 @@ loxy listens on `127.0.0.1:9090` only. Public access goes through the `sys_panel ### 15.1 Panel ↔ agent - `loxy agent` runs next to HAProxy on each HAProxy node (same compose as all‑in‑one minus the panel). It dials **out** to the panel over HTTPS WebSocket, so nodes need no inbound management port. - Enrollment: panel creates a node and shows a one‑time join token; `loxy agent join --panel https://panel.example.com --token …` stores a long‑lived node credential and pins the panel's TLS certificate fingerprint. -- Frontends are placed on a node or an HA group; backends are rendered on every node that has a frontend using them. +- Projects are placed on a node or an HA group; their services, targets and domains are rendered on those nodes. Listen ports stay unique installation‑wide (simple, and lets a project move between nodes). - Messages: panel → agent `DesiredState{version, haproxy config inputs, certs, acme_tokens}`; agent → panel `Applied{version, result, output}`, `StatsBatch` (1 s samples, sent every 1 s), `HealthDelta`, `NodeInfo`. - The agent persists the last applied desired state locally and keeps running autonomously if the panel is down (HAProxy keeps serving; ACME HTTP‑01 tokens pushed before disconnect stay valid). ### 15.2 HA pairs (active/passive, generic) -- Both nodes of an HA group receive identical desired state for the group's frontends; both may bind the group's **virtual IPs** at all times (`net.ipv4.ip_nonlocal_bind=1` and `net.ipv6.ip_nonlocal_bind=1` on the host), so failover needs no reload. -- Leader election and address movement: **keepalived** VRRP (in **unicast** mode between the nodes' private addresses by default; multicast optional) owns the virtual IP(s) and moves them to the backup node; a `track_script` checks HAProxy (`/healthz` of the agent + a TCP probe of a frontend port). `notify_*` scripts report state changes to loxy (`loxy ha notify `), which records events. No provider API is called; environments that need provider‑specific address routing can hook their own script into `notify_master`. +- Both nodes of an HA group receive identical desired state for the group's projects; both may bind the group's **virtual IPs** at all times (`net.ipv4.ip_nonlocal_bind=1` and `net.ipv6.ip_nonlocal_bind=1` on the host), so failover needs no reload. Domains of projects on an HA group require the virtual IPs as their DNS records. +- Leader election and address movement: **keepalived** VRRP (in **unicast** mode between the nodes' private addresses by default; multicast optional) owns the virtual IP(s) and moves them to the backup node; a `track_script` checks HAProxy (`/healthz` of the agent + a TCP probe of a service port). `notify_*` scripts report state changes to loxy (`loxy ha notify `), which records events. No provider API is called; environments that need provider‑specific address routing can hook their own script into `notify_master`. - Loxy renders the keepalived config from the HA group (virtual IPs, peer addresses, priorities, auth) into `deploy/keepalived/` templates; keepalived runs as its own container (`network_mode: host`, `NET_ADMIN`) — an ADR is required before adding it. - Stick tables (sticky/rate‑limit state) are synced with an HAProxy `peers` section between the two nodes. - UI: HA group page on System shows active/backup, virtual IPs, last failover, "Fail over now" button (lowers the local VRRP priority temporarily). @@ -961,7 +1046,9 @@ net.ipv4.tcp_tw_reuse = 1 fs.file-max = 2097152 fs.nr_open = 2097152 ``` -(If `nf_conntrack` is loaded, raise `net.netfilter.nf_conntrack_max` accordingly or exclude frontend ports from tracking.) +(If `nf_conntrack` is loaded, raise `net.netfilter.nf_conntrack_max` accordingly or exclude service ports from tracking.) + +Note: the ephemeral port range (`1024 65000`) overlaps the automatic service ports (20000+). A listening socket always wins over later outgoing connections, but an outgoing connection that already holds a port blocks a new listener on it until it closes; the API reports the bind error from `haproxy -c`/reload as `apply_failed` with the HAProxy text. Operators who want strict separation MAY reserve the automatic range with `net.ipv4.ip_local_reserved_ports = 20000-29999`. ### 16.2 Dockerfile (`deploy/docker/Dockerfile`) ```dockerfile @@ -1050,8 +1137,8 @@ services: | `HAPROXY_VERSION` | `3.4.6` | Pinned HAProxy image version (same for both containers) | | `LOXY_VERSION` | — | loxy release | | `LOXY_SECRET_KEY` | generated by installer | 32 bytes base64, encrypts secrets at rest | -| `LOXY_LISTEN` | `127.0.0.1:9090` | Internal HTTP listener | -| `LOXY_PANEL_PORT` | `8443` | Port of the `sys_panel` frontend | +| `LOXY_LISTEN` | `127.0.0.1:9090` | Internal HTTP listener (its port is reserved, §8) | +| `LOXY_PANEL_PORT` | `8443` | Port of the `sys_panel` frontend (must not be 80/443) | | `LOXY_PANEL_DOMAIN` | empty | If set, managed cert for the panel | | `LOXY_PANEL_ALLOW` | empty | Comma‑separated CIDRs allowed to reach the panel | | `LOXY_ACME_EMAIL` | empty | ACME account contact | @@ -1059,11 +1146,12 @@ services: | `LOXY_DATA_DIR` | `/var/lib/loxy` | DB, backups | | `LOXY_HAPROXY_CONF_DIR` | `/usr/local/etc/haproxy` | Revision tree | | `LOXY_HAPROXY_RUN_DIR` | `/run/haproxy` | Sockets | +| `LOXY_IPV6` | `auto` | IPv6 listeners for services and domains: `auto` (probe), `on`, `off` (ADR 0004 §9) | | `LOXY_STATS_INTERVAL` | `1s` | Poll interval | | `LOXY_LOG_LEVEL` | `info` | | ### 16.5 Installer (`deploy/install.sh`) -1. Check root, Docker ≥ 24, Compose v2, free ports (8443, 80/443 if they will be used by frontends). +1. Check root, Docker ≥ 24, Compose v2, free ports (8443; 80/443 if domains will be used). 2. Write `/etc/sysctl.d/99-loxy.conf`, `sysctl --system`. 3. Create `/opt/loxy/{data/haproxy-conf,data/haproxy-state,data/loxy,run/haproxy}`; `chown -R 1500:1500`; `chmod 0750` on data dirs and `chmod 0770` on `run/haproxy` (the capability‑dropped HAProxy master creates its sockets there through group 1500). 4. Write `compose.yml` and `.env` (generate `LOXY_SECRET_KEY`, chmod 0600). @@ -1071,19 +1159,19 @@ services: 6. Print panel URL (`https://:8443`) and the one‑time owner password (`docker compose logs loxy | grep 'initial password'`), remind to restrict access (allowlist/firewall). ### 16.6 Operations -- **Upgrade loxy:** `docker compose pull loxy && docker compose up -d loxy` — HAProxy keeps serving, no traffic impact. DB migrations run at start after an automatic pre‑migration backup. +- **Upgrade loxy:** `docker compose pull loxy && docker compose up -d loxy` — HAProxy keeps serving, no traffic impact. DB migrations run at start after an automatic pre‑migration backup. Upgrading a database from the frontend/backend version runs `0002_projects.sql` (§6): frontend/backend definitions are dropped, so recreate them as projects; domains are kept detached and can be attached to the new projects. - **Upgrade HAProxy:** bump `HAPROXY_VERSION` (and the matching loxy image tag) → `docker compose up -d`. Replacing the HAProxy container restarts it (brief interruption) — schedule it, or in an HA pair fail over first, upgrade passive, fail back. - **Backup:** `loxy backup` (SQLite online backup via `VACUUM INTO` + current live revision tarball), nightly by default, keep 14; optional upload to S3‑compatible storage. **Restore:** `loxy restore ` then the reconciler re‑applies. - **Break‑glass:** `docker compose exec loxy loxy admin reset-password`; `loxy render --dry-run` prints the config that would be applied; `loxy admin rollback --revision N`. -- **Firewall:** allow frontend bind ports publicly; restrict 8443 to admin IPs; never expose 9090, 8405 or 5514. +- **Firewall:** open the project ports you use, plus 80/443 when using domains; restrict 8443 to admin IPs; never expose 9090, 8405 or 5514. --- ## 17. Development workflow -- `make dev` → `docker compose -f deploy/compose/compose.dev.yml up` with: HAProxy (same image), three `traefik/whoami` servers on different ports (one can be toggled unhealthy via an env flag), **Pebble** (Let's Encrypt's ACME test server) + its challenge DNS, and loxy in dev mode with live reload; Vite dev server proxies `/api` to loxy. +- `make dev` → `docker compose -f deploy/compose/compose.dev.yml up` with: HAProxy (same image), three `traefik/whoami` targets on different ports (one can be toggled unhealthy via an env flag), **Pebble** (Let's Encrypt's ACME test server) + its challenge DNS, and loxy in dev mode with live reload; Vite dev server proxies `/api` to loxy. - `make gen` (OpenAPI → Go + TS, sqlc), `make lint` (golangci‑lint, `svelte-check`, eslint, prettier), `make test`, `make test-integration`, `make e2e`, `make load`, `make build`, `make image`. -- `make seed` creates a demo project: an http, a TLS (redirect + routes) and a tcp frontend, 3 backends, 6 servers (IP, host and label selector), 3 hosts, an uploaded cert, a managed cert against Pebble, and synthetic traffic (`oha` loop) so graphs have data. +- `make seed` creates demo projects: "shop" (http service on an automatic port + an https service, 3 targets of all three types, two domains), "db" (tcp service), 3 hosts, an uploaded cert, a managed cert against Pebble, and synthetic traffic (`oha` loop) so graphs have data. - Conventional commits; CI on every PR: gen‑check, lint, unit, integration (real HAProxy via testcontainers), e2e (headless), bundle‑size check, image build. --- @@ -1092,13 +1180,14 @@ services: | Layer | What | |---|---| -| Unit | Validation rules (incl. cross‑frontend port collisions with `*`, redirect :80 and `sys_acme_fe` :80; frontend/backend mode match; in‑use backend delete refusal); status‑code conversion; label‑selector parser; route → ACL rendering; metric delta/reset logic; health aggregation; reconciler classification table (§7.6) | -| Renderer golden tests | Fixture models → expected `haproxy.cfg` (byte‑exact): tls frontend with redirect and routes, tcp pair, `*` binds, maintenance, disabled frontend, `sys_acme_fe`; every golden passes `haproxy -c` in CI | +| Unit | Validation rules (listen‑port uniqueness across projects, reserved ports, automatic port allocation incl. gaps and concurrent creates, domain uniqueness, default‑service resolution, refusal to remove the last http/https service with domains); status‑code conversion; label‑selector parser; target `ip[:port]` parsing; Host‑condition rendering (exact before wildcard); metric delta/reset logic and project = Σ services; health aggregation (per target per service); reconciler classification table (§7.6) | +| Migration | `0002_projects.sql` on a fixture database of the frontend/backend version: tenancy `projects`, hosts, certificates, users, settings, DNS providers survive; domains are copied detached; events keep their rows; frontend/backend tables are gone and `metrics_1m` is empty; a database with a `load_balancers` table is refused with the documented error | +| Renderer golden tests | Fixture models → expected `haproxy.cfg` (byte‑exact): the §7.4 reference, a project without targets, maintenance (http in `fe_` + `be_` only with domains, tcp in `fe_`), IPv6 off, `sys_http` only (no certificate issued yet), exact vs. wildcard domains of two projects, HTTP‑01 cert without domains; every golden passes `haproxy -c` in CI | | Fuzz | Random valid models → config passes `haproxy -c`, no injected lines (§14.4) | -| Integration (real HAProxy 3.4) | Runtime client matchers for every command; reload success and failure paths (`Success=0` keeps old workers, symlink restored); server‑state survives reload; dynamic server add/remove/drain; host/path routing reaches the right backend; maintenance via map (http 503, tcp reject); cert hot‑swap; ACME HTTP‑01 + DNS‑01 against Pebble; metrics deltas across reloads; **all VERIFY items**: `-S` socket options, double `http-check expect` semantics, `@system-ca`, `del ssl cert`, minimal capability set, `chroot auto`, prompt mode, `shm-stats-file` (if enabled) | -| E2E (Playwright) | Login, create backend + servers of all three types, create http / TLS (with redirect) / tcp frontends, add routing rules and reorder them, toggle sticky/proxy protocol, drain/maint/weight a server, maintenance mode, delete refused for an in‑use backend, delete frontend, certificate upload/managed flow, domain verification, graphs render, rollback | -| Load / zero‑downtime | `oha` at 2 000 rps against a frontend while (a) 100 reloads, (b) 50 server add/removes, (c) cert renewals — **0 non‑2xx/connection errors** allowed | -| Resource | loxy RSS and CPU under the §1.4 dataset (50 frontends / 200 backends / 1 000 servers); UI bundle size budget | +| Integration (real HAProxy 3.4) | Runtime client matchers for every command; reload success and failure paths (`Success=0` keeps old workers, symlink restored); server‑state survives reload; dynamic target add/remove/drain across two services; two projects with domains on shared :80/:443 route by Host, unknown Host → 404; 301 only after issuance; maintenance via map (http 503 on port and domain, tcp reject); cert hot‑swap; ACME HTTP‑01 + DNS‑01 against Pebble; metrics deltas across reloads; **all VERIFY items**: `-S` socket options, double `http-check expect` semantics, `@system-ca`, `del ssl cert`, minimal capability set, `http-request return` with `opt@` map in a backend, `chroot auto`, prompt mode, `shm-stats-file` (if enabled) | +| E2E (Playwright) | Login, create project with the wizard (automatic port), add targets of all three types, reach the project on its port, add https and tcp services, port conflict error, toggle sticky/PROXY protocol, drain/maint/weight a target, maintenance mode, attach a domain → verification → certificate → 301, detach domain, delete project, certificate upload/managed flow, graphs render (project and per service), rollback | +| Load / zero‑downtime | `oha` at 2 000 rps against a service port and a domain while (a) 100 reloads, (b) 50 target add/removes, (c) cert renewals — **0 non‑2xx/connection errors** allowed | +| Resource | loxy RSS and CPU under the §1.4 dataset (50 projects / 200 services / 1 000 targets); UI bundle size budget | --- @@ -1108,49 +1197,51 @@ services: Repo skeleton, Makefile, CI, OpenAPI skeleton + codegen, SQLite + migrations, auth (owner login, sessions), runtime client + parsers, renderer + golden tests, revision FS + validator, bootstrap config, reload procedure. *DoD:* `docker compose up` on a clean VM brings up HAProxy with the panel reachable at :8443; `loxy render --dry-run` works; reload path integration tests pass. -**Phase 1 — HAProxy management MVP on a single node** -Frontends (http/tcp, binds incl. `*`, TLS termination, HTTP→HTTPS redirect, default backend, host/path routes, timeouts, maxconn, forwarded headers, enable, maintenance, labels); backends (algorithm, full health checks, sticky, PROXY protocol, timeouts, labels); servers (IP, host, label selector) with runtime apply, weight/backup/drain/maint and graceful removal; hosts inventory; certificates (upload + managed HTTP‑01/DNS‑01); **Domains & DNS part 1** (§8.9): domains attached to frontends with required records and live verification, DNS provider accounts with encrypted credentials, DNS‑01 via the libdns‑backed lego provider, one‑click certificate issuance; node addresses; Dashboard, frontend Overview/Routing/Graphs/Config, backend Servers/Settings/Graphs (live + history); Activity (events + audit); users/roles. -*DoD:* every acceptance criterion in §8 for these screens passes in E2E; zero‑downtime load tests pass; §1.4 budgets met. +**Phase 1 — Load‑balancer MVP on a single node (projects model, ADR 0006)** +Migration `0002_projects.sql`; projects (algorithm, maintenance, labels) with the create wizard and automatic ports; services (http/https/tcp, listen → destination, health checks, sticky, PROXY protocol, timeout, certificates); targets (IP, host, label selector) with runtime apply, weight/backup/drain/maint and graceful removal; domains on shared `sys_http`/`sys_https` with automatic HTTPS (HTTP‑01 and DNS‑01), required records and live verification; hosts inventory; certificates (upload + managed); DNS provider accounts with encrypted credentials; node addresses; Dashboard, Projects list, project Overview/Graphs/Targets/Services/Domains/Settings (live + history graphs), global Domains page; Activity (events + audit); users/roles. +*DoD:* every acceptance criterion in §8 for these screens passes in E2E; zero‑downtime load tests pass; §1.4 budgets met; migration test green. **Phase 2 — Operations polish** -Config revisions UI (list, diff, rollback, pin), notifications (webhook, email, Telegram, Slack) and alert rules with label filters, API tokens, live logs (sampled) on the frontend Logs tab, Prometheus endpoints, backups (+ S3), 2FA, `loxy` CLI client for the API, **Domains & DNS part 2** (§8.9): auto‑managed A/AAAA records and the zone editor. -*DoD:* alert on server down arrives within 5 s; rollback restores previous behaviour verified by integration test; nightly backup + restore round‑trip test. +Config revisions UI (list, diff, rollback, pin), notifications (webhook, email, Telegram, Slack) and alert rules with project label filters, API tokens, live logs (sampled) on the project Logs tab, Prometheus endpoints, backups (+ S3), 2FA, `loxy` CLI client for the API, **DNS part 2** (§8.12): auto‑managed A/AAAA records and the zone editor. +*DoD:* alert on target down arrives within 5 s; rollback restores previous behaviour verified by integration test; nightly backup + restore round‑trip test. **Phase 3 — Multi‑node and generic HA** `loxy panel` / `loxy agent`, enrollment, node pages, HA groups with keepalived VRRP and a virtual IP (no provider API), `peers` sync of stick tables. *DoD:* killing the active node moves the virtual IP and traffic to the passive node automatically; the panel going down doesn't affect traffic; agent reconnects and reconciles. **Phase 4 — Advanced L7 (optional)** -Rate limiting per client IP (stick tables), IP allow/deny lists per frontend (runtime ACLs), request/response header rules, HSTS, response compression (3.4 `filter comp-res`), custom error pages, HTTP/3 (QUIC) when the image reports QUIC support, backend TLS re‑encryption to servers, OIDC SSO for the panel. +Path routing inside a project (path prefix → service), rate limiting per client IP (stick tables), IP allow/deny lists per project (runtime ACLs), request/response header rules, HSTS for domains, response compression (3.4 `filter comp-res`), custom error pages, HTTP/3 (QUIC) when the image reports QUIC support, TLS re‑encryption to targets, OIDC SSO for the panel. --- ## 20. Backlog of useful extras -- **Config import** from an existing `haproxy.cfg`: parse frontends/backends/servers that map onto the Loxy model, show what can't be imported (kept out, listed), create the objects in one transaction, then let Loxy own the config. -- **Maintenance page** per frontend with custom text (instant, runtime map). -- **Graceful drain** with configurable timeout, and **slow start** (`slowstart`) for servers returning to service. -- **Live logs**: HAProxy logs to `127.0.0.1:5514` UDP → loxy ring buffer per frontend, sampling (e.g. 1:10), off by default (privacy and performance first). +- **Config import** from an existing `haproxy.cfg`: map frontend/backend pairs onto projects and services, show what can't be imported (kept out, listed), create the objects in one transaction, then let Loxy own the config. +- **Maintenance page** per project with custom text (instant, runtime map). +- **Graceful drain** with configurable timeout, and **slow start** (`slowstart`) for targets returning to service. +- **Live logs**: HAProxy logs to `127.0.0.1:5514` UDP → loxy ring buffer per project (by `be_` name), sampling (e.g. 1:10), off by default (privacy and performance first). - **Config preview & diff** before applying risky changes; "Download HAProxy config". -- **Per‑server graphs** and response‑time / status‑code breakdowns. -- **Bandwidth limits** per frontend using HAProxy's `bwlim-in`/`bwlim-out` filters. -- **Duplicate frontend/backend**, **Export/Import JSON**, Terraform provider later (the API is resource‑shaped, which makes this easy). +- **Per‑target graphs** and response‑time / status‑code breakdowns. +- **Bandwidth limits** per service using HAProxy's `bwlim-in`/`bwlim-out` filters. +- **Duplicate project**, **Export/Import JSON**, Terraform provider later (the API is resource‑shaped, which makes this easy). - **Webhooks** for every event kind. - **Rate limiting & IP lists** via stick tables and runtime ACLs (Phase 4). - **HTTP/3** advertisement (`alt-svc`) when QUIC is available. -- **Runtime backends** (3.4 `add backend` / `publish backend`) to make some backend changes reload‑free. -- **Own authoritative DNS server** — explicitly *not* planned (non‑goal); revisit only if operators need GeoDNS/health‑based DNS failover. Today Loxy manages records at external providers (§8.9). +- **Runtime backends** (3.4 `add backend` / `publish backend`) to make some service changes reload‑free. +- **Own authoritative DNS server** — explicitly *not* planned (non‑goal); revisit only if operators need GeoDNS/health‑based DNS failover. Today Loxy manages records at external providers (§8.12). - **More DNS providers** via libdns (deSEC, Route 53, DigitalOcean, OVH, …) — one registry entry each. -- **Health‑check templates** using the 3.4 `healthcheck` section (reusable checks shared by backends). +- **Health‑check templates** using the 3.4 `healthcheck` section (reusable checks shared by services). --- ## 21. Open questions for the owner (ask before Phase 1 ends; defaults in brackets) 1. Accent colour and logo [neutral red, simple wordmark "Loxy"]. (Name is decided: **Loxy**.) -2. Will people other than you log in (multi‑tenant projects)? [single owner + team, one project] +2. Will people other than you log in? [single owner + team; one tenancy project (`projects` id 1) — the load‑balancer projects are `balancers` in the DB; per‑project permissions are backlog] 3. Is HA in scope for the first production rollout? [no — Phase 3, generic VRRP] -4. DNS providers for DNS‑01 and record management [libdns providers: Hetzner DNS, Cloudflare first, §8.9]. +4. DNS providers for DNS‑01 and record management [the libdns providers already in the `internal/dns` registry; more are one registry entry each, §8.12]. +5. Automatic port range [lowest free port in 20000–29999; explicit ports may be anywhere else that is free]. +6. Should a domain default to HTTPS? [yes — `https` on, :80 redirects once the certificate is issued]. --- @@ -1171,3 +1262,4 @@ Rate limiting per client IP (stick tables), IP allow/deny lists per frontend (ru - libdns (provider‑agnostic DNS record APIs for Go) — https://github.com/libdns/libdns - lego ACME library (custom challenge providers, DNS providers) — https://go-acme.github.io/lego/ - Roxy‑WI (for contrast) — https://github.com/roxy-wi/roxy-wi +- ADR 0006 — projects model (`docs/adr/0006-projects.md`) diff --git a/README.md b/README.md index be8ed21..9225710 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,39 @@ # Loxy -A lightweight, self-hosted management panel for -[HAProxy 3.4 LTS](https://www.haproxy.org/) — a safer alternative to -hand-editing `haproxy.cfg`. - -Manage HAProxy's own building blocks from a small web UI or REST API: - -- **Frontends** — HTTP or TCP listeners on any addresses/ports (`*` = all), - TLS termination with certificates, HTTP→HTTPS redirect, a default backend - and host/path routing rules, timeouts, `maxconn`, forwarded headers, - one-click maintenance mode. -- **Backends** — algorithm, health checks (TCP/HTTP/HTTPS), sticky sessions, - PROXY protocol, timeouts; shared by as many frontends as you like. -- **Servers** — IPs, hosts from the inventory or label selectors; weight, - backup, drain/maintenance and graceful removal, applied at runtime. -- **Hosts**, **certificates** (uploaded or Let's Encrypt via HTTP-01/DNS-01) - and **domains & DNS** (required records, live verification, DNS providers - via libdns). -- **Observability** — per-server health, live and historical graphs per - frontend and backend, live logs, activity and audit log. - -Every change is rendered, validated with `haproxy -c`, stored as a numbered -config revision (diff and rollback), and applied through the runtime API when -possible or a hitless reload otherwise. One Go binary, SQLite, no other -services. +Simple, self-hosted load balancers on your own servers, powered by +[HAProxy 3.4 LTS](https://www.haproxy.org/). One Go binary, SQLite and the +official HAProxy image — no other services. + +## How it works + +A **project** is one load balancer. Create as many as you like. Each one has: + +- **Targets** — where traffic goes: IP addresses (optionally `ip:port`), hosts + from your inventory, or a label selector such as `role=web`. Set weights, + mark backups, drain or pause a target; changes apply instantly without a + reload. +- **Services** — the ports it listens on: `http`, `https` or `tcp`, listen + port → destination port, health checks, sticky sessions, PROXY protocol. + Every new project gets its own free port automatically (from 20000 up). +- **Domains** — attach `app.example.com` and Loxy routes it to the project on + ports 80/443. HTTPS is automatic: as soon as the name points at your server, + Loxy gets a Let's Encrypt certificate and redirects HTTP to HTTPS. A DNS + provider (via libdns) can manage the records for you. + +Plus live and historical graphs per project and service, live logs, health per +target, maintenance mode, certificates, users and roles, API tokens, 2FA, +notifications and backups. Every change is validated with `haproxy -c`, stored +as a config revision you can diff and roll back, and applied without dropping +traffic. + +## Quick start + +1. **Create a project** — give it a name. It is reachable right away on its own + port, e.g. `http://:20001`. +2. **Add targets** — paste the IPs of your app servers (or pick hosts / a label + selector). Loxy health-checks them and starts sending traffic. +3. **Reach it** — use the project's port, or attach a domain: create the A/AAAA + records Loxy shows you, and HTTPS turns on by itself. > Status: under active development. The build spec is > [INSTRUCTIONS.md](INSTRUCTIONS.md). @@ -99,8 +109,9 @@ Open `https://:8443` and log in as `admin@loxy.local` (override with docker compose logs loxy | grep 'initial password' ``` -Restrict panel access with `LOXY_PANEL_ALLOW` or a firewall. Never expose -ports 9090, 8405 or 5514. See [docs/operations.md](docs/operations.md) for +Restrict panel access with `LOXY_PANEL_ALLOW` or a firewall. Open the project +ports you use, plus 80/443 when you use domains. Never expose ports 9090, 8405 +or 5514. See [docs/operations.md](docs/operations.md) for upgrades, backup/restore and break-glass procedures. ## Configuration @@ -113,7 +124,7 @@ All settings are environment variables in `.env`; see Prerequisites: Go (see `go.mod`), Node 22+, Docker. ```sh -make dev # haproxy + 3 whoami servers + Pebble ACME + loxy (dev mode) +make dev # haproxy + 3 whoami targets + Pebble ACME + loxy (dev mode) make gen # OpenAPI -> Go + TS, sqlc make lint # gofmt, go vet, golangci-lint, svelte-check make test # unit tests @@ -123,7 +134,7 @@ make build # bin/loxy make image # ghcr.io/2shiraz/loxy:-haproxy ``` -In dev the whoami servers listen on `127.0.0.1:9001-9003` (loopback servers are +In dev the whoami targets listen on `127.0.0.1:9001-9003` (loopback targets are accepted only when `LOXY_DEV=true`), Pebble on `https://127.0.0.1:14000/dir`; state is kept in `.dev/`. diff --git a/api/openapi.yaml b/api/openapi.yaml index a53c0e9..ddc3d96 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,13 +2,13 @@ openapi: 3.0.3 info: title: Loxy API version: 1.0.0 - description: 'REST API of Loxy, a self-hosted management panel for HAProxy 3.4. + description: 'REST API of Loxy, a self-hosted load balancer panel on HAProxy 3.4. - Objects map directly to HAProxy: frontends (listeners + routing rules), backends (server pools + health checks), + A project is one load balancer: targets (the servers traffic goes to), services (ports it listens on) - servers, certificates. Session cookie or `Authorization: Bearer loxy__`. Mutating requests with a + and domains (hostnames routed to it on the shared :80/:443). Session cookie or - session cookie must send `X-LOXY-CSRF`. + `Authorization: Bearer loxy__`. Mutating requests with a session cookie must send `X-LOXY-CSRF`. ' servers: @@ -18,8 +18,7 @@ security: - bearer: [] tags: - name: auth -- name: frontends -- name: backends +- name: projects - name: certificates - name: domains - name: inventory @@ -288,6 +287,12 @@ paths: type: array items: $ref: '#/components/schemas/Domain' + parameters: + - name: project_id + in: query + schema: &id001 + type: integer + format: int64 post: operationId: createDomain tags: @@ -692,7 +697,11 @@ paths: - system responses: '200': - description: SSE stream of Event objects (event name = kind) + description: "SSE stream of Event objects (event name = kind). Every 2 s an ephemeral `live.tick` event carries + data = {ts, projects: [{id, status, maintenance, version, health: {status, healthy, total}, health_sig, stats: {open_connections, + requests_per_second, bytes_in_per_second, bytes_out_per_second, response_time_ms}}], traffic: {open_connections, requests_per_second, + bytes_in_per_second, bytes_out_per_second}, node: {status, applied_version, desired_version}}; health_sig changes whenever + any target check of that project changes." content: text/event-stream: schema: @@ -703,14 +712,6 @@ paths: tags: - system parameters: - - name: frontend_id - in: query - schema: &id001 - type: integer - format: int64 - - name: backend_id - in: query - schema: *id001 - name: kind in: query schema: @@ -719,6 +720,9 @@ paths: in: query schema: type: integer + - name: project_id + in: query + schema: *id001 responses: '200': description: Events @@ -1002,16 +1006,19 @@ paths: responses: '204': description: Deleted - /frontends: + /apply/retry: + post: + operationId: retryApply + tags: + - system + responses: + '202': + description: Queued + /hosts: get: - operationId: listFrontends + operationId: listHosts tags: - - frontends - parameters: - - name: label_selector - in: query - schema: - type: string + - inventory responses: '200': description: List @@ -1020,22 +1027,22 @@ paths: schema: type: object required: - - frontends + - hosts properties: - frontends: + hosts: type: array items: - $ref: '#/components/schemas/Frontend' + $ref: '#/components/schemas/Host' post: - operationId: createFrontend + operationId: createHost tags: - - frontends + - inventory requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/FrontendInput' + $ref: '#/components/schemas/HostInput' responses: '201': description: Created @@ -1044,67 +1051,25 @@ paths: schema: type: object required: - - frontend + - host properties: - frontend: - $ref: '#/components/schemas/Frontend' + host: + $ref: '#/components/schemas/Host' '422': &id002 $ref: '#/components/responses/Error' - /frontends/{id}: + /hosts/{id}: parameters: - - &id003 - $ref: '#/components/parameters/Id' - get: - operationId: getFrontend - tags: - - frontends - responses: - '200': - description: Frontend - content: - application/json: - schema: - type: object - required: - - frontend - properties: - frontend: - $ref: '#/components/schemas/Frontend' - '404': *id002 - put: - operationId: updateFrontend - tags: - - frontends - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/FrontendInput' - responses: - '200': - description: Updated - content: - application/json: - schema: - type: object - required: - - frontend - properties: - frontend: - $ref: '#/components/schemas/Frontend' - '409': *id002 - '422': *id002 + - $ref: '#/components/parameters/Id' patch: - operationId: patchFrontend + operationId: updateHost tags: - - frontends + - inventory requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/FrontendPatch' + $ref: '#/components/schemas/HostInput' responses: '200': description: Updated @@ -1113,105 +1078,61 @@ paths: schema: type: object required: - - frontend + - host properties: - frontend: - $ref: '#/components/schemas/Frontend' - '422': *id002 + host: + $ref: '#/components/schemas/Host' delete: - operationId: deleteFrontend + operationId: deleteHost tags: - - frontends + - inventory responses: '204': description: Deleted - '423': *id002 - /frontends/{id}/config: - parameters: - - *id003 - get: - operationId: getFrontendConfig - tags: - - frontends - responses: - '200': - description: Rendered HAProxy sections of this frontend (and the backends it uses) - content: - text/plain: - schema: - type: string - /frontends/{id}/metrics: - parameters: - - *id003 + '409': *id002 + /label_selector/preview: get: - operationId: getFrontendMetrics + operationId: previewLabelSelector tags: - - frontends - parameters: &id004 - - name: type + - inventory + parameters: + - name: selector in: query + required: true schema: type: string - description: comma separated series names - - name: start - in: query - schema: *id001 - - name: end - in: query - schema: *id001 - - name: step - in: query - schema: - type: integer responses: '200': - description: Columnar series + description: Matching hosts content: application/json: schema: - $ref: '#/components/schemas/Metrics' - /frontends/{id}/metrics/stream: - parameters: - - *id003 - get: - operationId: streamFrontendMetrics - tags: - - frontends - responses: - '200': - description: 'SSE: event `metrics.tick`, data MetricsPoint' - content: - text/event-stream: - schema: - type: string - /frontends/{id}/logs/stream: - parameters: - - *id003 + type: object + required: + - hosts + properties: + hosts: + type: array + items: + $ref: '#/components/schemas/Host' + '422': *id002 + /overview: get: - operationId: streamFrontendLogs + operationId: getOverview tags: - - frontends + - system responses: '200': - description: 'SSE: event `log`, data {ts, text}; off unless LOXY_LIVE_LOGS is set' + description: Dashboard summary content: - text/event-stream: + application/json: schema: - type: string - '404': *id002 - /apply/retry: - post: - operationId: retryApply - tags: - - system - responses: - '202': - description: Queued - /backends: + $ref: '#/components/schemas/Overview' + /projects: get: - operationId: listBackends + operationId: listProjects tags: - - backends + - projects parameters: - name: label_selector in: query @@ -1225,22 +1146,22 @@ paths: schema: type: object required: - - backends + - projects properties: - backends: + projects: type: array items: - $ref: '#/components/schemas/Backend' + $ref: '#/components/schemas/Project' post: - operationId: createBackend + operationId: createProject tags: - - backends + - projects requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/BackendInput' + $ref: '#/components/schemas/ProjectCreate' responses: '201': description: Created @@ -1249,41 +1170,43 @@ paths: schema: type: object required: - - backend + - project properties: - backend: - $ref: '#/components/schemas/Backend' - '422': *id002 - /backends/{id}: + project: + $ref: '#/components/schemas/Project' + '422': &id003 + $ref: '#/components/responses/Error' + /projects/{id}: parameters: - - *id003 + - &id004 + $ref: '#/components/parameters/Id' get: - operationId: getBackend + operationId: getProject tags: - - backends + - projects responses: '200': - description: Backend + description: Project content: application/json: schema: type: object required: - - backend + - project properties: - backend: - $ref: '#/components/schemas/Backend' - '404': *id002 - put: - operationId: updateBackend + project: + $ref: '#/components/schemas/Project' + '404': *id003 + patch: + operationId: updateProject tags: - - backends + - projects requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/BackendInput' + $ref: '#/components/schemas/ProjectUpdate' responses: '200': description: Updated @@ -1292,42 +1215,61 @@ paths: schema: type: object required: - - backend + - project properties: - backend: - $ref: '#/components/schemas/Backend' - '409': *id002 - '422': *id002 + project: + $ref: '#/components/schemas/Project' + '409': *id003 + '422': *id003 delete: - operationId: deleteBackend + operationId: deleteProject tags: - - backends + - projects responses: '204': description: Deleted - '409': *id002 - /backends/{id}/status: + '404': *id003 + /projects/{id}/config: parameters: - - *id003 + - *id004 get: - operationId: getBackendStatus + operationId: getProjectConfig tags: - - backends + - projects responses: '200': - description: Health of every server + description: Rendered HAProxy sections of this project content: - application/json: + text/plain: schema: - $ref: '#/components/schemas/BackendHealth' - /backends/{id}/metrics: + type: string + /projects/{id}/metrics: parameters: - - *id003 + - *id004 get: - operationId: getBackendMetrics + operationId: getProjectMetrics tags: - - backends - parameters: *id004 + - projects + parameters: + - name: type + in: query + schema: + type: string + description: comma separated series names + - name: start + in: query + schema: *id001 + - name: end + in: query + schema: *id001 + - name: step + in: query + schema: + type: integer + - name: service_id + in: query + schema: *id001 + description: 'only this service (default: whole project)' responses: '200': description: Columnar series @@ -1335,37 +1277,66 @@ paths: application/json: schema: $ref: '#/components/schemas/Metrics' - /backends/{id}/servers: + /projects/{id}/metrics/stream: + parameters: + - *id004 + get: + operationId: streamProjectMetrics + tags: + - projects + responses: + '200': + description: 'SSE: event `metrics.tick`, data MetricsPoint (whole project)' + content: + text/event-stream: + schema: + type: string + /projects/{id}/logs/stream: + parameters: + - *id004 + get: + operationId: streamProjectLogs + tags: + - projects + responses: + '200': + description: 'SSE: event `log`, data {ts, text}; off unless LOXY_LIVE_LOGS is set' + content: + text/event-stream: + schema: + type: string + '404': *id003 + /projects/{id}/services: parameters: - - *id003 + - *id004 get: - operationId: listBackendServers + operationId: listServices tags: - - backends + - projects responses: '200': - description: Servers + description: List content: application/json: schema: type: object required: - - servers + - services properties: - servers: + services: type: array items: - $ref: '#/components/schemas/BackendServer' + $ref: '#/components/schemas/Service' post: - operationId: createBackendServers + operationId: createService tags: - - backends + - projects requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/BackendServersCreate' + $ref: '#/components/schemas/ServiceInput' responses: '201': description: Created @@ -1374,30 +1345,30 @@ paths: schema: type: object required: - - servers + - service properties: - servers: - type: array - items: - $ref: '#/components/schemas/BackendServer' - '422': *id002 - /backends/{id}/servers/{sid}: + service: + $ref: '#/components/schemas/Service' + '422': *id003 + /projects/{id}/services/{sid}: parameters: - - *id003 + - *id004 - name: sid in: path required: true - schema: *id001 - patch: - operationId: updateBackendServer + schema: + type: integer + format: int64 + put: + operationId: updateService tags: - - backends + - projects requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/BackendServerUpdate' + $ref: '#/components/schemas/ServiceInput' responses: '200': description: Updated @@ -1406,22 +1377,26 @@ paths: schema: type: object required: - - server + - service properties: - server: - $ref: '#/components/schemas/BackendServer' + service: + $ref: '#/components/schemas/Service' + '422': *id003 delete: - operationId: deleteBackendServer + operationId: deleteService tags: - - backends + - projects responses: '204': - description: Removal started (graceful drain) - /hosts: + description: Deleted + '409': *id003 + /projects/{id}/targets: + parameters: + - *id004 get: - operationId: listHosts + operationId: listTargets tags: - - inventory + - projects responses: '200': description: List @@ -1430,22 +1405,22 @@ paths: schema: type: object required: - - hosts + - targets properties: - hosts: + targets: type: array items: - $ref: '#/components/schemas/Host' + $ref: '#/components/schemas/Target' post: - operationId: createHost + operationId: createTargets tags: - - inventory + - projects requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/HostInput' + $ref: '#/components/schemas/TargetsCreate' responses: '201': description: Created @@ -1454,24 +1429,32 @@ paths: schema: type: object required: - - host + - targets properties: - host: - $ref: '#/components/schemas/Host' - '422': *id002 - /hosts/{id}: + targets: + type: array + items: + $ref: '#/components/schemas/Target' + '422': *id003 + /projects/{id}/targets/{tid}: parameters: - - *id003 + - *id004 + - name: tid + in: path + required: true + schema: + type: integer + format: int64 patch: - operationId: updateHost + operationId: updateTarget tags: - - inventory + - projects requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/HostInput' + $ref: '#/components/schemas/TargetUpdate' responses: '200': description: Updated @@ -1480,56 +1463,19 @@ paths: schema: type: object required: - - host + - target properties: - host: - $ref: '#/components/schemas/Host' + target: + $ref: '#/components/schemas/Target' + '422': *id003 delete: - operationId: deleteHost - tags: - - inventory - responses: - '204': - description: Deleted - '409': *id002 - /label_selector/preview: - get: - operationId: previewLabelSelector + operationId: deleteTarget tags: - - inventory - parameters: - - name: selector - in: query - required: true - schema: - type: string + - projects responses: - '200': - description: Matching hosts - content: - application/json: - schema: - type: object - required: - - hosts - properties: - hosts: - type: array - items: - $ref: '#/components/schemas/Host' - '422': *id002 - /overview: - get: - operationId: getOverview - tags: - - system - responses: - '200': - description: Dashboard summary - content: - application/json: - schema: - $ref: '#/components/schemas/Overview' + '202': + description: Draining, then removed + '404': *id003 components: securitySchemes: session: @@ -1898,6 +1844,7 @@ components: - required_records - observed - created_at + - https properties: id: type: integer @@ -1953,10 +1900,21 @@ components: created_at: type: integer format: int64 - frontend_id: &id005 + project_id: &id005 + type: integer + format: int64 + nullable: true + service_id: type: integer format: int64 nullable: true + description: 'service the domain routes to (default: the project''s first http/https service)' + https: + type: boolean + description: serve on :443 with an automatic certificate and redirect :80 to it (default true) + url: + type: string + description: https://fqdn once the certificate is issued, else http://fqdn DomainInput: type: object properties: @@ -1968,7 +1926,14 @@ components: nullable: true auto_records: type: boolean - frontend_id: *id005 + project_id: *id001 + service_id: + type: integer + format: int64 + description: 'default: the project''s first http/https service' + https: + type: boolean + description: serve on :443 with an automatic certificate and redirect :80 to it (default true) DomainResponse: type: object required: @@ -2087,7 +2052,9 @@ components: - address - family properties: - id: *id001 + id: &id007 + type: integer + format: int64 address: type: string family: @@ -2260,8 +2227,7 @@ components: data: type: object additionalProperties: true - frontend_id: *id005 - backend_id: *id005 + project_id: *id005 AuditEntry: type: object required: @@ -2423,7 +2389,7 @@ components: type: integer label_filter: type: string - description: label selector on frontend/backend labels; node-wide events (apply, backup) are not filtered by it + description: label selector on project labels; node-wide events (apply, backup) are not filtered by it AlertRuleInput: type: object required: @@ -2447,179 +2413,189 @@ components: type: integer label_filter: type: string - description: label selector on frontend/backend labels; node-wide events (apply, backup) are not filtered by it - Bind: + description: label selector on project labels; node-wide events (apply, backup) are not filtered by it + HostInput: type: object - required: - - address - - port properties: - address: + name: type: string - description: IP address, or "*" for all interfaces - port: - type: integer - Route: - type: object - required: - - backend_id - properties: - id: *id001 - hosts: - type: array - items: - type: string - description: Host header match; "*.example.com" matches subdomains - path_prefix: + ipv4: &id006 + type: string + nullable: true + ipv6: *id006 + private_ipv4: *id006 + labels: + $ref: '#/components/schemas/Labels' + notes: type: string - description: e.g. /api; empty = any path - backend_id: *id001 - FrontendInput: + Host: type: object required: + - id - name - - mode - - binds + - labels properties: + id: *id007 name: type: string - description: - type: string - mode: + ipv4: *id006 + ipv6: *id006 + private_ipv4: *id006 + labels: + $ref: '#/components/schemas/Labels' + notes: type: string - enum: - - http - - tcp - tls: - type: boolean - description: terminate TLS (http mode); requires certificates - binds: + used_by: type: array - items: - $ref: '#/components/schemas/Bind' + items: *id007 + description: project ids + Overview: + type: object + required: + - projects + - targets + - certificates + - node + properties: + projects: + type: object + required: + - total + - apply_errors + - healthy + - degraded + - unhealthy + properties: + total: + type: integer + apply_errors: + type: integer + healthy: + type: integer + degraded: + type: integer + unhealthy: + type: integer + targets: + type: object + required: + - total + - healthy + properties: + total: + type: integer + healthy: + type: integer certificates: - type: array - items: - type: integer - format: int64 - http_redirect: - type: boolean - description: 'tls only: also listen on :80 and redirect to https' - default_backend_id: - type: integer - format: int64 - nullable: true - routes: - type: array - items: - $ref: '#/components/schemas/Route' - timeout_client_s: - type: integer - maxconn: - type: integer - description: 0 = unlimited - forwarded_headers: - type: boolean - description: 'http: X-Forwarded-For/Proto/Port' - enabled: - type: boolean - labels: - $ref: '#/components/schemas/Labels' - FrontendPatch: + type: object + required: + - total + - expiring_soon + - failed + properties: + total: + type: integer + expiring_soon: + type: integer + failed: + type: integer + node: + $ref: '#/components/schemas/Node' + traffic: + type: object + required: + - open_connections + - requests_per_second + - bytes_in_per_second + - bytes_out_per_second + properties: + open_connections: + type: number + requests_per_second: + type: number + bytes_in_per_second: + type: number + bytes_out_per_second: + type: number + Endpoint: type: object + required: + - kind + - url properties: - name: + kind: type: string - labels: - $ref: '#/components/schemas/Labels' - maintenance: - type: boolean - enabled: - type: boolean - description: + enum: + - port + - domain + url: type: string - Frontend: + service_id: *id005 + domain_id: *id005 + Project: type: object required: - id - name - version - - mode - - tls - - binds - - certificates - - http_redirect - - default_backend_id - - routes - - timeout_client_s - - maxconn - - forwarded_headers - - enabled - - maintenance + - description + - algorithm - labels + - maintenance - status - created_at - - stats + - endpoints + - counts - health + - stats properties: + id: *id001 name: type: string + version: *id001 description: type: string - mode: + algorithm: type: string enum: - - http - - tcp - tls: - type: boolean - description: terminate TLS (http mode); requires certificates - binds: - type: array - items: - $ref: '#/components/schemas/Bind' - certificates: - type: array - items: - type: integer - format: int64 - http_redirect: - type: boolean - description: 'tls only: also listen on :80 and redirect to https' - default_backend_id: - type: integer - format: int64 - nullable: true - routes: - type: array - items: - $ref: '#/components/schemas/Route' - timeout_client_s: - type: integer - maxconn: - type: integer - description: 0 = unlimited - forwarded_headers: - type: boolean - description: 'http: X-Forwarded-For/Proto/Port' - enabled: - type: boolean + - roundrobin + - leastconn + - source + - random labels: $ref: '#/components/schemas/Labels' - id: *id001 - version: *id001 maintenance: type: boolean status: type: string enum: - - active - applying - - apply_error - last_error: &id006 + - ready + - error + last_error: &id009 type: string nullable: true created_at: *id001 + endpoints: + type: array + items: + $ref: '#/components/schemas/Endpoint' + description: 'where the project is reachable: its ports and https/http domains' + counts: + type: object + required: + - targets + - services + - domains + properties: + targets: + type: integer + services: + type: integer + domains: + type: integer + health: + $ref: '#/components/schemas/HealthSummary' stats: type: object required: @@ -2633,24 +2609,41 @@ components: type: number bytes_out_per_second: type: number - health: - $ref: '#/components/schemas/HealthSummary' - BackendInput: + ProjectCreate: type: object required: - name - - mode - - port properties: name: type: string description: type: string - mode: + algorithm: type: string enum: - - http - - tcp + - roundrobin + - leastconn + - source + - random + labels: + $ref: '#/components/schemas/Labels' + targets: + type: array + items: + $ref: '#/components/schemas/TargetInput' + description: initial targets (optional) + service: + allOf: + - $ref: '#/components/schemas/ServiceInput' + description: first service; default http, own free port (listen_port omitted = auto), destination port 80 + ProjectUpdate: + type: object + required: [] + properties: + name: + type: string + description: + type: string algorithm: type: string enum: @@ -2658,16 +2651,26 @@ components: - leastconn - source - random - port: - type: integer - description: default server port - health_check: - $ref: '#/components/schemas/HealthCheck' - sticky_sessions: + labels: + $ref: '#/components/schemas/Labels' + maintenance: type: boolean - cookie_name: + ServiceInput: + type: object + required: + - protocol + - destination_port + properties: + protocol: type: string - cookie_lifetime_s: + enum: + - http + - https + - tcp + listen_port: + type: integer + description: omit for a free port picked automatically (20000+); 80/443 are reserved for domains + destination_port: type: integer proxy_protocol: type: string @@ -2675,58 +2678,49 @@ components: - none - v1 - v2 - timeout_server_s: + timeout_s: type: integer - labels: - $ref: '#/components/schemas/Labels' - Backend: + description: client/server timeout (default 60) + certificates: + type: array + items: *id001 + description: https only + sticky_sessions: + type: boolean + cookie_name: + type: string + cookie_lifetime_s: + type: integer + health_check: &id008 + $ref: '#/components/schemas/HealthCheck' + Service: type: object required: - id - - name - - version - - mode - - algorithm - - port - - health_check + - project_id + - protocol + - listen_port + - destination_port + - proxy_protocol + - timeout_s + - certificates - sticky_sessions - cookie_name - cookie_lifetime_s - - proxy_protocol - - timeout_server_s - - labels - - created_at - - used_by - - servers_count + - health_check - health - - stats properties: - name: - type: string - description: - type: string - mode: + id: *id001 + project_id: *id001 + protocol: type: string enum: - http + - https - tcp - algorithm: - type: string - enum: - - roundrobin - - leastconn - - source - - random - port: + listen_port: type: integer - description: default server port - health_check: - $ref: '#/components/schemas/HealthCheck' - sticky_sessions: - type: boolean - cookie_name: - type: string - cookie_lifetime_s: + destination_port: type: integer proxy_protocol: type: string @@ -2734,35 +2728,26 @@ components: - none - v1 - v2 - timeout_server_s: + timeout_s: type: integer - labels: - $ref: '#/components/schemas/Labels' - id: *id001 - version: *id001 - created_at: *id001 - used_by: + certificates: type: array items: *id001 - description: frontend ids - servers_count: + sticky_sessions: + type: boolean + cookie_name: + type: string + cookie_lifetime_s: type: integer + health_check: *id008 health: $ref: '#/components/schemas/HealthSummary' - stats: - type: object - required: - - sessions - - queue - - response_time_ms - properties: - sessions: - type: number - queue: - type: number - response_time_ms: - type: number - BackendServerInput: + domains: + type: array + items: + type: string + description: domains routed to this service + TargetInput: type: object required: - type @@ -2775,90 +2760,107 @@ components: - label_selector ip: type: string + description: ip, optionally ip:port or [v6]:port host_id: *id001 label_selector: type: string - port: - type: integer - description: override the backend port use_private_ip: type: boolean + port: + type: integer + description: override the service destination port for this target weight: type: integer backup: type: boolean - BackendServersCreate: + TargetsCreate: type: object required: - - servers + - targets properties: - servers: + targets: type: array items: - $ref: '#/components/schemas/BackendServerInput' - BackendServerUpdate: + $ref: '#/components/schemas/TargetInput' + TargetUpdate: type: object + required: [] properties: weight: type: integer + backup: + type: boolean admin_state: type: string enum: - ready - drain - maint - backup: - type: boolean - ResolvedServer: + TargetCheck: + type: object + required: + - service_id + - status + properties: + service_id: *id001 + status: + type: string + enum: + - healthy + - unhealthy + - unknown + check: + type: string + sessions: + type: number + ResolvedTarget: type: object required: - slot_id - address - - port - status + - checks properties: slot_id: *id001 host_id: *id005 - name: *id006 + name: *id009 address: type: string - port: - type: integer status: type: string enum: - healthy - unhealthy - - maintenance - - draining + - degraded - unknown - check: - type: string - sessions: - type: number - BackendServer: + checks: + type: array + items: + $ref: '#/components/schemas/TargetCheck' + Target: type: object required: - id - - backend_id + - project_id - type - use_private_ip - weight - backup - admin_state + - removing - resolved properties: id: *id001 - backend_id: *id001 + project_id: *id001 type: type: string enum: - ip - host - label_selector - ip: *id006 + ip: *id009 host_id: *id005 - label_selector: *id006 + label_selector: *id009 port: type: integer nullable: true @@ -2879,125 +2881,4 @@ components: resolved: type: array items: - $ref: '#/components/schemas/ResolvedServer' - BackendHealth: - type: object - required: - - summary - - servers - properties: - summary: - $ref: '#/components/schemas/HealthSummary' - servers: - type: array - items: - $ref: '#/components/schemas/ResolvedServer' - HostInput: - type: object - properties: - name: - type: string - ipv4: *id006 - ipv6: *id006 - private_ipv4: *id006 - labels: - $ref: '#/components/schemas/Labels' - notes: - type: string - Host: - type: object - required: - - id - - name - - labels - properties: - id: *id001 - name: - type: string - ipv4: *id006 - ipv6: *id006 - private_ipv4: *id006 - labels: - $ref: '#/components/schemas/Labels' - notes: - type: string - used_by: - type: array - items: *id001 - description: backend ids - Overview: - type: object - required: - - frontends - - backends - - servers - - certificates - - node - properties: - frontends: - type: object - required: - - total - - apply_errors - properties: - total: - type: integer - apply_errors: - type: integer - backends: - type: object - required: - - total - - healthy - - degraded - - unhealthy - properties: - total: - type: integer - healthy: - type: integer - degraded: - type: integer - unhealthy: - type: integer - servers: - type: object - required: - - total - - healthy - properties: - total: - type: integer - healthy: - type: integer - certificates: - type: object - required: - - total - - expiring_soon - - failed - properties: - total: - type: integer - expiring_soon: - type: integer - failed: - type: integer - node: - $ref: '#/components/schemas/Node' - traffic: - type: object - required: - - open_connections - - requests_per_second - - bytes_in_per_second - - bytes_out_per_second - properties: - open_connections: - type: number - requests_per_second: - type: number - bytes_in_per_second: - type: number - bytes_out_per_second: - type: number + $ref: '#/components/schemas/ResolvedTarget' diff --git a/cmd/loxy/client.go b/cmd/loxy/client.go index d33eae9..c8fbd37 100644 --- a/cmd/loxy/client.go +++ b/cmd/loxy/client.go @@ -5,10 +5,8 @@ import ( "encoding/json" "fmt" "io" - "net" "net/http" "os" - "strconv" "strings" "text/tabwriter" "time" @@ -73,68 +71,44 @@ func runAPI(args []string) error { return nil } -// runList implements `loxy frontends ls` and `loxy backends ls`. -func runList(kind string, args []string) error { +// runProjects implements `loxy projects ls`. +func runProjects(args []string) error { if len(args) == 0 || args[0] != "ls" { - return fmt.Errorf("usage: loxy %s ls (other operations: loxy api ...)", kind) + return fmt.Errorf("usage: loxy projects ls (other operations: loxy api ...)") } - b, status, err := apiCall("GET", "/"+kind, nil) + b, status, err := apiCall("GET", "/projects", nil) if err != nil { return err } if status != 200 { return fmt.Errorf("HTTP %d: %s", status, b) } - type health struct { - Status string `json:"status"` - Healthy int `json:"healthy"` - Total int `json:"total"` - } - type bind struct { - Address string `json:"address"` - Port int64 `json:"port"` - } var res struct { - Frontends []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Mode string `json:"mode"` - TLS bool `json:"tls"` - Binds []bind `json:"binds"` - Status string `json:"status"` - Health health `json:"health"` - } `json:"frontends"` - Backends []struct { + Projects []struct { ID int64 `json:"id"` Name string `json:"name"` - Mode string `json:"mode"` - Algorithm string `json:"algorithm"` - Servers int `json:"servers_count"` - Health health `json:"health"` - } `json:"backends"` + Status string `json:"status"` + Endpoints []struct { + URL string `json:"url"` + } `json:"endpoints"` + Health struct { + Status string `json:"status"` + Healthy int `json:"healthy"` + Total int `json:"total"` + } `json:"health"` + } `json:"projects"` } if err := json.Unmarshal(b, &res); err != nil { return err } tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - if kind == "frontends" { - fmt.Fprintln(tw, "ID\tNAME\tMODE\tBINDS\tSTATUS\tHEALTH") - for _, f := range res.Frontends { - mode := f.Mode - if f.TLS { - mode += "+tls" - } - var bs []string - for _, x := range f.Binds { - bs = append(bs, net.JoinHostPort(x.Address, strconv.FormatInt(x.Port, 10))) - } - fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s %d/%d\n", f.ID, f.Name, mode, strings.Join(bs, ","), f.Status, f.Health.Status, f.Health.Healthy, f.Health.Total) - } - } else { - fmt.Fprintln(tw, "ID\tNAME\tMODE\tALGORITHM\tSERVERS\tHEALTH") - for _, x := range res.Backends { - fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%s %d/%d\n", x.ID, x.Name, x.Mode, x.Algorithm, x.Servers, x.Health.Status, x.Health.Healthy, x.Health.Total) + fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tHEALTH\tENDPOINTS") + for _, p := range res.Projects { + var eps []string + for _, e := range p.Endpoints { + eps = append(eps, e.URL) } + fmt.Fprintf(tw, "%d\t%s\t%s\t%s %d/%d\t%s\n", p.ID, p.Name, p.Status, p.Health.Status, p.Health.Healthy, p.Health.Total, strings.Join(eps, " ")) } return tw.Flush() } diff --git a/cmd/loxy/main.go b/cmd/loxy/main.go index d763bf4..0128f5a 100644 --- a/cmd/loxy/main.go +++ b/cmd/loxy/main.go @@ -1,4 +1,4 @@ -// Command loxy is the Loxy control plane: a self-hosted management panel for HAProxy 3.4. +// Command loxy is the Loxy control plane: a self-hosted load-balancer panel for HAProxy 3.4. package main import ( @@ -22,7 +22,7 @@ import ( var version = "dev" -const usage = `loxy — self-hosted management panel for HAProxy +const usage = `loxy — self-hosted load-balancer panel for HAProxy Usage: loxy all-in-one run panel + local node agent (default) @@ -33,8 +33,7 @@ Usage: loxy admin reset-password set a new random owner password loxy version print the version loxy api METHOD PATH [JSON] call the REST API (env LOXY_URL, LOXY_TOKEN) - loxy frontends ls list frontends (env LOXY_URL, LOXY_TOKEN) - loxy backends ls list backends (env LOXY_URL, LOXY_TOKEN) + loxy projects ls list projects (env LOXY_URL, LOXY_TOKEN) loxy panel | agent multi-node modes (Phase 3, not available yet) ` @@ -66,10 +65,8 @@ func run(cmd string, args []string) error { return nil case "api": return runAPI(args) - case "frontends", "fe": - return runList("frontends", args) - case "backends", "be": - return runList("backends", args) + case "projects", "project": + return runProjects(args) case "panel", "agent": return fmt.Errorf("%s mode is part of Phase 3 and not implemented yet; use all-in-one", cmd) } diff --git a/docs/adr/0005-product-direction.md b/docs/adr/0005-product-direction.md index d3ecceb..183b81d 100644 --- a/docs/adr/0005-product-direction.md +++ b/docs/adr/0005-product-direction.md @@ -1,6 +1,8 @@ # ADR 0005 — From cloud-LB clone to HAProxy management panel -Status: accepted · 2026-10-14 +> **Superseded by [ADR 0006](0006-projects.md)** (projects with targets, services and domains). Kept for history; frontends/backends are no longer user objects. + +Status: superseded · 2026-10-14 ## Context Phase 0/1 modelled Loxy on a cloud load-balancer console: "load balancer" diff --git a/docs/adr/0006-projects.md b/docs/adr/0006-projects.md new file mode 100644 index 0000000..e62e66c --- /dev/null +++ b/docs/adr/0006-projects.md @@ -0,0 +1,108 @@ +# ADR 0006 — Projects: simple load balancers with targets, services and domains + +Status: accepted · 2026-10-14 · supersedes ADR 0005 + +## Context +ADR 0005 turned Loxy into an HAProxy management panel whose user objects were +HAProxy's own: frontends (binds, TLS, redirect, routes), backends (algorithm, +health check, sticky, PROXY protocol) and servers. Working with it, the owner +found that model too complex for the everyday job: to publish one app a user +had to understand binds, `*` addresses, default backends, routing rules, +redirect listeners and mode matching. The owner's requirement: **"I want this +to be simple."** + +What users want is a load balancer they can create in one step, point at their +servers, and reach either on its own port or on a domain with HTTPS that just +works. + +## Decision +- **Project = one load balancer.** A user can have many. Each project has + Overview, Graphs, Targets, Services, Domains, Logs and Settings, plus an + algorithm (roundrobin/leastconn/source/random), a maintenance switch + (http 503 / tcp reject) and labels. +- **Targets** — where traffic goes: an IP (optionally `ip:port`), an inventory + host, or a label selector over host labels; weight, backup, drain/maintenance. + Each target is health-checked per service. +- **Services** — ports the project listens on: protocol `http | https | tcp`, + listen port → destination port, health check (tcp/http/https), sticky + sessions (http/https), PROXY protocol v1/v2, timeout, certificates (https). + A new project automatically gets an http service on **its own free port, + allocated from 20000 upward** (20000–29999); the listen port can be set explicitly. Listen + ports are unique across all projects. **Ports 80 and 443 are reserved for + domains**; the panel port, 8405, 5514 and loxy's internal listener port are + reserved too. +- **Domains** — attach a hostname to a project (optionally to one service; + default: the project's first http/https service). Traffic for the hostname on + the shared :80/:443 is routed to the project. **HTTPS is automatic**: once the + name resolves to the node, Loxy issues a certificate (Let's Encrypt HTTP-01 + through the shared :80, or DNS-01 through a DNS provider) and redirects + :80 → :443. Required DNS records are the node's addresses; optional automatic + A/AAAA records via a libdns DNS provider. +- **Compiled to the verified rendering engine.** Users and the API never see + frontends/backends. Internally each service `S` renders `frontend fe_` + (`bind 0.0.0.0:` + `ipv6@::: v6only` when the host has IPv6; + `ssl crt-list` for https) with `default_backend be_`, and `backend be_` + holds the project's targets as servers `s` / `sx` + (port = target override or service destination port) with the service's + health check, sticky cookie (`secure` on https) and PROXY protocol. Domains + render two shared frontends: `sys_http` on :80 (ACME HTTP-01 from + `virt@acme_http01`, 301 to https for domains whose certificate is issued, + `use_backend be_` by Host otherwise, 404 for unknown hosts) and `sys_https` + on :443 once a domain certificate is issued (`ssl crt-list` of domain + certificates, `use_backend` by Host, `X-Forwarded-Proto https`, 404 for + unknown hosts). HTTPS is automatic: when a domain verification finds the + name resolving to the node and `https` is on and no certificate exists yet, + a managed certificate is issued (HTTP-01, or DNS-01 when the domain has a DNS + provider); :80 redirects only once it is issued. Maintenance uses the `opt@` + maint map keyed by service id. Everything verified in ADR 0004 / `docs/haproxy-notes.md` + is kept: `sys_panel`, `sys_stats`, master-worker `-W`, `crt-base` relative + crt-lists, named defaults with `from`, runtime ops for target + add/remove/weight/state, reload otherwise, `haproxy -c` before going live. +- Metrics and graphs per project and per service (from `be_`, so domain + traffic is included), live logs per project. Alert rules filter by project + labels; node-wide events (apply, backup) are not filtered. +- Revisions + rollback, users/roles/API tokens/TOTP, notifications, + backup/restore and the Prometheus endpoint are unchanged. +- API: `/projects`, `/projects/{id}/services`, `/projects/{id}/targets`, + `/projects/{id}/metrics`, `/domains` (with `project_id`, `service_id`, + `https`) — see spec §12 and `api/openapi.yaml`. + +## Consequences +- Storage: the load-balancer project lives in a new table **`balancers`** + (foreign keys `balancer_id` on services, targets, target_slots, domains and + events), because `projects` is the existing tenancy table. The tenancy table + is unchanged; hosts, certificates, DNS providers, users and API tokens keep + their `project_id` (always 1), which is why they are usable by every + load-balancer project. The API and UI call a balancer a "project". +- Migration `0002_projects.sql`: frontend/backend definitions are dropped (they + do not map 1:1 onto projects and Loxy is pre-1.0) — `routes`, + `frontend_certificates`, `frontends`, `server_slots`, `backend_servers` and + `backends` are removed and `metrics_1m` is cleared; domains are copied + detached (`balancer_id` NULL, `https` 1; attach them to a project + afterwards); events are copied without their frontend/backend ids. Only + pre-release databases with a `load_balancers` table are refused, with a clear + error. +- Deleting a project deletes its domains (their auto-managed DNS records are + removed first); `ON DELETE SET NULL` only remains as a safety net. +- Frontends, backends, binds, routes and default backends are no longer + user-visible. The read-only Config view still shows the rendered HAProxy text. +- No per-address binds: services listen on all addresses of the node. Port + collisions reduce to "listen port unique + not reserved". +- The per-frontend HTTP→HTTPS redirect option and `sys_acme_fe` are + removed: **:80 redirects only for domains**, through `sys_http`. A service + port is served exactly as configured (http or https). +- Host/path routing rules are removed from v1; host routing happens only + through domains. Path routing inside a project is a Phase 4 item. +- A target is rendered in every service backend of its project, so target + runtime ops multiply by the number of services (still one command per + backend; no reload). +- Maintenance for http/https services is rendered in `be_` (one rule that covers the service port and domain traffic); tcp services reject in `fe_`. +- Exact-host `use_backend` rules are rendered before wildcard ones. Deleting the + last http/https service of a project with domains, or switching it to tcp, + returns 409. `LOXY_PANEL_PORT` 80/443 is refused at start. Automatic ports + come from 20000–29999. +- Project metrics are a new `pr` scope (sum of the project's service backends, + so domain traffic is included); `?service_id=` returns that service's backend + series. Health events are `target.health.changed` with the project id. +- Performance budgets are restated as 50 projects / 200 services / + 1 000 targets. diff --git a/docs/haproxy-notes.md b/docs/haproxy-notes.md index 87fd78e..0eab1b7 100644 --- a/docs/haproxy-notes.md +++ b/docs/haproxy-notes.md @@ -14,11 +14,13 @@ All items below are covered by `test/integration` (`make test-integration`, real | §7.6 dynamic servers | **Confirmed.** `add server` with the exact `render.ServerArgs` (incl. `cookie`) → `New server registered.`; `enable server`/`enable health` → empty output; `set server … weight/state` → empty; `del server` after maint → `Server deleted.` | `TestDynamicServers` | | §7.6 SSL runtime | **Confirmed.** `set ssl cert` → "Transaction created/updated", `commit ssl cert` → `Success!`, `new ssl cert` → "New empty certificate store", `add ssl crt-list` (payload) → `Success!`, `del ssl crt-list` → "deleted in crtlist", `del ssl cert` → "deleted!" (command available). | `TestVerifySSLRuntimeAndMaps` | | §7.6 maps | **Confirmed.** `opt@` is not part of the map name (runtime uses the plain path); `virt@acme_http01` works with `add map` and the `http-request return … lf-string` rule answers the challenge. | `TestVerifySSLRuntimeAndMaps` | +| §7.4 domains on shared `sys_http` (ADR 0006) | **Confirmed.** Host routing via `use_backend … if { req.hdr(host),field(1,:),lower -m str … }` routes a known domain to its service backend; `http-request return status 404 … unless ` answers unknown hosts; ACME HTTP-01 answers precede it. | `TestVerifySSLRuntimeAndMaps`, `TestACMEHTTP01Pebble` | +| §7.4 maintenance inside a backend | **Confirmed.** `http-request return status 503 … if { str(),map(opt@maint.map) -m found }` in `be_` (rendered when a domain routes to the service) answers 503 for domain traffic; the same map entry makes `fe_` answer 503 on the service port. | `TestVerifySSLRuntimeAndMaps` | | §14.7 minimal capability set | **Confirmed.** `--user 0:0 --group-add 1500 --cap-drop ALL --cap-add NET_BIND_SERVICE,SETUID,SETGID,KILL,CHOWN` starts, binds :80 and reloads. | `TestVerifyMinimalCapabilities` | ## Other findings -- **`master-worker` in `global` is deprecated since 3.3** (warning: "will be removed in 3.5"). Loxy relies on `-W` only and does not render the keyword (deviation from the §7.4 reference text). +- **`master-worker` in `global` is deprecated since 3.3** (warning: "will be removed in 3.5"). Loxy relies on `-W` only and does not render the keyword (ADR 0004 §1). - **Global `maxconn 200000` requires a matching `nofile` ulimit** (≈ 2×maxconn). With a lower hard limit the worker exits ("Cannot raise FD limit"). Compose sets `nofile: 1048576`; the value is configurable (`LOXY_HAPROXY_MAXCONN`), tests use 4000. - Starting as root without `chroot` logs a warning recommending `chroot auto`; still off by default (§7.4), to be enabled after a dedicated test. - `server-state-file` missing on first start only logs a NOTICE. @@ -37,4 +39,5 @@ All items below are covered by `test/integration` (`make test-integration`, real - `chroot auto` (§7.4) — not enabled. ## Retired -- The cross-frontend connection cap (`track-sc0 … table lb_cap`) was verified for the earlier load-balancer model and is no longer rendered: limits are per frontend (`maxconn`) since the frontend/backend rework (ADR 0005). Its test was removed. +- The cross-frontend connection cap (`track-sc0 … table lb_cap`) was verified for the earlier load-balancer model and is no longer rendered (ADR 0005). Its test was removed. +- Per-frontend HTTP→HTTPS redirect listeners (`fe__rd`) and `sys_acme_fe` are no longer rendered: since the projects model (ADR 0006) :80 is the shared `sys_http` frontend, which answers ACME HTTP-01 and redirects only domains. The verified building blocks (`virt@acme_http01`, crt-lists via `crt-base`, dynamic servers, maps) are unchanged; frontends/backends are now internal sections (`fe_`/`be_` per service) rather than user objects. diff --git a/docs/operations.md b/docs/operations.md index 0cc5405..ae79229 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -23,6 +23,14 @@ docker compose pull loxy && docker compose up -d loxy HAProxy keeps serving; no traffic impact. DB migrations run at start after an automatic pre-migration backup in `data/loxy/backups/`. +Upgrading from the frontend/backend version runs migration `0002_projects` +(ADR 0006): frontend and backend definitions are dropped and the 1-minute +metrics are cleared, so recreate them as projects (services + targets); domains +are kept but detached — attach them to the new projects on the Domains page. +Hosts, certificates, users, API tokens, DNS providers, events and settings are +kept. Only a pre-release database with a `load_balancers` table is refused at +start with an error; start from an empty data directory for that one. + ## Upgrade HAProxy Bump `HAPROXY_VERSION` **and** `LOXY_VERSION` to a loxy image built for that @@ -69,6 +77,7 @@ The reconciler then re-applies the restored state to HAProxy (validated with ## Firewall -Allow the ports your frontends bind publicly. Restrict the panel port (8443) to admin IPs +Open the project ports you use (the service listen ports, 20000+ by default), +plus 80/443 when you use domains. Restrict the panel port (8443) to admin IPs (`LOXY_PANEL_ALLOW` and/or host firewall). Never expose 9090 (internal API), 8405 (stats/metrics) or 5514 (syslog receiver). diff --git a/docs/screenshots/backend-graphs-dark.png b/docs/screenshots/backend-graphs-dark.png deleted file mode 100644 index 4fe9c5b..0000000 Binary files a/docs/screenshots/backend-graphs-dark.png and /dev/null differ diff --git a/docs/screenshots/backend-servers.png b/docs/screenshots/backend-servers.png deleted file mode 100644 index 326a939..0000000 Binary files a/docs/screenshots/backend-servers.png and /dev/null differ diff --git a/docs/screenshots/backends.png b/docs/screenshots/backends.png deleted file mode 100644 index c098e09..0000000 Binary files a/docs/screenshots/backends.png and /dev/null differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index eebc1c2..9235bc2 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/domains.png b/docs/screenshots/domains.png deleted file mode 100644 index eefc75d..0000000 Binary files a/docs/screenshots/domains.png and /dev/null differ diff --git a/docs/screenshots/frontend-new.png b/docs/screenshots/frontend-new.png deleted file mode 100644 index 93d5cfe..0000000 Binary files a/docs/screenshots/frontend-new.png and /dev/null differ diff --git a/docs/screenshots/frontend.png b/docs/screenshots/frontend.png deleted file mode 100644 index 16e5214..0000000 Binary files a/docs/screenshots/frontend.png and /dev/null differ diff --git a/docs/screenshots/frontends-dark.png b/docs/screenshots/frontends-dark.png deleted file mode 100644 index dbbb094..0000000 Binary files a/docs/screenshots/frontends-dark.png and /dev/null differ diff --git a/docs/screenshots/frontends.png b/docs/screenshots/frontends.png deleted file mode 100644 index 3257514..0000000 Binary files a/docs/screenshots/frontends.png and /dev/null differ diff --git a/docs/screenshots/mobile-project-overview.png b/docs/screenshots/mobile-project-overview.png new file mode 100644 index 0000000..598c006 Binary files /dev/null and b/docs/screenshots/mobile-project-overview.png differ diff --git a/docs/screenshots/mobile-projects.png b/docs/screenshots/mobile-projects.png new file mode 100644 index 0000000..0da53a4 Binary files /dev/null and b/docs/screenshots/mobile-projects.png differ diff --git a/docs/screenshots/project-domains.png b/docs/screenshots/project-domains.png new file mode 100644 index 0000000..415608e Binary files /dev/null and b/docs/screenshots/project-domains.png differ diff --git a/docs/screenshots/project-graphs.png b/docs/screenshots/project-graphs.png new file mode 100644 index 0000000..1bc9a8a Binary files /dev/null and b/docs/screenshots/project-graphs.png differ diff --git a/docs/screenshots/project-new.png b/docs/screenshots/project-new.png new file mode 100644 index 0000000..be45fa5 Binary files /dev/null and b/docs/screenshots/project-new.png differ diff --git a/docs/screenshots/project-overview-dark.png b/docs/screenshots/project-overview-dark.png new file mode 100644 index 0000000..ce9afb3 Binary files /dev/null and b/docs/screenshots/project-overview-dark.png differ diff --git a/docs/screenshots/project-overview.png b/docs/screenshots/project-overview.png new file mode 100644 index 0000000..d3b1071 Binary files /dev/null and b/docs/screenshots/project-overview.png differ diff --git a/docs/screenshots/project-services.png b/docs/screenshots/project-services.png new file mode 100644 index 0000000..4236bed Binary files /dev/null and b/docs/screenshots/project-services.png differ diff --git a/docs/screenshots/project-targets.png b/docs/screenshots/project-targets.png new file mode 100644 index 0000000..3a9cb59 Binary files /dev/null and b/docs/screenshots/project-targets.png differ diff --git a/docs/screenshots/projects-dark.png b/docs/screenshots/projects-dark.png new file mode 100644 index 0000000..27d8237 Binary files /dev/null and b/docs/screenshots/projects-dark.png differ diff --git a/docs/screenshots/projects.png b/docs/screenshots/projects.png new file mode 100644 index 0000000..db4bc6b Binary files /dev/null and b/docs/screenshots/projects.png differ diff --git a/docs/screenshots/system.png b/docs/screenshots/system.png deleted file mode 100644 index ce42a33..0000000 Binary files a/docs/screenshots/system.png and /dev/null differ diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 03540b6..bb264aa 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -9,6 +10,7 @@ import ( "net/http/cookiejar" "net/http/httptest" "path/filepath" + "strings" "testing" "time" @@ -82,7 +84,7 @@ func fields(b map[string]any) map[string]bool { func TestAuthAndCSRF(t *testing.T) { c, _ := setup(t) - if st, _ := c.req("GET", "/frontends", nil); st != 401 { + if st, _ := c.req("GET", "/projects", nil); st != 401 { t.Fatalf("unauthenticated: %d", st) } if st, _ := c.req("POST", "/auth/login", map[string]any{"email": "admin@loxy.local", "password": "wrong"}); st != 401 { @@ -91,7 +93,7 @@ func TestAuthAndCSRF(t *testing.T) { login(c) csrf := c.csrf c.csrf = "" - if st, _ := c.req("POST", "/backends", map[string]any{"name": "x", "mode": "http", "port": 80}); st != 403 { + if st, _ := c.req("POST", "/projects", map[string]any{"name": "x"}); st != 403 { t.Fatalf("missing csrf: %d", st) } c.csrf = csrf @@ -106,127 +108,139 @@ func TestAuthAndCSRF(t *testing.T) { v := &tc{t: t, srv: c.srv, hc: &http.Client{Jar: jar}} _, b := v.req("POST", "/auth/login", map[string]any{"email": "v@x.io", "password": "viewer-password-1"}) v.csrf = b["csrf_token"].(string) - if st, _ := v.req("POST", "/backends", map[string]any{"name": "x", "mode": "http", "port": 80}); st != 403 { + if st, _ := v.req("POST", "/projects", map[string]any{"name": "x"}); st != 403 { t.Fatalf("viewer mutate: %d", st) } - if st, _ := v.req("GET", "/frontends", nil); st != 200 { + if st, _ := v.req("GET", "/projects", nil); st != 200 { t.Fatalf("viewer read: %d", st) } } -// mkBackend creates an http backend with the given IP servers and returns its id. -func mkBackend(c *tc, name string, ips ...string) float64 { +// mkProject creates a project (own auto port, http → destination 8080) with IP targets. +func mkProject(c *tc, name string, ips ...string) (float64, map[string]any) { c.t.Helper() - st, b := c.req("POST", "/backends", map[string]any{"name": name, "mode": "http", "port": 8080}) - if st != 201 { - c.t.Fatalf("backend %s: %d %v", name, st, b) + var targets []any + for _, ip := range ips { + targets = append(targets, map[string]any{"type": "ip", "ip": ip}) } - id := b["backend"].(map[string]any)["id"].(float64) - if len(ips) > 0 { - var srv []any - for _, ip := range ips { - srv = append(srv, map[string]any{"type": "ip", "ip": ip}) - } - if st, b = c.req("POST", fmt.Sprintf("/backends/%v/servers", id), map[string]any{"servers": srv}); st != 201 { - c.t.Fatalf("servers: %d %v", st, b) - } + body := map[string]any{"name": name, "service": map[string]any{"protocol": "http", "destination_port": 8080}} + if len(targets) > 0 { + body["targets"] = targets + } + st, b := c.req("POST", "/projects", body) + if st != 201 { + c.t.Fatalf("project %s: %d %v", name, st, b) } - return id + p := b["project"].(map[string]any) + return p["id"].(float64), p } -func TestFrontendBackendLifecycle(t *testing.T) { +func TestProjectLifecycle(t *testing.T) { c, a := setup(t) login(c) - web := mkBackend(c, "web", "10.0.0.5", "10.0.0.6") - api := mkBackend(c, "api", "10.0.0.7") - // backend validation (§8.4): timeout ≥ interval, bad path, sticky on tcp - st, b := c.req("POST", "/backends", map[string]any{"name": "bad", "mode": "tcp", "port": 1, "sticky_sessions": true, + id, p := mkProject(c, "shop", "10.0.0.5", "10.0.0.6") + // a new project gets its own free port (20000+) and shows it as an endpoint + eps := p["endpoints"].([]any) + if len(eps) != 1 || !strings.Contains(eps[0].(map[string]any)["url"].(string), ":20000") { + t.Fatalf("endpoints %v", eps) + } + if cnt := p["counts"].(map[string]any); cnt["targets"].(float64) != 2 || cnt["services"].(float64) != 1 { + t.Fatalf("counts %v", cnt) + } + // the second project gets the next port + _, p2 := mkProject(c, "api") + if u := p2["endpoints"].([]any)[0].(map[string]any)["url"].(string); !strings.Contains(u, ":20001") { + t.Fatalf("second auto port: %s", u) + } + // duplicate name + if st, b := c.req("POST", "/projects", map[string]any{"name": "SHOP"}); st != 422 || !fields(b)["name"] { + t.Fatalf("duplicate name: %d %v", st, b) + } + // service validation (§8.5): reserved/taken ports, sticky on tcp, timeout ≥ interval, bad path, https without cert + svc := fmt.Sprintf("/projects/%v/services", id) + st, b := c.req("POST", svc, map[string]any{"protocol": "tcp", "listen_port": 20001, "destination_port": 1, "sticky_sessions": true, "health_check": map[string]any{"protocol": "http", "interval_s": 5, "timeout_s": 5, "http": map[string]any{"path": "no-slash"}}}) f := fields(b) - for _, want := range []string{"sticky_sessions", "health_check.timeout_s", "health_check.http.path"} { + for _, want := range []string{"listen_port", "sticky_sessions", "health_check.timeout_s", "health_check.http.path"} { if !f[want] { t.Errorf("expected field error %s, got %v (%d)", want, f, st) } } - // servers: multicast rejected; label selectors resolve against the host inventory - if st, b = c.req("POST", fmt.Sprintf("/backends/%v/servers", web), map[string]any{"servers": []any{map[string]any{"type": "ip", "ip": "224.0.0.1"}}}); st != 422 { - t.Fatalf("multicast server: %d %v", st, b) - } - if st, b = c.req("POST", "/hosts", map[string]any{"name": "h1", "ipv4": "10.2.0.1", "labels": map[string]string{"role": "web"}}); st != 201 { - t.Fatalf("host %d %v", st, b) + for _, port := range []int{80, 443, 8443} { + if st, b = c.req("POST", svc, map[string]any{"protocol": "http", "listen_port": port, "destination_port": 80}); st != 422 || !fields(b)["listen_port"] { + t.Fatalf("reserved port %d: %d %v", port, st, b) + } } - st, b = c.req("POST", fmt.Sprintf("/backends/%v/servers", web), map[string]any{"servers": []any{map[string]any{"type": "label_selector", "label_selector": "role=web"}}}) - if st != 201 || len(b["servers"].([]any)[0].(map[string]any)["resolved"].([]any)) != 1 { - t.Fatalf("selector: %d %v", st, b) + if st, b = c.req("POST", svc, map[string]any{"protocol": "https", "destination_port": 8080}); st != 422 || !fields(b)["certificates"] { + t.Fatalf("https without cert: %d %v", st, b) } - // frontend validation (§8.2): tls without cert, panel port, routes need a backend of the same mode - tcpBE := 0.0 - if st, b = c.req("POST", "/backends", map[string]any{"name": "pg", "mode": "tcp", "port": 5432}); st == 201 { - tcpBE = b["backend"].(map[string]any)["id"].(float64) + st, b = c.req("POST", svc, map[string]any{"protocol": "tcp", "listen_port": 15432, "destination_port": 5432}) + if st != 201 { + t.Fatalf("tcp service: %d %v", st, b) } - st, b = c.req("POST", "/frontends", map[string]any{"name": "x", "mode": "http", "tls": true, "binds": []any{map[string]any{"address": "*", "port": 8443}}, - "default_backend_id": tcpBE}) - f = fields(b) - for _, want := range []string{"certificates", "binds[0].port", "default_backend_id"} { - if !f[want] { - t.Errorf("expected field error %s, got %v (%d)", want, f, st) - } + tcpID := b["service"].(map[string]any)["id"].(float64) + // targets: multicast rejected, ip:port accepted, label selectors resolve against hosts + tg := fmt.Sprintf("/projects/%v/targets", id) + if st, b = c.req("POST", tg, map[string]any{"targets": []any{map[string]any{"type": "ip", "ip": "224.0.0.1"}}}); st != 422 { + t.Fatalf("multicast target: %d %v", st, b) } - st, b = c.req("POST", "/frontends", map[string]any{"name": "edge", "mode": "http", "labels": map[string]string{"env": "prod"}, - "binds": []any{map[string]any{"address": "*", "port": 80}}, "default_backend_id": web, - "routes": []any{map[string]any{"hosts": []string{"api.example.com"}, "path_prefix": "/v1", "backend_id": api}}}) - if st != 201 { - t.Fatalf("create frontend %d %v", st, b) + if st, b = c.req("POST", tg, map[string]any{"targets": []any{map[string]any{"type": "ip", "ip": "10.0.0.7:9000"}}}); st != 201 || + b["targets"].([]any)[0].(map[string]any)["port"].(float64) != 9000 { + t.Fatalf("ip:port target: %d %v", st, b) } - fe := b["frontend"].(map[string]any) - id := fe["id"].(float64) - if len(fe["routes"].([]any)) != 1 { - t.Fatalf("routes %v", fe["routes"]) + if st, b = c.req("POST", "/hosts", map[string]any{"name": "h1", "ipv4": "10.2.0.1", "labels": map[string]string{"role": "web"}}); st != 201 { + t.Fatalf("host %d %v", st, b) } - // a second frontend on the same port collides - if st, b = c.req("POST", "/frontends", map[string]any{"name": "dup", "mode": "http", "binds": []any{map[string]any{"address": "*", "port": 80}}, "default_backend_id": web}); st != 422 || !fields(b)["binds"] { - t.Fatalf("bind collision: %d %v", st, b) + st, b = c.req("POST", tg, map[string]any{"targets": []any{map[string]any{"type": "label_selector", "label_selector": "role=web"}}}) + if st != 201 || len(b["targets"].([]any)[0].(map[string]any)["resolved"].([]any)) != 1 { + t.Fatalf("selector: %d %v", st, b) } - // a backend in use cannot be deleted - if st, _ = c.req("DELETE", fmt.Sprintf("/backends/%v", api), nil); st != 409 { - t.Fatalf("delete in-use backend: %d", st) + // every resolved target carries one check per service + _, b = c.req("GET", tg, nil) + r0 := b["targets"].([]any)[0].(map[string]any)["resolved"].([]any)[0].(map[string]any) + if len(r0["checks"].([]any)) != 2 { + t.Fatalf("checks per service: %v", r0) } - // maintenance toggle - if st, b = c.req("PATCH", fmt.Sprintf("/frontends/%v", id), map[string]any{"maintenance": true}); st != 200 || b["frontend"].(map[string]any)["maintenance"] != true { - t.Fatalf("maintenance: %d %v", st, b) + // maintenance + rename + if st, b = c.req("PATCH", fmt.Sprintf("/projects/%v", id), map[string]any{"maintenance": true, "name": "shop-2"}); st != 200 || + b["project"].(map[string]any)["maintenance"] != true || b["project"].(map[string]any)["name"] != "shop-2" { + t.Fatalf("patch: %d %v", st, b) } - // config endpoint renders this frontend's sections only - r, _ := c.hc.Get(c.srv.URL + fmt.Sprintf("/api/v1/frontends/%v/config", id)) + // config shows the project's services only + r, _ := c.hc.Get(c.srv.URL + fmt.Sprintf("/api/v1/projects/%v/config", id)) cfgText, _ := io.ReadAll(r.Body) r.Body.Close() - for _, want := range []string{fmt.Sprintf("frontend fe_%v", id), fmt.Sprintf("use_backend be_%v", api), fmt.Sprintf("default_backend be_%v", web)} { + for _, want := range []string{"frontend fe_1 ", "bind 0.0.0.0:20000", fmt.Sprintf("frontend fe_%v from loxy_tcp", tcpID), "backend be_1 "} { if !bytes.Contains(cfgText, []byte(want)) { t.Fatalf("config missing %q:\n%s", want, cfgText) } } - if bytes.Contains(cfgText, []byte("sys_panel")) { - t.Fatalf("config leaks system sections:\n%s", cfgText) + if bytes.Contains(cfgText, []byte("sys_panel")) || bytes.Contains(cfgText, []byte("fe_2 ")) { + t.Fatalf("config leaks other sections:\n%s", cfgText) } - // backend status lists resolved servers (2 IPs + 1 host) - if st, b = c.req("GET", fmt.Sprintf("/backends/%v/status", web), nil); st != 200 || len(b["servers"].([]any)) != 3 { - t.Fatalf("status %d %v", st, b) + // metrics per project and per service + if st, b = c.req("GET", fmt.Sprintf("/projects/%v/metrics?type=requests_per_second", id), nil); st != 200 || b["series"] == nil { + t.Fatalf("metrics %d %v", st, b) + } + if st, _ = c.req("GET", fmt.Sprintf("/projects/%v/metrics?service_id=999", id), nil); st != 422 { + t.Fatalf("foreign service metrics: %d", st) } if st, b = c.req("GET", "/audit", nil); st != 200 || len(b["audit"].([]any)) < 5 { t.Fatalf("audit %d %v", st, b) } - if st, _ = c.req("DELETE", fmt.Sprintf("/frontends/%v", id), nil); st != 204 { + if st, _ = c.req("DELETE", fmt.Sprintf("/projects/%v/services/%v", id, tcpID), nil); st != 204 { + t.Fatalf("delete service: %d", st) + } + if st, _ = c.req("DELETE", fmt.Sprintf("/projects/%v", id), nil); st != 204 { t.Fatalf("delete: %d", st) } - if a.Snap.Get().Frontend(int64(id)) != nil { + if a.Snap.Get().Project(int64(id)) != nil || a.Snap.Get().Frontend(1) != nil { t.Fatal("still in snapshot") } - if st, _ = c.req("DELETE", fmt.Sprintf("/backends/%v", api), nil); st != 204 { - t.Fatalf("delete freed backend: %d", st) - } } func TestCertificatesAndDomains(t *testing.T) { - c, _ := setup(t) + c, a := setup(t) login(c) crt, key, _ := certs.SelfSigned("app.example.com", []string{"app.example.com"}, 48*time.Hour) st, b := c.req("POST", "/certificates", map[string]any{"name": "app", "type": "uploaded", "certificate": crt, "private_key": key}) @@ -241,36 +255,69 @@ func TestCertificatesAndDomains(t *testing.T) { if st, b = c.req("POST", "/certificates", map[string]any{"name": "w", "type": "managed", "domains": []string{"*.example.com"}, "challenge": "http01"}); st != 422 { t.Fatalf("wildcard http01: %d %v", st, b) } - be := mkBackend(c, "app", "10.0.0.5") - st, b = c.req("POST", "/frontends", map[string]any{"name": "tls", "mode": "http", "tls": true, "http_redirect": true, "certificates": []float64{certID}, - "binds": []any{map[string]any{"address": "*", "port": 443}}, "default_backend_id": be}) - if st != 201 { - t.Fatalf("tls frontend %d %v", st, b) + id, _ := mkProject(c, "web", "10.0.0.5") + st, b = c.req("POST", fmt.Sprintf("/projects/%v/services", id), map[string]any{"protocol": "https", "destination_port": 8080, "certificates": []float64{certID, certID}}) + if st != 422 || !fields(b)["certificates"] { + t.Fatalf("duplicate certs: %d %v", st, b) } - feID := b["frontend"].(map[string]any)["id"].(float64) - // the redirect occupies :80 - if st, b = c.req("POST", "/frontends", map[string]any{"name": "plain", "mode": "http", "binds": []any{map[string]any{"address": "*", "port": 80}}, "default_backend_id": be}); st != 422 { - t.Fatalf(":80 collision: %d %v", st, b) + if st, b = c.req("POST", fmt.Sprintf("/projects/%v/services", id), map[string]any{"protocol": "https", "destination_port": 8080, "certificates": []float64{certID}}); st != 201 { + t.Fatalf("https service %d %v", st, b) } // cert in use cannot be deleted if st, _ = c.req("DELETE", fmt.Sprintf("/certificates/%v", certID), nil); st != 409 { t.Fatalf("delete in-use cert: %d", st) } - // domains - st, b = c.req("POST", "/domains", map[string]any{"fqdn": "app.invalid", "frontend_id": feID}) + // domains attach to a project; required records = the server's addresses + c.req("POST", "/nodes/1/ips", map[string]any{"address": "192.0.2.10"}) + c.req("POST", "/nodes/1/ips", map[string]any{"address": "2001:db8::10"}) + if st, b = c.req("POST", "/domains", map[string]any{"fqdn": "app.invalid"}); st != 422 || !fields(b)["project_id"] { + t.Fatalf("domain without project: %d %v", st, b) + } + st, b = c.req("POST", "/domains", map[string]any{"fqdn": "app.invalid", "project_id": id}) if st != 201 { t.Fatalf("domain %d %v", st, b) } d := b["domain"].(map[string]any) - if d["status"] == "ok" { - t.Fatalf("unexpected ok: %v", d) + if d["status"] == "ok" || d["https"] != true || d["url"] != "http://app.invalid" || d["service_id"] == nil { + t.Fatalf("unexpected domain view: %v", d) + } + types := map[string]any{} + for _, r := range d["required_records"].([]any) { + types[r.(map[string]any)["type"].(string)] = r.(map[string]any)["value"] + } + if types["A"] != "192.0.2.10" || types["AAAA"] != "2001:db8::10" { + t.Fatalf("required records %v", types) + } + // not resolving → no automatic certificate, manual issue refused + if d["certificate_id"] != nil { + t.Fatalf("certificate issued for an unresolved domain: %v", d) } if st, b = c.req("POST", fmt.Sprintf("/domains/%v/actions/issue_certificate", d["id"]), nil); st != 422 { t.Fatalf("issue before verified: %d %v", st, b) } - if st, b = c.req("POST", "/domains", map[string]any{"fqdn": "*.example.com", "frontend_id": feID}); st != 422 { + if st, b = c.req("POST", "/domains", map[string]any{"fqdn": "*.example.com", "project_id": id}); st != 422 { t.Fatalf("wildcard without provider: %d %v", st, b) } + if st, b = c.req("POST", "/domains", map[string]any{"fqdn": "app.invalid", "project_id": id}); st != 422 || !fields(b)["fqdn"] { + t.Fatalf("duplicate domain: %d %v", st, b) + } + // the domain is routed on the shared :80 frontend to the project's first http service + out, err := a.Rec.RenderLive(context.Background()) + if err != nil || !strings.Contains(out.Config(), "frontend sys_http") || !strings.Contains(out.Config(), "use_backend be_1 if { req.hdr(host),field(1,:),lower -m str app.invalid }") { + t.Fatalf("domain routing: %v\n%s", err, out.Config()) + } + // the last http/https service cannot go while domains need it + _, b = c.req("GET", fmt.Sprintf("/projects/%v/services", id), nil) + for _, sv := range b["services"].([]any) { + sid := sv.(map[string]any)["id"].(float64) + st, _ := c.req("DELETE", fmt.Sprintf("/projects/%v/services/%v", id, sid), nil) + if sid == 1 && st != 204 { + t.Fatalf("delete first web service while another exists: %d", st) + } + if sid != 1 && st != 409 { + t.Fatalf("delete last web service with domains: %d", st) + } + } if st, b = c.req("POST", "/dns_providers", map[string]any{"name": "x", "kind": "nope", "credentials": map[string]string{}}); st != 422 { t.Fatalf("bad provider kind: %d %v", st, b) } @@ -302,20 +349,20 @@ func TestAPITokens(t *testing.T) { resp.Body.Close() return resp.StatusCode } - if s := call("POST", "/backends", tok, map[string]any{"name": "via-token", "mode": "http", "port": 80}); s != 201 { - t.Fatalf("operator token create backend: %d", s) + if s := call("POST", "/projects", tok, map[string]any{"name": "via-token"}); s != 201 { + t.Fatalf("operator token create project: %d", s) } if s := call("POST", "/users", tok, map[string]any{"email": "a@b.c", "password": "xxxxxxxxxxxxxx", "role": "viewer"}); s != 403 { t.Fatalf("operator token admin action: %d", s) } - if s := call("GET", "/frontends", tok+"x", nil); s != 401 { + if s := call("GET", "/projects", tok+"x", nil); s != 401 { t.Fatalf("bad token: %d", s) } id := b["api_token"].(map[string]any)["id"].(float64) if st, _ := c.req("DELETE", fmt.Sprintf("/api_tokens/%v", id), nil); st != 204 { t.Fatal("revoke") } - if s := call("GET", "/frontends", tok, nil); s != 401 { + if s := call("GET", "/projects", tok, nil); s != 401 { t.Fatalf("revoked token: %d", s) } } @@ -347,7 +394,7 @@ func TestTOTP(t *testing.T) { _ = a } -// Review findings #3, #4, #5, #8. +// Review findings #3, #5, #8. func TestReviewRegressions(t *testing.T) { c, _ := setup(t) login(c) @@ -355,28 +402,9 @@ func TestReviewRegressions(t *testing.T) { if st, b := c.req("POST", "/nodes/1/ips", map[string]any{"address": "2001:db8::1%x\n http-request deny"}); st != 422 { t.Fatalf("zone accepted: %d %v", st, b) } - // #4: a wildcard frontend on a dual-stack node needs both A and AAAA records. - c.req("POST", "/nodes/1/ips", map[string]any{"address": "192.0.2.10"}) - c.req("POST", "/nodes/1/ips", map[string]any{"address": "2001:db8::10"}) - be := mkBackend(c, "dual") - st, b := c.req("POST", "/frontends", map[string]any{"name": "dual", "mode": "http", "binds": []any{map[string]any{"address": "*", "port": 80}}, "default_backend_id": be}) - if st != 201 { - t.Fatalf("dual-stack create: %d %v", st, b) - } - id := b["frontend"].(map[string]any)["id"].(float64) - st, b = c.req("POST", "/domains", map[string]any{"fqdn": "dual.invalid", "frontend_id": id}) - if st != 201 { - t.Fatalf("domain %d %v", st, b) - } - types := map[string]any{} - for _, r := range b["domain"].(map[string]any)["required_records"].([]any) { - types[r.(map[string]any)["type"].(string)] = r.(map[string]any)["value"] - } - if types["A"] != "192.0.2.10" || types["AAAA"] != "2001:db8::10" { - t.Fatalf("required records %v", types) - } + id, _ := mkProject(c, "m") // #3: absurd metric ranges are clamped instead of exhausting memory. - st, b = c.req("GET", fmt.Sprintf("/frontends/%v/metrics?start=1&end=9000000000000000000&type=open_connections,bogus", id), nil) + st, b := c.req("GET", fmt.Sprintf("/projects/%v/metrics?start=1&end=9000000000000000000&type=open_connections,bogus", id), nil) if st != 200 || len(b["timestamps"].([]any)) > 2100 || len(b["series"].(map[string]any)) != 1 { t.Fatalf("metrics clamp: %d points=%d series=%v", st, len(b["timestamps"].([]any)), b["series"] != nil) } @@ -401,51 +429,14 @@ func TestReviewRegressions(t *testing.T) { } } -// Review round 2: listener collisions via enable/domains, duplicate certs, health-check defaults. -func TestFrontendBackendRegressions(t *testing.T) { - c, _ := setup(t) +// live.tick carries per-project status, health and traffic for real-time pages. +func TestLiveSnapshot(t *testing.T) { + c, a := setup(t) login(c) - be := mkBackend(c, "pool") - // http backends default to http checks; an explicit check port equal to the backend port is kept. - st, b := c.req("POST", "/backends", map[string]any{"name": "hc", "mode": "http", "port": 80, "health_check": map[string]any{"port": 80}}) - if st != 201 { - t.Fatalf("backend %d %v", st, b) - } - hc := b["backend"].(map[string]any)["health_check"].(map[string]any) - if hc["protocol"] != "http" || hc["port"] != float64(80) { - t.Fatalf("health check %v", hc) - } - if st, b = c.req("POST", "/backends", map[string]any{"name": "fast", "mode": "http", "port": 80, "health_check": map[string]any{"interval_s": 2, "timeout_s": 1}}); st != 422 || !fields(b)["health_check.interval_s"] { - t.Fatalf("interval < 3 accepted: %d %v", st, b) - } - // duplicate certificate ids are a field error, not a 500. - crt, key, _ := certs.SelfSigned("d.example.com", []string{"d.example.com"}, 48*time.Hour) - _, b = c.req("POST", "/certificates", map[string]any{"name": "d", "type": "uploaded", "certificate": crt, "private_key": key}) - cid := b["certificate"].(map[string]any)["id"].(float64) - if st, b = c.req("POST", "/frontends", map[string]any{"name": "dup", "mode": "http", "tls": true, "certificates": []float64{cid, cid}, - "binds": []any{map[string]any{"address": "*", "port": 443}}, "default_backend_id": be}); st != 422 || !fields(b)["certificates"] { - t.Fatalf("duplicate certs: %d %v", st, b) - } - // a disabled frontend on a taken port cannot be enabled. - mk := func(name string, port int, extra map[string]any) float64 { - body := map[string]any{"name": name, "mode": "http", "binds": []any{map[string]any{"address": "*", "port": port}}, "default_backend_id": be} - for k, v := range extra { - body[k] = v - } - st, b := c.req("POST", "/frontends", body) - if st != 201 { - t.Fatalf("frontend %s: %d %v", name, st, b) - } - return b["frontend"].(map[string]any)["id"].(float64) - } - mk("a", 80, nil) - off := mk("b", 80, map[string]any{"enabled": false}) - if st, b = c.req("PATCH", fmt.Sprintf("/frontends/%v", off), map[string]any{"enabled": true}); st != 422 || !fields(b)["enabled"] { - t.Fatalf("enable onto taken port: %d %v", st, b) - } - // attaching a domain adds a :80 ACME listener, which collides with frontend a. - tcp := mk("c", 8081, nil) - if st, b = c.req("POST", "/domains", map[string]any{"fqdn": "c.invalid", "frontend_id": tcp}); st != 422 || !fields(b)["frontend_id"] { - t.Fatalf("domain ACME :80 collision: %d %v", st, b) + id, _ := mkProject(c, "live", "10.0.0.5") + snap := a.API.LiveSnapshotForTest() + ps := snap["projects"].([]map[string]any) + if len(ps) != 1 || ps[0]["id"].(int64) != int64(id) || ps[0]["health_sig"] == "" || snap["traffic"] == nil { + t.Fatalf("live snapshot %v", snap) } } diff --git a/internal/api/backends.go b/internal/api/backends.go deleted file mode 100644 index 03599c8..0000000 --- a/internal/api/backends.go +++ /dev/null @@ -1,523 +0,0 @@ -package api - -import ( - "database/sql" - "fmt" - "net/http" - "net/netip" - "strings" - - "github.com/2shiraz/loxy/internal/api/gen" - "github.com/2shiraz/loxy/internal/metrics" - "github.com/2shiraz/loxy/internal/model" - "github.com/2shiraz/loxy/internal/state" - "github.com/2shiraz/loxy/internal/store/db" -) - -var algorithms = map[string]bool{"roundrobin": true, "leastconn": true, "source": true, "random": true} - -func (s *Server) backend(id int64) (*state.Backend, error) { - b := s.Snap.Get().Backend(id) - if b == nil { - return nil, errNotFound("backend") - } - return b, nil -} - -func (s *Server) respondBackend(w http.ResponseWriter, status int, id int64) { - b := s.Snap.Get().Backend(id) - if b == nil { - w.WriteHeader(204) - return - } - writeJSON(w, status, map[string]any{"backend": s.viewBackend(b)}) -} - -func (s *Server) ListBackends(w http.ResponseWriter, r *http.Request, p gen.ListBackendsParams) { - var sel model.Selector - if p.LabelSelector != nil && *p.LabelSelector != "" { - var err error - if sel, err = model.ParseSelector(*p.LabelSelector); err != nil { - fail(w, errField("label_selector", err.Error())) - return - } - } - out := []map[string]any{} - for _, b := range s.Snap.Get().Backends { - if sel != nil && !sel.Matches(b.Labels) { - continue - } - out = append(out, s.viewBackend(b)) - } - writeJSON(w, 200, map[string]any{"backends": out}) -} - -func (s *Server) GetBackend(w http.ResponseWriter, r *http.Request, id gen.Id) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - writeJSON(w, 200, map[string]any{"backend": s.viewBackend(b)}) -} - -// buildBackend validates a backend input (§8.6). -func (s *Server) buildBackend(existing *state.Backend, in gen.BackendInput) (db.CreateBackendParams, error) { - fe := model.FieldErrors{} - p := db.CreateBackendParams{Name: strings.TrimSpace(in.Name), Mode: string(in.Mode), Algorithm: "roundrobin", Port: int64(in.Port), - StickyCookieName: "LOXYSTICKY", StickyCookieLifetimeS: 300, ProxyProtocol: "none", TimeoutServerS: 60, - HcProtocol: "tcp", HcIntervalS: 15, HcTimeoutS: 10, HcRetries: 3, HcPath: "/", Labels: "{}", CreatedAt: nowMs()} - if !model.ValidName(p.Name) { - fe.Add("name", "1–64 printable characters") - } - for _, o := range s.Snap.Get().Backends { - if o.Name == p.Name && (existing == nil || o.ID != existing.ID) { - fe.Add("name", "already in use") - } - } - if p.Mode != "http" && p.Mode != "tcp" { - fe.Add("mode", "must be http or tcp") - } - if existing != nil && existing.Mode != p.Mode { - for _, fid := range s.Snap.Get().UsedBy(existing.ID) { - fe.Add("mode", fmt.Sprintf("used by frontend %d in %s mode; change the frontend first", fid, existing.Mode)) - break - } - } - if in.Description != nil { - p.Description = strings.TrimSpace(*in.Description) - if len(p.Description) > 500 { - fe.Add("description", "at most 500 characters") - } - } - if in.Algorithm != nil { - p.Algorithm = string(*in.Algorithm) - } - if !algorithms[p.Algorithm] { - fe.Add("algorithm", "must be roundrobin, leastconn, source or random") - } - if !model.ValidPort(in.Port) { - fe.Add("port", "must be 1–65535") - } - if in.ProxyProtocol != nil { - p.ProxyProtocol = string(*in.ProxyProtocol) - if p.ProxyProtocol != "none" && p.ProxyProtocol != "v1" && p.ProxyProtocol != "v2" { - fe.Add("proxy_protocol", "must be none, v1 or v2") - } - } - if in.TimeoutServerS != nil { - p.TimeoutServerS = int64(*in.TimeoutServerS) - } - if p.TimeoutServerS < 1 || p.TimeoutServerS > 86400 { - fe.Add("timeout_server_s", "must be 1–86400") - } - if in.StickySessions != nil { - p.StickyEnabled = b2i(*in.StickySessions) - } - if in.CookieName != nil && *in.CookieName != "" { - p.StickyCookieName = *in.CookieName - } - if in.CookieLifetimeS != nil { - p.StickyCookieLifetimeS = int64(*in.CookieLifetimeS) - } - if p.StickyEnabled != 0 && p.Mode == "tcp" { - fe.Add("sticky_sessions", "only available in http mode") - } - if !model.ValidCookieName(p.StickyCookieName) { - fe.Add("cookie_name", "letters, digits, - and _ only (1–64)") - } - if p.StickyCookieLifetimeS < 1 || p.StickyCookieLifetimeS > 31536000 { - fe.Add("cookie_lifetime_s", "must be 1–31536000") - } - codes := []string{"2??", "3??"} - if p.Mode == "http" { - p.HcProtocol = "http" // §8.6: http checks by default for http backends - } - if hc := in.HealthCheck; hc != nil { - if hc.Protocol != nil { - p.HcProtocol = string(*hc.Protocol) - } - if hc.Port != nil { - p.HcPort = int64(*hc.Port) - } - if hc.IntervalS != nil { - p.HcIntervalS = int64(*hc.IntervalS) - } - if hc.TimeoutS != nil { - p.HcTimeoutS = int64(*hc.TimeoutS) - } - if hc.Retries != nil { - p.HcRetries = int64(*hc.Retries) - } - if h := hc.Http; h != nil && p.HcProtocol != "tcp" { - if h.Domain != nil { - p.HcDomain = strings.ToLower(strings.TrimSpace(*h.Domain)) - } - if h.Path != nil { - p.HcPath = *h.Path - } - if h.Response != nil { - p.HcResponse = *h.Response - } - if h.StatusCodes != nil { - codes = *h.StatusCodes - } - if h.TlsVerify != nil { - p.HcTlsVerify = b2i(*h.TlsVerify) - } - } - } - if p.HcProtocol != "tcp" && p.HcProtocol != "http" && p.HcProtocol != "https" { - fe.Add("health_check.protocol", "must be tcp, http or https") - } - if p.HcPort != 0 && !model.ValidPort(int(p.HcPort)) { - fe.Add("health_check.port", "must be 1–65535") - } - if p.HcIntervalS < 3 || p.HcIntervalS > 3600 { - fe.Add("health_check.interval_s", "must be 3–3600") - } - if p.HcTimeoutS < 1 || p.HcTimeoutS > 3600 { - fe.Add("health_check.timeout_s", "must be 1–3600") - } - if p.HcTimeoutS >= p.HcIntervalS { - fe.Add("health_check.timeout_s", "must be less than the interval") - } - if p.HcRetries < 1 || p.HcRetries > 10 { - fe.Add("health_check.retries", "must be 1–10") - } - if p.HcProtocol == "tcp" { - p.HcDomain, p.HcResponse, p.HcTlsVerify = "", "", 0 - } else { - if !model.ValidHCPath(p.HcPath) { - fe.Add("health_check.http.path", "must start with / and contain no spaces, quotes, #, $ or \\") - } - if p.HcDomain != "" && !model.ValidHostname(p.HcDomain, false) { - fe.Add("health_check.http.domain", "invalid hostname") - } - if p.HcResponse != "" && !model.ValidHCResponse(p.HcResponse) { - fe.Add("health_check.http.response", "printable ASCII without ' \" \\ # $ (1–128)") - } - if _, err := model.StatusCodesToHAProxy(codes); err != nil { - fe.Add("health_check.http.status_codes", err.Error()) - } - if p.HcTlsVerify != 0 && p.HcDomain == "" { - fe.Add("health_check.http.domain", "required when TLS verification is on") - } - } - p.HcStatusCodes = jsonStr(codes) - if in.Labels != nil { - model.ValidateLabels(fe, "labels", *in.Labels) - p.Labels = jsonStr(*in.Labels) - } - if !fe.Empty() { - return p, errInput(fe) - } - return p, nil -} - -func (s *Server) CreateBackend(w http.ResponseWriter, r *http.Request) { - var in gen.BackendInput - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - p, err := s.buildBackend(nil, in) - if err != nil { - fail(w, err) - return - } - var id int64 - if err := s.mutate(r, "backend.create", "backend", func() int64 { return id }, in, func(q *db.Queries) error { - b, err := q.CreateBackend(r.Context(), p) - id = b.ID - return err - }); err != nil { - fail(w, err) - return - } - s.respondBackend(w, 201, id) -} - -func (s *Server) UpdateBackend(w http.ResponseWriter, r *http.Request, id gen.Id) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - if err := checkIfMatch(r, b.Version); err != nil { - fail(w, err) - return - } - var in gen.BackendInput - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - p, err := s.buildBackend(b, in) - if err != nil { - fail(w, err) - return - } - if err := s.mutate(r, "backend.update", "backend", func() int64 { return id }, in, func(q *db.Queries) error { - return q.UpdateBackend(r.Context(), db.UpdateBackendParams{Name: p.Name, Description: p.Description, Mode: p.Mode, Algorithm: p.Algorithm, - Port: p.Port, StickyEnabled: p.StickyEnabled, StickyCookieName: p.StickyCookieName, StickyCookieLifetimeS: p.StickyCookieLifetimeS, - ProxyProtocol: p.ProxyProtocol, TimeoutServerS: p.TimeoutServerS, HcProtocol: p.HcProtocol, HcPort: p.HcPort, HcIntervalS: p.HcIntervalS, - HcTimeoutS: p.HcTimeoutS, HcRetries: p.HcRetries, HcDomain: p.HcDomain, HcPath: p.HcPath, HcResponse: p.HcResponse, - HcStatusCodes: p.HcStatusCodes, HcTlsVerify: p.HcTlsVerify, Labels: p.Labels, ID: id}) - }); err != nil { - fail(w, err) - return - } - s.respondBackend(w, 200, id) -} - -func (s *Server) DeleteBackend(w http.ResponseWriter, r *http.Request, id gen.Id) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - if used := s.Snap.Get().UsedBy(id); len(used) > 0 { - f := s.Snap.Get().Frontend(used[0]) - fail(w, errConflict(fmt.Sprintf("backend is used by frontend %q", f.Name))) - return - } - if err := s.mutate(r, "backend.delete", "backend", func() int64 { return id }, map[string]any{"name": b.Name}, func(q *db.Queries) error { - return q.DeleteBackend(r.Context(), id) - }); err != nil { - fail(w, err) - return - } - w.WriteHeader(204) -} - -func (s *Server) GetBackendStatus(w http.ResponseWriter, r *http.Request, id gen.Id) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - sum, rs := s.backendHealth(b) - writeJSON(w, 200, map[string]any{"summary": sum, "servers": rs}) -} - -func (s *Server) GetBackendMetrics(w http.ResponseWriter, r *http.Request, id gen.Id, p gen.GetBackendMetricsParams) { - if _, err := s.backend(id); err != nil { - fail(w, err) - return - } - a, b, st, types := metricsParams(p.Start, p.End, p.Step, p.Type) - s.writeSeries(w, r, metrics.BE, id, a, b, st, types) -} - -// ---------- servers ---------- - -func (s *Server) ListBackendServers(w http.ResponseWriter, r *http.Request, id gen.Id) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - out := []map[string]any{} - view := s.serverViewer(b) - for _, sv := range b.Servers { - out = append(out, view(sv)) - } - writeJSON(w, 200, map[string]any{"servers": out}) -} - -// buildServers validates server inputs (§8.6). -func (s *Server) buildServers(b *state.Backend, ins []gen.BackendServerInput) ([]db.CreateBackendServerParams, error) { - snap := s.Snap.Get() - existing := map[string]bool{} - for _, sv := range b.Servers { - existing[fmt.Sprintf("%s|%s|%d|%s|%d", sv.Type, sv.Ip, sv.HostID.Int64, sv.LabelSelector, sv.PortOr(0))] = true - } - fe := model.FieldErrors{} - var out []db.CreateBackendServerParams - if len(ins) == 0 || len(ins) > 500 { - fe.Add("servers", "1 to 500 servers") - } - for i, in := range ins { - f := fmt.Sprintf("servers[%d]", i) - p := db.CreateBackendServerParams{BackendID: b.ID, Type: string(in.Type), Weight: 100, CreatedAt: nowMs()} - if in.Weight != nil { - p.Weight = int64(*in.Weight) - } - if p.Weight < 0 || p.Weight > 256 { - fe.Add(f+".weight", "must be 0–256") - } - if in.Backup != nil { - p.Backup = b2i(*in.Backup) - } - if in.UsePrivateIp != nil { - p.UsePrivateIp = b2i(*in.UsePrivateIp) - } - if in.Port != nil && *in.Port != 0 { - if !model.ValidPort(*in.Port) { - fe.Add(f+".port", "must be 1–65535") - } - p.Port = sql.NullInt64{Int64: int64(*in.Port), Valid: true} - } - switch p.Type { - case "ip": - if in.Ip == nil { - fe.Add(f+".ip", "required") - continue - } - a, err := model.ParseTargetIP(*in.Ip) - if err != nil { - // Dev mode (make dev) allows loopback servers such as local test containers. - if la, perr := netip.ParseAddr(strings.TrimSpace(*in.Ip)); s.Cfg.Dev && perr == nil && la.IsLoopback() { - a = la - } else { - fe.Add(f+".ip", err.Error()) - continue - } - } - p.Ip = a.String() - case "host": - if in.HostId == nil || snap.Host(*in.HostId) == nil { - fe.Add(f+".host_id", "unknown host") - continue - } - p.HostID = sql.NullInt64{Int64: *in.HostId, Valid: true} - case "label_selector": - if in.LabelSelector == nil { - fe.Add(f+".label_selector", "required") - continue - } - if _, err := model.ParseSelector(*in.LabelSelector); err != nil { - fe.Add(f+".label_selector", err.Error()) - continue - } - p.LabelSelector = strings.TrimSpace(*in.LabelSelector) - default: - fe.Add(f+".type", "must be ip, host or label_selector") - continue - } - k := fmt.Sprintf("%s|%s|%d|%s|%d", p.Type, p.Ip, p.HostID.Int64, p.LabelSelector, p.Port.Int64) - if existing[k] { - fe.Add(f, "server already exists") - } - existing[k] = true - out = append(out, p) - } - if !fe.Empty() { - return nil, errInput(fe) - } - return out, nil -} - -func (s *Server) CreateBackendServers(w http.ResponseWriter, r *http.Request, id gen.Id) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - var in gen.BackendServersCreate - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - ps, err := s.buildServers(b, in.Servers) - if err != nil { - fail(w, err) - return - } - var ids []int64 - if err := s.mutate(r, "server.create", "backend", func() int64 { return id }, in, func(q *db.Queries) error { - for _, p := range ps { - sv, err := q.CreateBackendServer(r.Context(), p) - if err != nil { - return err - } - ids = append(ids, sv.ID) - } - return nil - }); err != nil { - fail(w, err) - return - } - out := []map[string]any{} - if nb := s.Snap.Get().Backend(id); nb != nil { - view := s.serverViewer(nb) - for _, sid := range ids { - if sv := nb.Server(sid); sv != nil { - out = append(out, view(sv)) - } - } - } - writeJSON(w, 201, map[string]any{"servers": out}) -} - -func (s *Server) UpdateBackendServer(w http.ResponseWriter, r *http.Request, id gen.Id, sid int64) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - sv := b.Server(sid) - if sv == nil { - fail(w, errNotFound("server")) - return - } - var in gen.BackendServerUpdate - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - p := db.UpdateBackendServerParams{Weight: sv.Weight, Backup: sv.Backup, AdminState: sv.AdminState, ID: sv.ID} - if in.Weight != nil { - if *in.Weight < 0 || *in.Weight > 256 { - fail(w, errField("weight", "must be 0–256")) - return - } - p.Weight = int64(*in.Weight) - } - if in.AdminState != nil { - p.AdminState = string(*in.AdminState) - if p.AdminState != "ready" && p.AdminState != "drain" && p.AdminState != "maint" { - fail(w, errField("admin_state", "must be ready, drain or maint")) - return - } - } - if in.Backup != nil { - p.Backup = b2i(*in.Backup) - } - if err := s.mutate(r, "server.update", "server", func() int64 { return sid }, in, func(q *db.Queries) error { - return q.UpdateBackendServer(r.Context(), p) - }); err != nil { - fail(w, err) - return - } - nb := s.Snap.Get().Backend(id) - if nb == nil || nb.Server(sid) == nil { - w.WriteHeader(204) - return - } - writeJSON(w, 200, map[string]any{"server": s.serverViewer(nb)(nb.Server(sid))}) -} - -// DeleteBackendServer starts a graceful removal: drain → wait for sessions to end or timeout → delete (§7.6). -func (s *Server) DeleteBackendServer(w http.ResponseWriter, r *http.Request, id gen.Id, sid int64) { - b, err := s.backend(id) - if err != nil { - fail(w, err) - return - } - if b.Server(sid) == nil { - fail(w, errNotFound("server")) - return - } - if err := s.mutate(r, "server.delete", "server", func() int64 { return sid }, nil, func(q *db.Queries) error { - return q.MarkBackendServerRemoving(r.Context(), sid) - }); err != nil { - fail(w, err) - return - } - if s.Drain != nil { - go s.Drain(id, sid) - } - w.WriteHeader(204) -} diff --git a/internal/api/binds_test.go b/internal/api/binds_test.go deleted file mode 100644 index 66ac8b0..0000000 --- a/internal/api/binds_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package api - -import ( - "testing" - - "github.com/2shiraz/loxy/internal/state" -) - -func TestBindsCollide(t *testing.T) { - for _, c := range []struct { - a, b string - want bool - }{ - {"*", "10.0.0.5", true}, - {"*", "::1", true}, - {"0.0.0.0", "10.0.0.5", true}, - {"0.0.0.0", "::", false}, - {"::", "2001:db8::1", true}, - {"::", "10.0.0.5", false}, - {"10.0.0.5", "10.0.0.6", false}, - {"10.0.0.5", "10.0.0.5", true}, - {"2001:db8::1", "2001:db8:0::1", true}, - } { - if got := bindsCollide(state.Bind{Address: c.a, Port: 80}, state.Bind{Address: c.b, Port: 80}); got != c.want { - t.Errorf("%s vs %s: got %v, want %v", c.a, c.b, got, c.want) - } - } - if bindsCollide(state.Bind{Address: "*", Port: 80}, state.Bind{Address: "*", Port: 81}) { - t.Error("different ports collide") - } -} diff --git a/internal/api/domains.go b/internal/api/domains.go index 3c43a1b..4986900 100644 --- a/internal/api/domains.go +++ b/internal/api/domains.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" + "log/slog" "net/http" "strings" "time" @@ -15,7 +17,6 @@ import ( "github.com/2shiraz/loxy/internal/dns" "github.com/2shiraz/loxy/internal/events" "github.com/2shiraz/loxy/internal/model" - "github.com/2shiraz/loxy/internal/reconcile" "github.com/2shiraz/loxy/internal/state" "github.com/2shiraz/loxy/internal/store/db" ) @@ -231,9 +232,12 @@ func (s *Server) domain(id int64) *state.Domain { return nil } -func (s *Server) ListDomains(w http.ResponseWriter, r *http.Request) { +func (s *Server) ListDomains(w http.ResponseWriter, r *http.Request, prm gen.ListDomainsParams) { out := []map[string]any{} for _, d := range s.Snap.Get().Domains { + if prm.ProjectId != nil && (!d.BalancerID.Valid || d.BalancerID.Int64 != *prm.ProjectId) { + continue + } out = append(out, s.viewDomain(d)) } writeJSON(w, 200, map[string]any{"domains": out}) @@ -257,10 +261,26 @@ func (s *Server) respondDomain(w http.ResponseWriter, status int, id int64) { writeJSON(w, status, map[string]any{"domain": s.viewDomain(d)}) } -// domainRefs validates frontend / provider references; returns the detected zone. -func (s *Server) domainRefs(ctx context.Context, fqdn string, in gen.DomainInput, fe model.FieldErrors) string { - if in.FrontendId != nil && *in.FrontendId != 0 && s.Snap.Get().Frontend(*in.FrontendId) == nil { - fe.Add("frontend_id", "unknown frontend") +// domainRefs validates project / service / provider references; returns the detected zone. +func (s *Server) domainRefs(ctx context.Context, fqdn string, in gen.DomainInput, fe model.FieldErrors, create bool) string { + snap := s.Snap.Get() + if s.Cfg.PanelPort == 80 || s.Cfg.PanelPort == 443 { + fe.Add("fqdn", fmt.Sprintf("the Loxy panel uses :%d, which domains need; move it with LOXY_PANEL_PORT", s.Cfg.PanelPort)) + } + if in.ProjectId == nil || *in.ProjectId == 0 { + if create { + fe.Add("project_id", "required") + } + } else if p := snap.Project(*in.ProjectId); p == nil { + fe.Add("project_id", "unknown project") + } else if in.ServiceId != nil && *in.ServiceId != 0 { + if sv := p.Service(*in.ServiceId); sv == nil { + fe.Add("service_id", "not a service of this project") + } else if sv.Protocol == "tcp" { + fe.Add("service_id", "domains route HTTP traffic; choose an http or https service") + } + } else if p.WebService() == nil { + fe.Add("project_id", "the project needs an http or https service to receive domain traffic") } zone := "" if in.DnsProviderId != nil && *in.DnsProviderId != 0 { @@ -298,27 +318,28 @@ func (s *Server) CreateDomain(w http.ResponseWriter, r *http.Request) { if !model.ValidHostname(fqdn, hasProvider) || !strings.Contains(fqdn, ".") { fe.Add("fqdn", "invalid hostname (wildcards need a DNS provider)") } + for _, o := range s.Snap.Get().Domains { + if o.Fqdn == fqdn { + fe.Add("fqdn", "already added") + } + } zone := "" if fe.Empty() { - zone = s.domainRefs(r.Context(), fqdn, in, fe) + zone = s.domainRefs(r.Context(), fqdn, in, fe, true) } if !fe.Empty() { fail(w, errInput(fe)) return } + https := in.Https == nil || *in.Https var row db.Domain - s.listenMu.Lock() - err := s.acmeCollision(in.FrontendId) - if err == nil { - err = s.mutate(r, "domain.create", "domain", func() int64 { return row.ID }, in, func(q *db.Queries) error { - var err error - row, err = q.CreateDomain(r.Context(), db.CreateDomainParams{Fqdn: fqdn, FrontendID: i64(in.FrontendId), DnsProviderID: i64(in.DnsProviderId), - Zone: zone, AutoRecords: b2i(in.AutoRecords != nil && *in.AutoRecords), CreatedAt: nowMs(), UpdatedAt: nowMs()}) - return err - }) - } - s.listenMu.Unlock() - if err != nil { + if err := s.mutate(r, "domain.create", "domain", func() int64 { return row.ID }, in, func(q *db.Queries) error { + var err error + row, err = q.CreateDomain(r.Context(), db.CreateDomainParams{Fqdn: fqdn, BalancerID: i64(in.ProjectId), ServiceID: i64(in.ServiceId), + Https: b2i(https), DnsProviderID: i64(in.DnsProviderId), Zone: zone, AutoRecords: b2i(in.AutoRecords != nil && *in.AutoRecords), + CreatedAt: nowMs(), UpdatedAt: nowMs()}) + return err + }); err != nil { fail(w, err) return } @@ -338,9 +359,25 @@ func (s *Server) UpdateDomain(w http.ResponseWriter, r *http.Request, id gen.Id) fail(w, err) return } - merged := gen.DomainInput{FrontendId: nullI64(d.FrontendID), DnsProviderId: nullI64(d.DnsProviderID), AutoRecords: ptr(d.AutoRecords != 0)} - if in.FrontendId != nil { - merged.FrontendId = in.FrontendId + merged := gen.DomainInput{ProjectId: nullI64(d.BalancerID), ServiceId: nullI64(d.ServiceID), Https: ptr(d.Https != 0), + DnsProviderId: nullI64(d.DnsProviderID), AutoRecords: ptr(d.AutoRecords != 0)} + if in.ProjectId != nil { + merged.ProjectId = in.ProjectId + if *in.ProjectId == 0 { + merged.ProjectId = nil // detach + } + if in.ServiceId == nil && (d.BalancerID.Int64 != *in.ProjectId) { + merged.ServiceId = nil // the old service belongs to the other project + } + } + if in.ServiceId != nil { + merged.ServiceId = in.ServiceId + if *in.ServiceId == 0 { + merged.ServiceId = nil + } + } + if in.Https != nil { + merged.Https = in.Https } if in.DnsProviderId != nil { merged.DnsProviderId = in.DnsProviderId @@ -349,27 +386,21 @@ func (s *Server) UpdateDomain(w http.ResponseWriter, r *http.Request, id gen.Id) merged.AutoRecords = in.AutoRecords } fe := model.FieldErrors{} - zone := s.domainRefs(r.Context(), d.Fqdn, merged, fe) + zone := s.domainRefs(r.Context(), d.Fqdn, merged, fe, false) if !fe.Empty() { fail(w, errInput(fe)) return } - s.listenMu.Lock() - err := s.acmeCollision(merged.FrontendId) - if err == nil { - err = s.mutate(r, "domain.update", "domain", func() int64 { return id }, in, func(q *db.Queries) error { - return q.UpdateDomain(r.Context(), db.UpdateDomainParams{FrontendID: i64(merged.FrontendId), DnsProviderID: i64(merged.DnsProviderId), - Zone: zone, AutoRecords: b2i(merged.AutoRecords != nil && *merged.AutoRecords), UpdatedAt: nowMs(), ID: id}) - }) - } - s.listenMu.Unlock() - if err != nil { + if err := s.mutate(r, "domain.update", "domain", func() int64 { return id }, in, func(q *db.Queries) error { + return q.UpdateDomain(r.Context(), db.UpdateDomainParams{BalancerID: i64(merged.ProjectId), ServiceID: i64(merged.ServiceId), + Https: b2i(merged.Https != nil && *merged.Https), DnsProviderID: i64(merged.DnsProviderId), Zone: zone, + AutoRecords: b2i(merged.AutoRecords != nil && *merged.AutoRecords), UpdatedAt: nowMs(), ID: id}) + }); err != nil { fail(w, err) return } // d is the pre-update copy: it still lists the records Loxy created. - turningOff := d.AutoRecords != 0 && (merged.AutoRecords == nil || !*merged.AutoRecords) - if turningOff || (merged.FrontendId == nil || *merged.FrontendId == 0) { + if d.AutoRecords != 0 && (merged.AutoRecords == nil || !*merged.AutoRecords) { s.removeManagedRecords(r.Context(), d) } s.SyncDomain(r.Context(), id) @@ -402,7 +433,8 @@ func (s *Server) VerifyDomain(w http.ResponseWriter, r *http.Request, id gen.Id) s.respondDomain(w, 200, id) } -// VerifyDomainNow resolves the name and stores the verification status (§8.16). +// VerifyDomainNow resolves the name, stores the verification status (§8.9) and, once the +// domain resolves to the server (or has a DNS provider), issues its certificate automatically. func (s *Server) VerifyDomainNow(ctx context.Context, id int64) { d := s.domain(id) if d == nil { @@ -410,36 +442,97 @@ func (s *Server) VerifyDomainNow(ctx context.Context, id int64) { } v4, v6 := s.domainAddresses(d) res := dns.Verify(ctx, nil, d.Fqdn, v4, v6) - if !d.FrontendID.Valid { - res.Status, res.Detail = "unknown", "not attached to a frontend" + if !d.BalancerID.Valid { + res.Status, res.Detail = "unknown", "not attached to a project" } obs, _ := json.Marshal(map[string]any{"a": res.A, "aaaa": res.AAAA}) _ = s.Store.Q.SetDomainStatus(ctx, db.SetDomainStatusParams{Status: res.Status, StatusDetail: sql.NullString{String: res.Detail, Valid: res.Detail != ""}, Observed: string(obs), CheckedAt: sql.NullInt64{Int64: nowMs(), Valid: true}, ID: id}) if res.Status != d.Status { - feID := domainFE(d) sev := "info" if res.Status != "ok" { sev = "warning" } - s.Hub.Publish(events.Event{Kind: "domain.status.changed", FrontendID: feID, Severity: sev, Message: fmt.Sprintf("%s: %s %s", d.Fqdn, res.Status, res.Detail), + s.Hub.Publish(events.Event{Kind: "domain.status.changed", ProjectID: domainProject(d), Severity: sev, Message: fmt.Sprintf("%s: %s %s", d.Fqdn, res.Status, res.Detail), Data: map[string]any{"domain_id": id, "status": res.Status}}) } _, _ = s.Snap.Refresh(ctx, s.Store.Q, s.Box) + if d.Https != 0 && !d.CertificateID.Valid && d.BalancerID.Valid && (res.Status == "ok" || d.DnsProviderID.Valid) { + if err := s.autoIssue(ctx, d); err != nil { + slog.Warn("automatic certificate", "domain", d.Fqdn, "err", err) + } + } +} + +// autoIssue creates and starts issuing the managed certificate of an HTTPS domain (system action). +func (s *Server) autoIssue(ctx context.Context, d *state.Domain) error { + challenge := "http01" + var pid sql.NullInt64 + if d.DnsProviderID.Valid { + challenge, pid = "dns01", d.DnsProviderID + } + s.issueMu.Lock() // one automatic issuance decision at a time (create, verify and the loop race) + defer s.issueMu.Unlock() + name := d.Fqdn + if r := []rune(name); len(r) > 64 { + name = string(r[:61]) + "..." + } + var certID int64 + err := s.Store.Tx(ctx, func(q *db.Queries) error { + cur, err := q.GetDomain(ctx, d.ID) + if err != nil || cur.CertificateID.Valid || cur.Https == 0 || !cur.BalancerID.Valid { + return errSkip // gone, already has a certificate, or no longer wants one + } + c, err := q.CreateCertificate(ctx, db.CreateCertificateParams{Name: name, Type: "managed", Domains: jsonStr([]string{d.Fqdn}), KeyType: "ecdsa256", + Challenge: challenge, DnsProviderID: pid, Status: "pending", Labels: "{}", CreatedAt: nowMs()}) + if err != nil { + return err + } + certID = c.ID + if err := q.SetDomainCertificate(ctx, db.SetDomainCertificateParams{CertificateID: sql.NullInt64{Int64: certID, Valid: true}, ID: d.ID}); err != nil { + return err + } + if _, err := q.BumpDesiredVersion(ctx, 1); err != nil { + return err + } + return q.CreateAudit(ctx, db.CreateAuditParams{Ts: nowMs(), Actor: "system", Action: "domain.issue_certificate", + ResourceType: "domain", ResourceID: sql.NullInt64{Int64: d.ID, Valid: true}, After: sql.NullString{String: jsonStr(map[string]any{"certificate_id": certID}), Valid: true}}) + }) + if errors.Is(err, errSkip) { + return nil + } + if err != nil { + return err + } + s.Changed(ctx) + if s.Issuer != nil { + s.Issuer.IssueAsync(certID) + } + return nil } +var errSkip = errors.New("skip") + func (s *Server) IssueDomainCertificate(w http.ResponseWriter, r *http.Request, id gen.Id) { d := s.domain(id) if d == nil { fail(w, errNotFound("domain")) return } + if d.CertificateID.Valid { + // Re-issue the existing managed certificate (renew now). + if c := s.Snap.Get().Cert(d.CertificateID.Int64); c != nil && c.Type == "managed" { + s.Issuer.IssueAsync(c.ID) + s.respondCert(w, 202, c.ID) + return + } + } challenge := "http01" var provider *int64 if d.DnsProviderID.Valid { challenge, provider = "dns01", ptr(d.DnsProviderID.Int64) } else if d.Status != "ok" { - fail(w, errField("status", "the domain must resolve to the frontend first (or attach a DNS provider for DNS-01)")) + fail(w, errField("status", "the domain must resolve to this server first (or attach a DNS provider for DNS-01)")) return } certID, err := s.createManaged(r, d.Fqdn, []string{d.Fqdn}, challenge, provider, "ecdsa256", map[string]string{}) @@ -448,14 +541,6 @@ func (s *Server) IssueDomainCertificate(w http.ResponseWriter, r *http.Request, return } _ = s.Store.Q.SetDomainCertificate(r.Context(), db.SetDomainCertificateParams{CertificateID: sql.NullInt64{Int64: certID, Valid: true}, ID: id}) - // Attach to the domain's frontend when it terminates TLS. - if d.FrontendID.Valid { - if f := s.Snap.Get().Frontend(d.FrontendID.Int64); f != nil && f.Tls != 0 { - _ = s.mutate(r, "frontend.attach_certificate", "frontend", func() int64 { return f.ID }, map[string]any{"certificate_id": certID}, func(q *db.Queries) error { - return q.AddFrontendCertificate(r.Context(), db.AddFrontendCertificateParams{FrontendID: f.ID, CertificateID: certID, Position: int64(len(f.CertIDs))}) - }) - } - } s.Changed(r.Context()) s.Issuer.IssueAsync(certID) s.respondCert(w, 202, certID) @@ -465,10 +550,7 @@ func (s *Server) IssueDomainCertificate(w http.ResponseWriter, r *http.Request, // Only records Loxy created are touched; pre-existing records are reported as a conflict. func (s *Server) SyncDomain(ctx context.Context, id int64) { d := s.domain(id) - if d == nil || d.AutoRecords == 0 || !d.DnsProviderID.Valid || !d.FrontendID.Valid || d.Zone == "" { - return - } - if s.Snap.Get().Frontend(d.FrontendID.Int64) == nil { + if d == nil || d.AutoRecords == 0 || !d.DnsProviderID.Valid || !d.BalancerID.Valid || d.Zone == "" { return } p, err := s.DNSProvider(ctx, d.DnsProviderID.Int64) @@ -492,7 +574,7 @@ func (s *Server) SyncDomain(ctx context.Context, id int64) { } existing, err := dns.ListRecords(ctx, p, zone) if err != nil { - s.Hub.Publish(events.Event{Kind: "domain.sync.failed", FrontendID: domainFE(d), Severity: "warning", Message: d.Fqdn + ": " + err.Error()}) + s.Hub.Publish(events.Event{Kind: "domain.sync.failed", ProjectID: domainProject(d), Severity: "warning", Message: d.Fqdn + ": " + err.Error()}) return } wantKeys := map[string]bool{} @@ -511,7 +593,7 @@ func (s *Server) SyncDomain(ctx context.Context, id int64) { continue } if !mset[k] { - s.Hub.Publish(events.Event{Kind: "domain.sync.failed", FrontendID: domainFE(d), Severity: "warning", + s.Hub.Publish(events.Event{Kind: "domain.sync.failed", ProjectID: domainProject(d), Severity: "warning", Message: fmt.Sprintf("%s: existing %s record %s was not created by Loxy; not overwriting", d.Fqdn, e.Type, e.Value)}) return } @@ -531,13 +613,13 @@ func (s *Server) SyncDomain(ctx context.Context, id int64) { } if len(toDelete) > 0 { if _, err := p.DeleteRecords(ctx, zone, toDelete); err != nil { - s.Hub.Publish(events.Event{Kind: "domain.sync.failed", FrontendID: domainFE(d), Severity: "warning", Message: d.Fqdn + ": " + err.Error()}) + s.Hub.Publish(events.Event{Kind: "domain.sync.failed", ProjectID: domainProject(d), Severity: "warning", Message: d.Fqdn + ": " + err.Error()}) return } } if len(toAdd) > 0 { if _, err := p.AppendRecords(ctx, zone, toAdd); err != nil { - s.Hub.Publish(events.Event{Kind: "domain.sync.failed", FrontendID: domainFE(d), Severity: "warning", Message: d.Fqdn + ": " + err.Error()}) + s.Hub.Publish(events.Event{Kind: "domain.sync.failed", ProjectID: domainProject(d), Severity: "warning", Message: d.Fqdn + ": " + err.Error()}) return } } @@ -606,39 +688,19 @@ func (s *Server) RunDomainLoop(ctx context.Context) { } } -// domainAddresses returns the addresses the domain should resolve to (its frontend's). +// domainAddresses returns the addresses the domain should resolve to: the server's +// addresses once it is attached to a project. func (s *Server) domainAddresses(d *state.Domain) (v4, v6 []string) { - if !d.FrontendID.Valid { + if !d.BalancerID.Valid { return nil, nil } - f := s.Snap.Get().Frontend(d.FrontendID.Int64) - if f == nil { - return nil, nil - } - return s.frontendAddresses(f) + return s.nodeAddresses() } -// domainFE is the frontend a domain's events belong to (nil when unattached). -func domainFE(d *state.Domain) *int64 { - if !d.FrontendID.Valid { +// domainProject is the project a domain's events belong to (nil when unattached). +func domainProject(d *state.Domain) *int64 { + if !d.BalancerID.Valid { return nil } - return events.I64(d.FrontendID.Int64) -} - -// acmeCollision checks, under listenMu, that attaching a domain to frontend feID (which then -// answers HTTP-01 challenges on :80, §8.9) does not collide with another frontend's listeners. -func (s *Server) acmeCollision(feID *int64) error { - if feID == nil || *feID == 0 { - return nil - } - snap := s.Snap.Get() - f := snap.Frontend(*feID) - if f == nil || reconcile.NeedsACME(snap, f.ID, f.CertIDs) { - return nil - } - if msg := listenerCollision(snap, f, true); msg != "" { - return errField("frontend_id", "HTTP-01 challenges need :80 on this frontend's addresses: "+msg) - } - return nil + return events.I64(d.BalancerID.Int64) } diff --git a/internal/api/frontends.go b/internal/api/frontends.go deleted file mode 100644 index ffdd621..0000000 --- a/internal/api/frontends.go +++ /dev/null @@ -1,620 +0,0 @@ -package api - -import ( - "database/sql" - "encoding/json" - "fmt" - "net/http" - "net/netip" - "slices" - "strings" - - "github.com/2shiraz/loxy/internal/api/gen" - "github.com/2shiraz/loxy/internal/haproxy/render" - "github.com/2shiraz/loxy/internal/metrics" - "github.com/2shiraz/loxy/internal/model" - "github.com/2shiraz/loxy/internal/reconcile" - "github.com/2shiraz/loxy/internal/state" - "github.com/2shiraz/loxy/internal/store/db" -) - -func (s *Server) frontend(id int64) (*state.Frontend, error) { - f := s.Snap.Get().Frontend(id) - if f == nil { - return nil, errNotFound("frontend") - } - return f, nil -} - -func (s *Server) respondFrontend(w http.ResponseWriter, status int, id int64) { - f := s.Snap.Get().Frontend(id) - if f == nil { - w.WriteHeader(204) - return - } - writeJSON(w, status, map[string]any{"frontend": s.viewFrontend(f)}) -} - -func (s *Server) ListFrontends(w http.ResponseWriter, r *http.Request, p gen.ListFrontendsParams) { - var sel model.Selector - if p.LabelSelector != nil && *p.LabelSelector != "" { - var err error - if sel, err = model.ParseSelector(*p.LabelSelector); err != nil { - fail(w, errField("label_selector", err.Error())) - return - } - } - out := []map[string]any{} - for _, f := range s.Snap.Get().Frontends { - if sel != nil && !sel.Matches(f.Labels) { - continue - } - out = append(out, s.viewFrontend(f)) - } - writeJSON(w, 200, map[string]any{"frontends": out}) -} - -func (s *Server) GetFrontend(w http.ResponseWriter, r *http.Request, id gen.Id) { - f, err := s.frontend(id) - if err != nil { - fail(w, err) - return - } - writeJSON(w, 200, map[string]any{"frontend": s.viewFrontend(f)}) -} - -// frontendPlan is a validated FrontendInput ready to be written. -type frontendPlan struct { - p db.CreateFrontendParams - certs []int64 - routes []db.AddRouteParams - binds []state.Bind - enabled bool -} - -func isV6(a string) bool { - x, err := netip.ParseAddr(a) - return err == nil && x.Is6() -} - -// listeners returns "addr:port" strings a frontend occupies (explicit binds plus implicit :80). -func listeners(f *state.Frontend, acme bool) []state.Bind { - if f.Enabled == 0 { - return nil - } - out := append([]state.Bind(nil), f.Binds...) - for _, a := range render.Ports80(f, acme) { - out = append(out, state.Bind{Address: a, Port: 80}) - } - return out -} - -// listenerCollision describes the first overlap between a frontend's listeners (explicit and -// implicit :80) and those of another enabled frontend, or returns ""; probe.ID is skipped -// (0 for a new frontend). -func listenerCollision(snap *state.Snapshot, probe *state.Frontend, acme bool) string { - others := reconcile.ACMEFrontends(snap) - mine := listeners(probe, acme) - for _, o := range snap.Frontends { - if o.ID == probe.ID { - continue - } - for _, ob := range listeners(o, others[o.ID]) { - for _, mb := range mine { - if bindsCollide(ob, mb) { - return fmt.Sprintf("%s:%d collides with frontend %q (%s:%d)", mb.Address, mb.Port, o.Name, ob.Address, ob.Port) - } - } - } - } - return "" -} - -// bindsCollide reports whether two listeners overlap: same port and either the same address -// or a wildcard covering the other ("*" covers both families, 0.0.0.0 all IPv4, :: all IPv6). -func bindsCollide(a, b state.Bind) bool { - if a.Port != b.Port { - return false - } - if a.Address == "*" || a.Address == "" || b.Address == "*" || b.Address == "" { - return true - } - pa, ea := netip.ParseAddr(a.Address) - pb, eb := netip.ParseAddr(b.Address) - if ea != nil || eb != nil { - return a.Address == b.Address - } - pa, pb = pa.Unmap(), pb.Unmap() - if pa.Is4() != pb.Is4() { - return false - } - return pa == pb || pa.IsUnspecified() || pb.IsUnspecified() -} - -// buildFrontend validates a frontend input (§8.3). -func (s *Server) buildFrontend(existing *state.Frontend, in gen.FrontendInput) (*frontendPlan, error) { - snap := s.Snap.Get() - fe := model.FieldErrors{} - name := strings.TrimSpace(in.Name) - if !model.ValidName(name) { - fe.Add("name", "1–64 printable characters") - } - for _, o := range snap.Frontends { - if o.Name == name && (existing == nil || o.ID != existing.ID) { - fe.Add("name", "already in use") - } - } - mode := string(in.Mode) - if mode != "http" && mode != "tcp" { - fe.Add("mode", "must be http or tcp") - } - b := func(p *bool, def bool) bool { - if p == nil { - return def - } - return *p - } - plan := &frontendPlan{enabled: b(in.Enabled, true)} - tls := b(in.Tls, false) - redirect := b(in.HttpRedirect, false) - if mode == "tcp" { - if tls { - fe.Add("tls", "TLS termination needs http mode (use tcp mode for TLS passthrough)") - } - redirect = false - } - if redirect && !tls { - fe.Add("http_redirect", "only available with TLS") - } - // binds - if len(in.Binds) == 0 || len(in.Binds) > 16 { - fe.Add("binds", "1 to 16 listen addresses") - } - seen := map[string]bool{} - for i, bd := range in.Binds { - f := fmt.Sprintf("binds[%d]", i) - addr := strings.TrimSpace(bd.Address) - if addr == "" { - addr = "*" - } - if addr != "*" { - a, err := netip.ParseAddr(addr) - if err != nil || a.Zone() != "" || a.IsMulticast() || a.IsUnspecified() { - fe.Add(f+".address", `an IP address or "*" for all interfaces`) - continue - } - addr = a.Unmap().String() - } - if !model.ValidPort(bd.Port) { - fe.Add(f+".port", "must be 1–65535") - continue - } - if bd.Port == s.Cfg.PanelPort { - fe.Add(f+".port", "reserved for the Loxy panel") - } - if (bd.Port == 8405 || bd.Port == 5514) && (addr == "*" || strings.HasPrefix(addr, "127.")) { - fe.Add(f+".port", "reserved for Loxy internals on loopback") - } - if redirect && bd.Port == 80 { - fe.Add(f+".port", ":80 is used by the HTTP→HTTPS redirect") - } - k := fmt.Sprintf("%s|%d", addr, bd.Port) - if seen[k] { - fe.Add(f, "duplicate listen address") - } - seen[k] = true - plan.binds = append(plan.binds, state.Bind{Address: addr, Port: int64(bd.Port)}) - } - // certificates - if in.Certificates != nil && tls { - for _, c := range *in.Certificates { - if snap.Cert(c) == nil { - fe.Add("certificates", fmt.Sprintf("unknown certificate %d", c)) - } - if slices.Contains(plan.certs, c) { - fe.Add("certificates", fmt.Sprintf("certificate %d listed twice", c)) - continue - } - plan.certs = append(plan.certs, c) - } - } - if tls && len(plan.certs) == 0 { - fe.Add("certificates", "TLS needs at least one certificate (a pending managed certificate is fine)") - } - // backends - var def sql.NullInt64 - if in.DefaultBackendId != nil && *in.DefaultBackendId != 0 { - be := snap.Backend(*in.DefaultBackendId) - switch { - case be == nil: - fe.Add("default_backend_id", "unknown backend") - case be.Mode != mode: - fe.Add("default_backend_id", fmt.Sprintf("backend %q is in %s mode", be.Name, be.Mode)) - } - def = sql.NullInt64{Int64: *in.DefaultBackendId, Valid: true} - } - if in.Routes != nil { - if mode != "http" && len(*in.Routes) > 0 { - fe.Add("routes", "routing rules need http mode") - } - if len(*in.Routes) > 100 { - fe.Add("routes", "at most 100 rules") - } - for i, rt := range *in.Routes { - f := fmt.Sprintf("routes[%d]", i) - be := snap.Backend(rt.BackendId) - if be == nil { - fe.Add(f+".backend_id", "unknown backend") - } else if be.Mode != "http" { - fe.Add(f+".backend_id", fmt.Sprintf("backend %q is in tcp mode", be.Name)) - } - var hosts []string - if rt.Hosts != nil { - for _, h := range *rt.Hosts { - h = strings.ToLower(strings.TrimSpace(h)) - if h == "" { - continue - } - if !model.ValidHostname(h, true) || !strings.Contains(h, ".") { - fe.Add(f+".hosts", fmt.Sprintf("invalid host %q", h)) - } - hosts = append(hosts, h) - } - } - if len(hosts) > 20 { - fe.Add(f+".hosts", "at most 20 hosts per rule") - } - prefix := "" - if rt.PathPrefix != nil { - prefix = strings.TrimSpace(*rt.PathPrefix) - } - if prefix != "" && !model.ValidRoutePath(prefix) { - fe.Add(f+".path_prefix", "must start with / and contain no spaces, quotes, ?, #, $ or \\") - } - if len(hosts) == 0 && prefix == "" { - fe.Add(f, "needs hosts, a path prefix, or both") - } - if hosts == nil { - hosts = []string{} - } - plan.routes = append(plan.routes, db.AddRouteParams{Position: int64(i), Hosts: jsonStr(hosts), PathPrefix: prefix, BackendID: rt.BackendId}) - } - } - timeout := int64(60) - if in.TimeoutClientS != nil { - timeout = int64(*in.TimeoutClientS) - } - if timeout < 1 || timeout > 86400 { - fe.Add("timeout_client_s", "must be 1–86400") - } - maxconn := int64(0) - if in.Maxconn != nil { - maxconn = int64(*in.Maxconn) - } - if maxconn < 0 || maxconn > 1_000_000 { - fe.Add("maxconn", "must be 0–1000000") - } - labels := map[string]string{} - if in.Labels != nil { - labels = *in.Labels - } - model.ValidateLabels(fe, "labels", labels) - desc := "" - if in.Description != nil { - desc = strings.TrimSpace(*in.Description) - if len(desc) > 500 { - fe.Add("description", "at most 500 characters") - } - } - if !fe.Empty() { - return nil, errInput(fe) - } - // Listener collisions with other frontends (incl. implicit :80 redirect/ACME listeners). - probe := &state.Frontend{Frontend: db.Frontend{Mode: mode, Tls: b2i(tls), HttpRedirect: b2i(redirect), Enabled: b2i(plan.enabled)}, Binds: plan.binds} - var selfID int64 - if existing != nil { - selfID = existing.ID - } - probe.ID = selfID - if msg := listenerCollision(snap, probe, reconcile.NeedsACME(snap, selfID, plan.certs)); msg != "" { - return nil, errField("binds", msg) - } - bj, _ := json.Marshal(plan.binds) - plan.p = db.CreateFrontendParams{Name: name, Description: desc, Mode: mode, Tls: b2i(tls), Binds: string(bj), HttpRedirect: b2i(redirect), - DefaultBackendID: def, TimeoutClientS: timeout, Maxconn: maxconn, ForwardedHeaders: b2i(b(in.ForwardedHeaders, true)), - Enabled: b2i(plan.enabled), Labels: jsonStr(labels), CreatedAt: nowMs(), UpdatedAt: nowMs()} - return plan, nil -} - -func writeFrontendChildren(r *http.Request, q *db.Queries, id int64, plan *frontendPlan) error { - if err := q.ClearRoutes(r.Context(), id); err != nil { - return err - } - for _, rt := range plan.routes { - rt.FrontendID = id - if err := q.AddRoute(r.Context(), rt); err != nil { - return err - } - } - if err := q.ClearFrontendCertificates(r.Context(), id); err != nil { - return err - } - for pos, c := range plan.certs { - if err := q.AddFrontendCertificate(r.Context(), db.AddFrontendCertificateParams{FrontendID: id, CertificateID: c, Position: int64(pos)}); err != nil { - return err - } - } - return nil -} - -func (s *Server) CreateFrontend(w http.ResponseWriter, r *http.Request) { - s.listenMu.Lock() - defer s.listenMu.Unlock() - var in gen.FrontendInput - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - plan, err := s.buildFrontend(nil, in) - if err != nil { - fail(w, err) - return - } - var id int64 - if err := s.mutate(r, "frontend.create", "frontend", func() int64 { return id }, in, func(q *db.Queries) error { - f, err := q.CreateFrontend(r.Context(), plan.p) - if err != nil { - return err - } - id = f.ID - return writeFrontendChildren(r, q, id, plan) - }); err != nil { - fail(w, err) - return - } - s.respondFrontend(w, 201, id) -} - -func (s *Server) UpdateFrontend(w http.ResponseWriter, r *http.Request, id gen.Id) { - s.listenMu.Lock() - defer s.listenMu.Unlock() - f, err := s.frontend(id) - if err != nil { - fail(w, err) - return - } - if err := checkIfMatch(r, f.Version); err != nil { - fail(w, err) - return - } - var in gen.FrontendInput - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - plan, err := s.buildFrontend(f, in) - if err != nil { - fail(w, err) - return - } - p := plan.p - if err := s.mutate(r, "frontend.update", "frontend", func() int64 { return id }, in, func(q *db.Queries) error { - if err := q.UpdateFrontend(r.Context(), db.UpdateFrontendParams{Name: p.Name, Description: p.Description, Mode: p.Mode, Tls: p.Tls, - Binds: p.Binds, HttpRedirect: p.HttpRedirect, DefaultBackendID: p.DefaultBackendID, TimeoutClientS: p.TimeoutClientS, - Maxconn: p.Maxconn, ForwardedHeaders: p.ForwardedHeaders, Enabled: p.Enabled, Maintenance: f.Maintenance, Labels: p.Labels, - UpdatedAt: nowMs(), ID: id}); err != nil { - return err - } - return writeFrontendChildren(r, q, id, plan) - }); err != nil { - fail(w, err) - return - } - s.respondFrontend(w, 200, id) -} - -func (s *Server) PatchFrontend(w http.ResponseWriter, r *http.Request, id gen.Id) { - s.listenMu.Lock() - defer s.listenMu.Unlock() - f, err := s.frontend(id) - if err != nil { - fail(w, err) - return - } - var in gen.FrontendPatch - if err := decode(r, &in); err != nil { - fail(w, err) - return - } - p := db.UpdateFrontendParams{Name: f.Name, Description: f.Description, Mode: f.Mode, Tls: f.Tls, Binds: f.Frontend.Binds, HttpRedirect: f.HttpRedirect, - DefaultBackendID: f.DefaultBackendID, TimeoutClientS: f.TimeoutClientS, Maxconn: f.Maxconn, ForwardedHeaders: f.ForwardedHeaders, - Enabled: f.Enabled, Maintenance: f.Maintenance, Labels: jsonStr(f.Labels), UpdatedAt: nowMs(), ID: id} - fe := model.FieldErrors{} - if in.Name != nil { - if !model.ValidName(*in.Name) { - fe.Add("name", "1–64 printable characters") - } - for _, o := range s.Snap.Get().Frontends { - if o.ID != id && o.Name == *in.Name { - fe.Add("name", "already in use") - } - } - p.Name = *in.Name - } - if in.Description != nil { - p.Description = strings.TrimSpace(*in.Description) - if len(p.Description) > 500 { - fe.Add("description", "at most 500 characters") - } - } - if in.Labels != nil { - model.ValidateLabels(fe, "labels", *in.Labels) - p.Labels = jsonStr(*in.Labels) - } - if in.Maintenance != nil { - p.Maintenance = b2i(*in.Maintenance) - } - if in.Enabled != nil { - p.Enabled = b2i(*in.Enabled) - } - if !fe.Empty() { - fail(w, errInput(fe)) - return - } - if p.Enabled != 0 && f.Enabled == 0 { - // Enabling brings the frontend's listeners back: they must not collide. - probe := *f - probe.Enabled = 1 - snap := s.Snap.Get() - if msg := listenerCollision(snap, &probe, reconcile.NeedsACME(snap, f.ID, f.CertIDs)); msg != "" { - fail(w, errField("enabled", msg)) - return - } - } - if err := s.mutate(r, "frontend.patch", "frontend", func() int64 { return id }, in, func(q *db.Queries) error { - return q.UpdateFrontend(r.Context(), p) - }); err != nil { - fail(w, err) - return - } - s.respondFrontend(w, 200, id) -} - -func (s *Server) DeleteFrontend(w http.ResponseWriter, r *http.Request, id gen.Id) { - f, err := s.frontend(id) - if err != nil { - fail(w, err) - return - } - if err := s.mutate(r, "frontend.delete", "frontend", func() int64 { return id }, map[string]any{"name": f.Name}, func(q *db.Queries) error { - return q.DeleteFrontend(r.Context(), id) - }); err != nil { - fail(w, err) - return - } - w.WriteHeader(204) -} - -func (s *Server) GetFrontendConfig(w http.ResponseWriter, r *http.Request, id gen.Id) { - f, err := s.frontend(id) - if err != nil { - fail(w, err) - return - } - out, err := s.Rec.RenderLive(r.Context()) - if err != nil { - fail(w, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = w.Write([]byte(render.FrontendSections(out.Config(), f))) -} - -func (s *Server) RetryApply(w http.ResponseWriter, r *http.Request) { - s.Rec.Trigger() - writeJSON(w, 202, map[string]any{"action": map[string]any{"command": "retry_apply", "status": "running"}}) -} - -func metricsParams(start, end *int64, step *int, typ *string) (int64, int64, int64, []string) { - var a, b, st int64 - if start != nil { - a = *start - } - if end != nil { - b = *end - } - if step != nil { - st = int64(*step) - } - var types []string - if typ != nil && *typ != "" { - types = strings.Split(*typ, ",") - } - return a, b, st, types -} - -func (s *Server) writeSeries(w http.ResponseWriter, r *http.Request, scope metrics.Scope, id, start, end, step int64, types []string) { - series, err := s.Metrics.Query(r.Context(), scope, id, start, end, step, types) - if err != nil { - fail(w, err) - return - } - writeJSON(w, 200, map[string]any{"start": series.Start, "end": series.End, "step": series.Step, "timestamps": series.Timestamps, "series": series.Values}) -} - -func (s *Server) GetFrontendMetrics(w http.ResponseWriter, r *http.Request, id gen.Id, p gen.GetFrontendMetricsParams) { - if _, err := s.frontend(id); err != nil { - fail(w, err) - return - } - a, b, st, types := metricsParams(p.Start, p.End, p.Step, p.Type) - s.writeSeries(w, r, metrics.FE, id, a, b, st, types) -} - -func (s *Server) StreamFrontendMetrics(w http.ResponseWriter, r *http.Request, id gen.Id) { - if _, err := s.frontend(id); err != nil { - fail(w, err) - return - } - fl, ok := w.(http.Flusher) - if !ok { - fail(w, fmt.Errorf("streaming unsupported")) - return - } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Accel-Buffering", "no") - ch, cancel := s.Metrics.Subscribe(id) - defer cancel() - fl.Flush() - for { - select { - case <-r.Context().Done(): - return - case p := <-ch: - b, _ := json.Marshal(map[string]any{"ts": p.TS, "open_connections": p.Open, "connections_per_second": p.CPS, - "requests_per_second": p.RPS, "bandwidth.in": p.In, "bandwidth.out": p.Out}) - fmt.Fprintf(w, "event: metrics.tick\ndata: %s\n\n", b) - fl.Flush() - } - } -} - -// GetOverview is the dashboard summary. -func (s *Server) GetOverview(w http.ResponseWriter, r *http.Request) { - snap := s.Snap.Get() - fes := map[string]int{"total": len(snap.Frontends), "apply_errors": 0} - for _, f := range snap.Frontends { - if f.Status == "apply_error" { - fes["apply_errors"]++ - } - } - bes := map[string]int{"total": len(snap.Backends), "healthy": 0, "degraded": 0, "unhealthy": 0, "unknown": 0} - srv := map[string]int{"total": 0, "healthy": 0} - for _, b := range snap.Backends { - sum, rs := s.backendHealth(b) - bes[sum.Status]++ - srv["total"] += len(rs) - srv["healthy"] += sum.Healthy - } - certs := map[string]int{"total": len(snap.Certs), "expiring_soon": 0, "failed": 0} - soon := nowMs() + 21*24*3600*1000 - for _, c := range snap.Certs { - if c.Status == "failed" { - certs["failed"]++ - } - if c.NotAfter.Valid && c.NotAfter.Int64 < soon { - certs["expiring_soon"]++ - } - } - var t metrics.Point - if s.Metrics != nil { - t = s.Metrics.Latest(metrics.FE, 0) - } - var node any - if len(snap.Nodes) > 0 { - node = s.viewNode(snap.Nodes[0]) - } - writeJSON(w, 200, map[string]any{"frontends": fes, "backends": bes, "servers": srv, "certificates": certs, "node": node, - "traffic": map[string]any{"open_connections": t.Open, "requests_per_second": t.RPS, "bytes_in_per_second": t.In, "bytes_out_per_second": t.Out}}) -} diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index acc9c1e..c7c747d 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -97,216 +97,6 @@ func (e AlertRuleInputMinSeverity) Valid() bool { } } -// Defines values for BackendAlgorithm. -const ( - BackendAlgorithmLeastconn BackendAlgorithm = "leastconn" - BackendAlgorithmRandom BackendAlgorithm = "random" - BackendAlgorithmRoundrobin BackendAlgorithm = "roundrobin" - BackendAlgorithmSource BackendAlgorithm = "source" -) - -// Valid indicates whether the value is a known member of the BackendAlgorithm enum. -func (e BackendAlgorithm) Valid() bool { - switch e { - case BackendAlgorithmLeastconn: - return true - case BackendAlgorithmRandom: - return true - case BackendAlgorithmRoundrobin: - return true - case BackendAlgorithmSource: - return true - default: - return false - } -} - -// Defines values for BackendMode. -const ( - BackendModeHttp BackendMode = "http" - BackendModeTcp BackendMode = "tcp" -) - -// Valid indicates whether the value is a known member of the BackendMode enum. -func (e BackendMode) Valid() bool { - switch e { - case BackendModeHttp: - return true - case BackendModeTcp: - return true - default: - return false - } -} - -// Defines values for BackendProxyProtocol. -const ( - BackendProxyProtocolNone BackendProxyProtocol = "none" - BackendProxyProtocolV1 BackendProxyProtocol = "v1" - BackendProxyProtocolV2 BackendProxyProtocol = "v2" -) - -// Valid indicates whether the value is a known member of the BackendProxyProtocol enum. -func (e BackendProxyProtocol) Valid() bool { - switch e { - case BackendProxyProtocolNone: - return true - case BackendProxyProtocolV1: - return true - case BackendProxyProtocolV2: - return true - default: - return false - } -} - -// Defines values for BackendInputAlgorithm. -const ( - BackendInputAlgorithmLeastconn BackendInputAlgorithm = "leastconn" - BackendInputAlgorithmRandom BackendInputAlgorithm = "random" - BackendInputAlgorithmRoundrobin BackendInputAlgorithm = "roundrobin" - BackendInputAlgorithmSource BackendInputAlgorithm = "source" -) - -// Valid indicates whether the value is a known member of the BackendInputAlgorithm enum. -func (e BackendInputAlgorithm) Valid() bool { - switch e { - case BackendInputAlgorithmLeastconn: - return true - case BackendInputAlgorithmRandom: - return true - case BackendInputAlgorithmRoundrobin: - return true - case BackendInputAlgorithmSource: - return true - default: - return false - } -} - -// Defines values for BackendInputMode. -const ( - BackendInputModeHttp BackendInputMode = "http" - BackendInputModeTcp BackendInputMode = "tcp" -) - -// Valid indicates whether the value is a known member of the BackendInputMode enum. -func (e BackendInputMode) Valid() bool { - switch e { - case BackendInputModeHttp: - return true - case BackendInputModeTcp: - return true - default: - return false - } -} - -// Defines values for BackendInputProxyProtocol. -const ( - BackendInputProxyProtocolNone BackendInputProxyProtocol = "none" - BackendInputProxyProtocolV1 BackendInputProxyProtocol = "v1" - BackendInputProxyProtocolV2 BackendInputProxyProtocol = "v2" -) - -// Valid indicates whether the value is a known member of the BackendInputProxyProtocol enum. -func (e BackendInputProxyProtocol) Valid() bool { - switch e { - case BackendInputProxyProtocolNone: - return true - case BackendInputProxyProtocolV1: - return true - case BackendInputProxyProtocolV2: - return true - default: - return false - } -} - -// Defines values for BackendServerAdminState. -const ( - BackendServerAdminStateDrain BackendServerAdminState = "drain" - BackendServerAdminStateMaint BackendServerAdminState = "maint" - BackendServerAdminStateReady BackendServerAdminState = "ready" -) - -// Valid indicates whether the value is a known member of the BackendServerAdminState enum. -func (e BackendServerAdminState) Valid() bool { - switch e { - case BackendServerAdminStateDrain: - return true - case BackendServerAdminStateMaint: - return true - case BackendServerAdminStateReady: - return true - default: - return false - } -} - -// Defines values for BackendServerType. -const ( - BackendServerTypeHost BackendServerType = "host" - BackendServerTypeIp BackendServerType = "ip" - BackendServerTypeLabelSelector BackendServerType = "label_selector" -) - -// Valid indicates whether the value is a known member of the BackendServerType enum. -func (e BackendServerType) Valid() bool { - switch e { - case BackendServerTypeHost: - return true - case BackendServerTypeIp: - return true - case BackendServerTypeLabelSelector: - return true - default: - return false - } -} - -// Defines values for BackendServerInputType. -const ( - BackendServerInputTypeHost BackendServerInputType = "host" - BackendServerInputTypeIp BackendServerInputType = "ip" - BackendServerInputTypeLabelSelector BackendServerInputType = "label_selector" -) - -// Valid indicates whether the value is a known member of the BackendServerInputType enum. -func (e BackendServerInputType) Valid() bool { - switch e { - case BackendServerInputTypeHost: - return true - case BackendServerInputTypeIp: - return true - case BackendServerInputTypeLabelSelector: - return true - default: - return false - } -} - -// Defines values for BackendServerUpdateAdminState. -const ( - BackendServerUpdateAdminStateDrain BackendServerUpdateAdminState = "drain" - BackendServerUpdateAdminStateMaint BackendServerUpdateAdminState = "maint" - BackendServerUpdateAdminStateReady BackendServerUpdateAdminState = "ready" -) - -// Valid indicates whether the value is a known member of the BackendServerUpdateAdminState enum. -func (e BackendServerUpdateAdminState) Valid() bool { - switch e { - case BackendServerUpdateAdminStateDrain: - return true - case BackendServerUpdateAdminStateMaint: - return true - case BackendServerUpdateAdminStateReady: - return true - default: - return false - } -} - // Defines values for CertificateChallenge. const ( CertificateChallengeDns01 CertificateChallenge = "dns01" @@ -508,6 +298,24 @@ func (e DomainStatus) Valid() bool { } } +// Defines values for EndpointKind. +const ( + EndpointKindDomain EndpointKind = "domain" + EndpointKindPort EndpointKind = "port" +) + +// Valid indicates whether the value is a known member of the EndpointKind enum. +func (e EndpointKind) Valid() bool { + switch e { + case EndpointKindDomain: + return true + case EndpointKindPort: + return true + default: + return false + } +} + // Defines values for ErrorResponseErrorCode. const ( ApplyFailed ErrorResponseErrorCode = "apply_failed" @@ -568,63 +376,6 @@ func (e EventSeverity) Valid() bool { } } -// Defines values for FrontendMode. -const ( - FrontendModeHttp FrontendMode = "http" - FrontendModeTcp FrontendMode = "tcp" -) - -// Valid indicates whether the value is a known member of the FrontendMode enum. -func (e FrontendMode) Valid() bool { - switch e { - case FrontendModeHttp: - return true - case FrontendModeTcp: - return true - default: - return false - } -} - -// Defines values for FrontendStatus. -const ( - Active FrontendStatus = "active" - ApplyError FrontendStatus = "apply_error" - Applying FrontendStatus = "applying" -) - -// Valid indicates whether the value is a known member of the FrontendStatus enum. -func (e FrontendStatus) Valid() bool { - switch e { - case Active: - return true - case ApplyError: - return true - case Applying: - return true - default: - return false - } -} - -// Defines values for FrontendInputMode. -const ( - FrontendInputModeHttp FrontendInputMode = "http" - FrontendInputModeTcp FrontendInputMode = "tcp" -) - -// Valid indicates whether the value is a known member of the FrontendInputMode enum. -func (e FrontendInputMode) Valid() bool { - switch e { - case FrontendInputModeHttp: - return true - case FrontendInputModeTcp: - return true - default: - return false - } -} - // Defines values for HealthCheckProtocol. const ( HealthCheckProtocolHttp HealthCheckProtocol = "http" @@ -718,27 +469,117 @@ func (e NotificationChannelInputKind) Valid() bool { } } -// Defines values for ResolvedServerStatus. +// Defines values for ProjectAlgorithm. +const ( + ProjectAlgorithmLeastconn ProjectAlgorithm = "leastconn" + ProjectAlgorithmRandom ProjectAlgorithm = "random" + ProjectAlgorithmRoundrobin ProjectAlgorithm = "roundrobin" + ProjectAlgorithmSource ProjectAlgorithm = "source" +) + +// Valid indicates whether the value is a known member of the ProjectAlgorithm enum. +func (e ProjectAlgorithm) Valid() bool { + switch e { + case ProjectAlgorithmLeastconn: + return true + case ProjectAlgorithmRandom: + return true + case ProjectAlgorithmRoundrobin: + return true + case ProjectAlgorithmSource: + return true + default: + return false + } +} + +// Defines values for ProjectStatus. +const ( + ProjectStatusApplying ProjectStatus = "applying" + ProjectStatusError ProjectStatus = "error" + ProjectStatusReady ProjectStatus = "ready" +) + +// Valid indicates whether the value is a known member of the ProjectStatus enum. +func (e ProjectStatus) Valid() bool { + switch e { + case ProjectStatusApplying: + return true + case ProjectStatusError: + return true + case ProjectStatusReady: + return true + default: + return false + } +} + +// Defines values for ProjectCreateAlgorithm. +const ( + ProjectCreateAlgorithmLeastconn ProjectCreateAlgorithm = "leastconn" + ProjectCreateAlgorithmRandom ProjectCreateAlgorithm = "random" + ProjectCreateAlgorithmRoundrobin ProjectCreateAlgorithm = "roundrobin" + ProjectCreateAlgorithmSource ProjectCreateAlgorithm = "source" +) + +// Valid indicates whether the value is a known member of the ProjectCreateAlgorithm enum. +func (e ProjectCreateAlgorithm) Valid() bool { + switch e { + case ProjectCreateAlgorithmLeastconn: + return true + case ProjectCreateAlgorithmRandom: + return true + case ProjectCreateAlgorithmRoundrobin: + return true + case ProjectCreateAlgorithmSource: + return true + default: + return false + } +} + +// Defines values for ProjectUpdateAlgorithm. const ( - ResolvedServerStatusDraining ResolvedServerStatus = "draining" - ResolvedServerStatusHealthy ResolvedServerStatus = "healthy" - ResolvedServerStatusMaintenance ResolvedServerStatus = "maintenance" - ResolvedServerStatusUnhealthy ResolvedServerStatus = "unhealthy" - ResolvedServerStatusUnknown ResolvedServerStatus = "unknown" + ProjectUpdateAlgorithmLeastconn ProjectUpdateAlgorithm = "leastconn" + ProjectUpdateAlgorithmRandom ProjectUpdateAlgorithm = "random" + ProjectUpdateAlgorithmRoundrobin ProjectUpdateAlgorithm = "roundrobin" + ProjectUpdateAlgorithmSource ProjectUpdateAlgorithm = "source" ) -// Valid indicates whether the value is a known member of the ResolvedServerStatus enum. -func (e ResolvedServerStatus) Valid() bool { +// Valid indicates whether the value is a known member of the ProjectUpdateAlgorithm enum. +func (e ProjectUpdateAlgorithm) Valid() bool { switch e { - case ResolvedServerStatusDraining: + case ProjectUpdateAlgorithmLeastconn: + return true + case ProjectUpdateAlgorithmRandom: return true - case ResolvedServerStatusHealthy: + case ProjectUpdateAlgorithmRoundrobin: + return true + case ProjectUpdateAlgorithmSource: + return true + default: + return false + } +} + +// Defines values for ResolvedTargetStatus. +const ( + ResolvedTargetStatusDegraded ResolvedTargetStatus = "degraded" + ResolvedTargetStatusHealthy ResolvedTargetStatus = "healthy" + ResolvedTargetStatusUnhealthy ResolvedTargetStatus = "unhealthy" + ResolvedTargetStatusUnknown ResolvedTargetStatus = "unknown" +) + +// Valid indicates whether the value is a known member of the ResolvedTargetStatus enum. +func (e ResolvedTargetStatus) Valid() bool { + switch e { + case ResolvedTargetStatusDegraded: return true - case ResolvedServerStatusMaintenance: + case ResolvedTargetStatusHealthy: return true - case ResolvedServerStatusUnhealthy: + case ResolvedTargetStatusUnhealthy: return true - case ResolvedServerStatusUnknown: + case ResolvedTargetStatusUnknown: return true default: return false @@ -787,63 +628,252 @@ func (e RevisionResult) Valid() bool { } } -// Defines values for UserRole. +// Defines values for ServiceProtocol. const ( - UserRoleAdmin UserRole = "admin" - UserRoleOperator UserRole = "operator" - UserRoleOwner UserRole = "owner" - UserRoleViewer UserRole = "viewer" + ServiceProtocolHttp ServiceProtocol = "http" + ServiceProtocolHttps ServiceProtocol = "https" + ServiceProtocolTcp ServiceProtocol = "tcp" ) -// Valid indicates whether the value is a known member of the UserRole enum. -func (e UserRole) Valid() bool { +// Valid indicates whether the value is a known member of the ServiceProtocol enum. +func (e ServiceProtocol) Valid() bool { switch e { - case UserRoleAdmin: - return true - case UserRoleOperator: + case ServiceProtocolHttp: return true - case UserRoleOwner: + case ServiceProtocolHttps: return true - case UserRoleViewer: + case ServiceProtocolTcp: return true default: return false } } -// Defines values for UserCreateRole. +// Defines values for ServiceProxyProtocol. const ( - UserCreateRoleAdmin UserCreateRole = "admin" - UserCreateRoleOperator UserCreateRole = "operator" - UserCreateRoleViewer UserCreateRole = "viewer" + ServiceProxyProtocolNone ServiceProxyProtocol = "none" + ServiceProxyProtocolV1 ServiceProxyProtocol = "v1" + ServiceProxyProtocolV2 ServiceProxyProtocol = "v2" ) -// Valid indicates whether the value is a known member of the UserCreateRole enum. -func (e UserCreateRole) Valid() bool { +// Valid indicates whether the value is a known member of the ServiceProxyProtocol enum. +func (e ServiceProxyProtocol) Valid() bool { switch e { - case UserCreateRoleAdmin: + case ServiceProxyProtocolNone: return true - case UserCreateRoleOperator: + case ServiceProxyProtocolV1: return true - case UserCreateRoleViewer: + case ServiceProxyProtocolV2: return true default: return false } } -// APIToken defines model for APIToken. -type APIToken struct { - CreatedAt int64 `json:"created_at"` - ExpiresAt *int64 `json:"expires_at,omitempty"` - Id int64 `json:"id"` - LastUsedAt *int64 `json:"last_used_at,omitempty"` - Name string `json:"name"` - Prefix string `json:"prefix"` - Role APITokenRole `json:"role"` +// Defines values for ServiceInputProtocol. +const ( + ServiceInputProtocolHttp ServiceInputProtocol = "http" + ServiceInputProtocolHttps ServiceInputProtocol = "https" + ServiceInputProtocolTcp ServiceInputProtocol = "tcp" +) + +// Valid indicates whether the value is a known member of the ServiceInputProtocol enum. +func (e ServiceInputProtocol) Valid() bool { + switch e { + case ServiceInputProtocolHttp: + return true + case ServiceInputProtocolHttps: + return true + case ServiceInputProtocolTcp: + return true + default: + return false + } } -// APITokenRole defines model for APIToken.Role. +// Defines values for ServiceInputProxyProtocol. +const ( + ServiceInputProxyProtocolNone ServiceInputProxyProtocol = "none" + ServiceInputProxyProtocolV1 ServiceInputProxyProtocol = "v1" + ServiceInputProxyProtocolV2 ServiceInputProxyProtocol = "v2" +) + +// Valid indicates whether the value is a known member of the ServiceInputProxyProtocol enum. +func (e ServiceInputProxyProtocol) Valid() bool { + switch e { + case ServiceInputProxyProtocolNone: + return true + case ServiceInputProxyProtocolV1: + return true + case ServiceInputProxyProtocolV2: + return true + default: + return false + } +} + +// Defines values for TargetAdminState. +const ( + TargetAdminStateDrain TargetAdminState = "drain" + TargetAdminStateMaint TargetAdminState = "maint" + TargetAdminStateReady TargetAdminState = "ready" +) + +// Valid indicates whether the value is a known member of the TargetAdminState enum. +func (e TargetAdminState) Valid() bool { + switch e { + case TargetAdminStateDrain: + return true + case TargetAdminStateMaint: + return true + case TargetAdminStateReady: + return true + default: + return false + } +} + +// Defines values for TargetType. +const ( + TargetTypeHost TargetType = "host" + TargetTypeIp TargetType = "ip" + TargetTypeLabelSelector TargetType = "label_selector" +) + +// Valid indicates whether the value is a known member of the TargetType enum. +func (e TargetType) Valid() bool { + switch e { + case TargetTypeHost: + return true + case TargetTypeIp: + return true + case TargetTypeLabelSelector: + return true + default: + return false + } +} + +// Defines values for TargetCheckStatus. +const ( + TargetCheckStatusHealthy TargetCheckStatus = "healthy" + TargetCheckStatusUnhealthy TargetCheckStatus = "unhealthy" + TargetCheckStatusUnknown TargetCheckStatus = "unknown" +) + +// Valid indicates whether the value is a known member of the TargetCheckStatus enum. +func (e TargetCheckStatus) Valid() bool { + switch e { + case TargetCheckStatusHealthy: + return true + case TargetCheckStatusUnhealthy: + return true + case TargetCheckStatusUnknown: + return true + default: + return false + } +} + +// Defines values for TargetInputType. +const ( + TargetInputTypeHost TargetInputType = "host" + TargetInputTypeIp TargetInputType = "ip" + TargetInputTypeLabelSelector TargetInputType = "label_selector" +) + +// Valid indicates whether the value is a known member of the TargetInputType enum. +func (e TargetInputType) Valid() bool { + switch e { + case TargetInputTypeHost: + return true + case TargetInputTypeIp: + return true + case TargetInputTypeLabelSelector: + return true + default: + return false + } +} + +// Defines values for TargetUpdateAdminState. +const ( + TargetUpdateAdminStateDrain TargetUpdateAdminState = "drain" + TargetUpdateAdminStateMaint TargetUpdateAdminState = "maint" + TargetUpdateAdminStateReady TargetUpdateAdminState = "ready" +) + +// Valid indicates whether the value is a known member of the TargetUpdateAdminState enum. +func (e TargetUpdateAdminState) Valid() bool { + switch e { + case TargetUpdateAdminStateDrain: + return true + case TargetUpdateAdminStateMaint: + return true + case TargetUpdateAdminStateReady: + return true + default: + return false + } +} + +// Defines values for UserRole. +const ( + UserRoleAdmin UserRole = "admin" + UserRoleOperator UserRole = "operator" + UserRoleOwner UserRole = "owner" + UserRoleViewer UserRole = "viewer" +) + +// Valid indicates whether the value is a known member of the UserRole enum. +func (e UserRole) Valid() bool { + switch e { + case UserRoleAdmin: + return true + case UserRoleOperator: + return true + case UserRoleOwner: + return true + case UserRoleViewer: + return true + default: + return false + } +} + +// Defines values for UserCreateRole. +const ( + UserCreateRoleAdmin UserCreateRole = "admin" + UserCreateRoleOperator UserCreateRole = "operator" + UserCreateRoleViewer UserCreateRole = "viewer" +) + +// Valid indicates whether the value is a known member of the UserCreateRole enum. +func (e UserCreateRole) Valid() bool { + switch e { + case UserCreateRoleAdmin: + return true + case UserCreateRoleOperator: + return true + case UserCreateRoleViewer: + return true + default: + return false + } +} + +// APIToken defines model for APIToken. +type APIToken struct { + CreatedAt int64 `json:"created_at"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + Id int64 `json:"id"` + LastUsedAt *int64 `json:"last_used_at,omitempty"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Role APITokenRole `json:"role"` +} + +// APITokenRole defines model for APIToken.Role. type APITokenRole string // APITokenCreate defines model for APITokenCreate. @@ -863,7 +893,7 @@ type AlertRule struct { EventKinds []string `json:"event_kinds"` Id int64 `json:"id"` - // LabelFilter label selector on frontend/backend labels; node-wide events (apply, backup) are not filtered by it + // LabelFilter label selector on project labels; node-wide events (apply, backup) are not filtered by it LabelFilter string `json:"label_filter"` MinSeverity AlertRuleMinSeverity `json:"min_severity"` } @@ -879,7 +909,7 @@ type AlertRuleInput struct { // EventKinds e.g. server.health.changed, apply.*; empty = all EventKinds *[]string `json:"event_kinds,omitempty"` - // LabelFilter label selector on frontend/backend labels; node-wide events (apply, backup) are not filtered by it + // LabelFilter label selector on project labels; node-wide events (apply, backup) are not filtered by it LabelFilter *string `json:"label_filter,omitempty"` MinSeverity *AlertRuleInputMinSeverity `json:"min_severity,omitempty"` } @@ -900,141 +930,6 @@ type AuditEntry struct { Ts int64 `json:"ts"` } -// Backend defines model for Backend. -type Backend struct { - Algorithm BackendAlgorithm `json:"algorithm"` - CookieLifetimeS int `json:"cookie_lifetime_s"` - CookieName string `json:"cookie_name"` - CreatedAt int64 `json:"created_at"` - Description *string `json:"description,omitempty"` - Health HealthSummary `json:"health"` - HealthCheck HealthCheck `json:"health_check"` - Id int64 `json:"id"` - Labels Labels `json:"labels"` - Mode BackendMode `json:"mode"` - Name string `json:"name"` - - // Port default server port - Port int `json:"port"` - ProxyProtocol BackendProxyProtocol `json:"proxy_protocol"` - ServersCount int `json:"servers_count"` - Stats struct { - Queue float32 `json:"queue"` - ResponseTimeMs float32 `json:"response_time_ms"` - Sessions float32 `json:"sessions"` - } `json:"stats"` - StickySessions bool `json:"sticky_sessions"` - TimeoutServerS int `json:"timeout_server_s"` - - // UsedBy frontend ids - UsedBy []int64 `json:"used_by"` - Version int64 `json:"version"` -} - -// BackendAlgorithm defines model for Backend.Algorithm. -type BackendAlgorithm string - -// BackendMode defines model for Backend.Mode. -type BackendMode string - -// BackendProxyProtocol defines model for Backend.ProxyProtocol. -type BackendProxyProtocol string - -// BackendHealth defines model for BackendHealth. -type BackendHealth struct { - Servers []ResolvedServer `json:"servers"` - Summary HealthSummary `json:"summary"` -} - -// BackendInput defines model for BackendInput. -type BackendInput struct { - Algorithm *BackendInputAlgorithm `json:"algorithm,omitempty"` - CookieLifetimeS *int `json:"cookie_lifetime_s,omitempty"` - CookieName *string `json:"cookie_name,omitempty"` - Description *string `json:"description,omitempty"` - HealthCheck *HealthCheck `json:"health_check,omitempty"` - Labels *Labels `json:"labels,omitempty"` - Mode BackendInputMode `json:"mode"` - Name string `json:"name"` - - // Port default server port - Port int `json:"port"` - ProxyProtocol *BackendInputProxyProtocol `json:"proxy_protocol,omitempty"` - StickySessions *bool `json:"sticky_sessions,omitempty"` - TimeoutServerS *int `json:"timeout_server_s,omitempty"` -} - -// BackendInputAlgorithm defines model for BackendInput.Algorithm. -type BackendInputAlgorithm string - -// BackendInputMode defines model for BackendInput.Mode. -type BackendInputMode string - -// BackendInputProxyProtocol defines model for BackendInput.ProxyProtocol. -type BackendInputProxyProtocol string - -// BackendServer defines model for BackendServer. -type BackendServer struct { - AdminState BackendServerAdminState `json:"admin_state"` - BackendId int64 `json:"backend_id"` - Backup bool `json:"backup"` - HostId *int64 `json:"host_id,omitempty"` - Id int64 `json:"id"` - Ip *string `json:"ip,omitempty"` - LabelSelector *string `json:"label_selector,omitempty"` - Port *int `json:"port,omitempty"` - Removing *bool `json:"removing,omitempty"` - Resolved []ResolvedServer `json:"resolved"` - Type BackendServerType `json:"type"` - UsePrivateIp bool `json:"use_private_ip"` - Weight int `json:"weight"` -} - -// BackendServerAdminState defines model for BackendServer.AdminState. -type BackendServerAdminState string - -// BackendServerType defines model for BackendServer.Type. -type BackendServerType string - -// BackendServerInput defines model for BackendServerInput. -type BackendServerInput struct { - Backup *bool `json:"backup,omitempty"` - HostId *int64 `json:"host_id,omitempty"` - Ip *string `json:"ip,omitempty"` - LabelSelector *string `json:"label_selector,omitempty"` - - // Port override the backend port - Port *int `json:"port,omitempty"` - Type BackendServerInputType `json:"type"` - UsePrivateIp *bool `json:"use_private_ip,omitempty"` - Weight *int `json:"weight,omitempty"` -} - -// BackendServerInputType defines model for BackendServerInput.Type. -type BackendServerInputType string - -// BackendServerUpdate defines model for BackendServerUpdate. -type BackendServerUpdate struct { - AdminState *BackendServerUpdateAdminState `json:"admin_state,omitempty"` - Backup *bool `json:"backup,omitempty"` - Weight *int `json:"weight,omitempty"` -} - -// BackendServerUpdateAdminState defines model for BackendServerUpdate.AdminState. -type BackendServerUpdateAdminState string - -// BackendServersCreate defines model for BackendServersCreate. -type BackendServersCreate struct { - Servers []BackendServerInput `json:"servers"` -} - -// Bind defines model for Bind. -type Bind struct { - // Address IP address, or "*" for all interfaces - Address string `json:"address"` - Port int `json:"port"` -} - // Certificate defines model for Certificate. type Certificate struct { Challenge *CertificateChallenge `json:"challenge,omitempty"` @@ -1168,16 +1063,25 @@ type Domain struct { CreatedAt int64 `json:"created_at"` DnsProviderId *int64 `json:"dns_provider_id,omitempty"` Fqdn string `json:"fqdn"` - FrontendId *int64 `json:"frontend_id,omitempty"` - Id int64 `json:"id"` - Observed struct { + + // Https serve on :443 with an automatic certificate and redirect :80 to it (default true) + Https bool `json:"https"` + Id int64 `json:"id"` + Observed struct { A []string `json:"a"` Aaaa []string `json:"aaaa"` } `json:"observed"` + ProjectId *int64 `json:"project_id,omitempty"` RequiredRecords []DNSRequiredRecord `json:"required_records"` - Status DomainStatus `json:"status"` - StatusDetail *string `json:"status_detail,omitempty"` - Zone *string `json:"zone,omitempty"` + + // ServiceId service the domain routes to (default: the project's first http/https service) + ServiceId *int64 `json:"service_id,omitempty"` + Status DomainStatus `json:"status"` + StatusDetail *string `json:"status_detail,omitempty"` + + // Url https://fqdn once the certificate is issued, else http://fqdn + Url *string `json:"url,omitempty"` + Zone *string `json:"zone,omitempty"` } // DomainStatus defines model for Domain.Status. @@ -1188,7 +1092,13 @@ type DomainInput struct { AutoRecords *bool `json:"auto_records,omitempty"` DnsProviderId *int64 `json:"dns_provider_id,omitempty"` Fqdn *string `json:"fqdn,omitempty"` - FrontendId *int64 `json:"frontend_id,omitempty"` + + // Https serve on :443 with an automatic certificate and redirect :80 to it (default true) + Https *bool `json:"https,omitempty"` + ProjectId *int64 `json:"project_id,omitempty"` + + // ServiceId default: the project's first http/https service + ServiceId *int64 `json:"service_id,omitempty"` } // DomainResponse defines model for DomainResponse. @@ -1196,6 +1106,17 @@ type DomainResponse struct { Domain Domain `json:"domain"` } +// Endpoint defines model for Endpoint. +type Endpoint struct { + DomainId *int64 `json:"domain_id,omitempty"` + Kind EndpointKind `json:"kind"` + ServiceId *int64 `json:"service_id,omitempty"` + Url string `json:"url"` +} + +// EndpointKind defines model for Endpoint.Kind. +type EndpointKind string + // ErrorResponse defines model for ErrorResponse. type ErrorResponse struct { Error struct { @@ -1215,101 +1136,18 @@ type ErrorResponseErrorCode string // Event defines model for Event. type Event struct { - BackendId *int64 `json:"backend_id,omitempty"` - Data *map[string]interface{} `json:"data,omitempty"` - FrontendId *int64 `json:"frontend_id,omitempty"` - Id int64 `json:"id"` - Kind string `json:"kind"` - Message string `json:"message"` - Severity EventSeverity `json:"severity"` - Ts int64 `json:"ts"` + Data *map[string]interface{} `json:"data,omitempty"` + Id int64 `json:"id"` + Kind string `json:"kind"` + Message string `json:"message"` + ProjectId *int64 `json:"project_id,omitempty"` + Severity EventSeverity `json:"severity"` + Ts int64 `json:"ts"` } // EventSeverity defines model for Event.Severity. type EventSeverity string -// Frontend defines model for Frontend. -type Frontend struct { - Binds []Bind `json:"binds"` - Certificates []int64 `json:"certificates"` - CreatedAt int64 `json:"created_at"` - DefaultBackendId *int64 `json:"default_backend_id"` - Description *string `json:"description,omitempty"` - Enabled bool `json:"enabled"` - - // ForwardedHeaders http: X-Forwarded-For/Proto/Port - ForwardedHeaders bool `json:"forwarded_headers"` - Health HealthSummary `json:"health"` - - // HttpRedirect tls only: also listen on :80 and redirect to https - HttpRedirect bool `json:"http_redirect"` - Id int64 `json:"id"` - Labels Labels `json:"labels"` - LastError *string `json:"last_error,omitempty"` - Maintenance bool `json:"maintenance"` - - // Maxconn 0 = unlimited - Maxconn int `json:"maxconn"` - Mode FrontendMode `json:"mode"` - Name string `json:"name"` - Routes []Route `json:"routes"` - Stats struct { - BytesOutPerSecond float32 `json:"bytes_out_per_second"` - OpenConnections float32 `json:"open_connections"` - RequestsPerSecond float32 `json:"requests_per_second"` - } `json:"stats"` - Status FrontendStatus `json:"status"` - TimeoutClientS int `json:"timeout_client_s"` - - // Tls terminate TLS (http mode); requires certificates - Tls bool `json:"tls"` - Version int64 `json:"version"` -} - -// FrontendMode defines model for Frontend.Mode. -type FrontendMode string - -// FrontendStatus defines model for Frontend.Status. -type FrontendStatus string - -// FrontendInput defines model for FrontendInput. -type FrontendInput struct { - Binds []Bind `json:"binds"` - Certificates *[]int64 `json:"certificates,omitempty"` - DefaultBackendId *int64 `json:"default_backend_id,omitempty"` - Description *string `json:"description,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - - // ForwardedHeaders http: X-Forwarded-For/Proto/Port - ForwardedHeaders *bool `json:"forwarded_headers,omitempty"` - - // HttpRedirect tls only: also listen on :80 and redirect to https - HttpRedirect *bool `json:"http_redirect,omitempty"` - Labels *Labels `json:"labels,omitempty"` - - // Maxconn 0 = unlimited - Maxconn *int `json:"maxconn,omitempty"` - Mode FrontendInputMode `json:"mode"` - Name string `json:"name"` - Routes *[]Route `json:"routes,omitempty"` - TimeoutClientS *int `json:"timeout_client_s,omitempty"` - - // Tls terminate TLS (http mode); requires certificates - Tls *bool `json:"tls,omitempty"` -} - -// FrontendInputMode defines model for FrontendInput.Mode. -type FrontendInputMode string - -// FrontendPatch defines model for FrontendPatch. -type FrontendPatch struct { - Description *string `json:"description,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Labels *Labels `json:"labels,omitempty"` - Maintenance *bool `json:"maintenance,omitempty"` - Name *string `json:"name,omitempty"` -} - // HealthCheck defines model for HealthCheck. type HealthCheck struct { Http *struct { @@ -1352,7 +1190,7 @@ type Host struct { Notes *string `json:"notes,omitempty"` PrivateIpv4 *string `json:"private_ipv4,omitempty"` - // UsedBy backend ids + // UsedBy project ids UsedBy *[]int64 `json:"used_by,omitempty"` } @@ -1450,26 +1288,23 @@ type NotificationChannelInputKind string // Overview defines model for Overview. type Overview struct { - Backends struct { - Degraded int `json:"degraded"` - Healthy int `json:"healthy"` - Total int `json:"total"` - Unhealthy int `json:"unhealthy"` - } `json:"backends"` Certificates struct { ExpiringSoon int `json:"expiring_soon"` Failed int `json:"failed"` Total int `json:"total"` } `json:"certificates"` - Frontends struct { + Node Node `json:"node"` + Projects struct { ApplyErrors int `json:"apply_errors"` + Degraded int `json:"degraded"` + Healthy int `json:"healthy"` Total int `json:"total"` - } `json:"frontends"` - Node Node `json:"node"` - Servers struct { + Unhealthy int `json:"unhealthy"` + } `json:"projects"` + Targets struct { Healthy int `json:"healthy"` Total int `json:"total"` - } `json:"servers"` + } `json:"targets"` Traffic *struct { BytesInPerSecond float32 `json:"bytes_in_per_second"` BytesOutPerSecond float32 `json:"bytes_out_per_second"` @@ -1478,20 +1313,81 @@ type Overview struct { } `json:"traffic,omitempty"` } -// ResolvedServer defines model for ResolvedServer. -type ResolvedServer struct { - Address string `json:"address"` - Check *string `json:"check,omitempty"` - HostId *int64 `json:"host_id,omitempty"` - Name *string `json:"name,omitempty"` - Port int `json:"port"` - Sessions *float32 `json:"sessions,omitempty"` - SlotId int64 `json:"slot_id"` - Status ResolvedServerStatus `json:"status"` +// Project defines model for Project. +type Project struct { + Algorithm ProjectAlgorithm `json:"algorithm"` + Counts struct { + Domains int `json:"domains"` + Services int `json:"services"` + Targets int `json:"targets"` + } `json:"counts"` + CreatedAt int64 `json:"created_at"` + Description string `json:"description"` + + // Endpoints where the project is reachable: its ports and https/http domains + Endpoints []Endpoint `json:"endpoints"` + Health HealthSummary `json:"health"` + Id int64 `json:"id"` + Labels Labels `json:"labels"` + LastError *string `json:"last_error,omitempty"` + Maintenance bool `json:"maintenance"` + Name string `json:"name"` + Stats struct { + BytesOutPerSecond float32 `json:"bytes_out_per_second"` + OpenConnections float32 `json:"open_connections"` + RequestsPerSecond float32 `json:"requests_per_second"` + } `json:"stats"` + Status ProjectStatus `json:"status"` + Version int64 `json:"version"` +} + +// ProjectAlgorithm defines model for Project.Algorithm. +type ProjectAlgorithm string + +// ProjectStatus defines model for Project.Status. +type ProjectStatus string + +// ProjectCreate defines model for ProjectCreate. +type ProjectCreate struct { + Algorithm *ProjectCreateAlgorithm `json:"algorithm,omitempty"` + Description *string `json:"description,omitempty"` + Labels *Labels `json:"labels,omitempty"` + Name string `json:"name"` + + // Service first service; default http, own free port (listen_port omitted = auto), destination port 80 + Service *ServiceInput `json:"service,omitempty"` + + // Targets initial targets (optional) + Targets *[]TargetInput `json:"targets,omitempty"` +} + +// ProjectCreateAlgorithm defines model for ProjectCreate.Algorithm. +type ProjectCreateAlgorithm string + +// ProjectUpdate defines model for ProjectUpdate. +type ProjectUpdate struct { + Algorithm *ProjectUpdateAlgorithm `json:"algorithm,omitempty"` + Description *string `json:"description,omitempty"` + Labels *Labels `json:"labels,omitempty"` + Maintenance *bool `json:"maintenance,omitempty"` + Name *string `json:"name,omitempty"` +} + +// ProjectUpdateAlgorithm defines model for ProjectUpdate.Algorithm. +type ProjectUpdateAlgorithm string + +// ResolvedTarget defines model for ResolvedTarget. +type ResolvedTarget struct { + Address string `json:"address"` + Checks []TargetCheck `json:"checks"` + HostId *int64 `json:"host_id,omitempty"` + Name *string `json:"name,omitempty"` + SlotId int64 `json:"slot_id"` + Status ResolvedTargetStatus `json:"status"` } -// ResolvedServerStatus defines model for ResolvedServer.Status. -type ResolvedServerStatus string +// ResolvedTargetStatus defines model for ResolvedTarget.Status. +type ResolvedTargetStatus string // Revision defines model for Revision. type Revision struct { @@ -1513,18 +1409,57 @@ type RevisionKind string // RevisionResult defines model for Revision.Result. type RevisionResult string -// Route defines model for Route. -type Route struct { - BackendId int64 `json:"backend_id"` - - // Hosts Host header match; "*.example.com" matches subdomains - Hosts *[]string `json:"hosts,omitempty"` - Id *int64 `json:"id,omitempty"` +// Service defines model for Service. +type Service struct { + Certificates []int64 `json:"certificates"` + CookieLifetimeS int `json:"cookie_lifetime_s"` + CookieName string `json:"cookie_name"` + DestinationPort int `json:"destination_port"` + + // Domains domains routed to this service + Domains *[]string `json:"domains,omitempty"` + Health HealthSummary `json:"health"` + HealthCheck HealthCheck `json:"health_check"` + Id int64 `json:"id"` + ListenPort int `json:"listen_port"` + ProjectId int64 `json:"project_id"` + Protocol ServiceProtocol `json:"protocol"` + ProxyProtocol ServiceProxyProtocol `json:"proxy_protocol"` + StickySessions bool `json:"sticky_sessions"` + TimeoutS int `json:"timeout_s"` +} + +// ServiceProtocol defines model for Service.Protocol. +type ServiceProtocol string + +// ServiceProxyProtocol defines model for Service.ProxyProtocol. +type ServiceProxyProtocol string + +// ServiceInput defines model for ServiceInput. +type ServiceInput struct { + // Certificates https only + Certificates *[]int64 `json:"certificates,omitempty"` + CookieLifetimeS *int `json:"cookie_lifetime_s,omitempty"` + CookieName *string `json:"cookie_name,omitempty"` + DestinationPort int `json:"destination_port"` + HealthCheck *HealthCheck `json:"health_check,omitempty"` + + // ListenPort omit for a free port picked automatically (20000+); 80/443 are reserved for domains + ListenPort *int `json:"listen_port,omitempty"` + Protocol ServiceInputProtocol `json:"protocol"` + ProxyProtocol *ServiceInputProxyProtocol `json:"proxy_protocol,omitempty"` + StickySessions *bool `json:"sticky_sessions,omitempty"` - // PathPrefix e.g. /api; empty = any path - PathPrefix *string `json:"path_prefix,omitempty"` + // TimeoutS client/server timeout (default 60) + TimeoutS *int `json:"timeout_s,omitempty"` } +// ServiceInputProtocol defines model for ServiceInput.Protocol. +type ServiceInputProtocol string + +// ServiceInputProxyProtocol defines model for ServiceInput.ProxyProtocol. +type ServiceInputProxyProtocol string + // SessionResponse defines model for SessionResponse. type SessionResponse struct { CsrfToken string `json:"csrf_token"` @@ -1537,6 +1472,74 @@ type Status struct { Version *string `json:"version,omitempty"` } +// Target defines model for Target. +type Target struct { + AdminState TargetAdminState `json:"admin_state"` + Backup bool `json:"backup"` + HostId *int64 `json:"host_id,omitempty"` + Id int64 `json:"id"` + Ip *string `json:"ip,omitempty"` + LabelSelector *string `json:"label_selector,omitempty"` + Port *int `json:"port,omitempty"` + ProjectId int64 `json:"project_id"` + Removing bool `json:"removing"` + Resolved []ResolvedTarget `json:"resolved"` + Type TargetType `json:"type"` + UsePrivateIp bool `json:"use_private_ip"` + Weight int `json:"weight"` +} + +// TargetAdminState defines model for Target.AdminState. +type TargetAdminState string + +// TargetType defines model for Target.Type. +type TargetType string + +// TargetCheck defines model for TargetCheck. +type TargetCheck struct { + Check *string `json:"check,omitempty"` + ServiceId int64 `json:"service_id"` + Sessions *float32 `json:"sessions,omitempty"` + Status TargetCheckStatus `json:"status"` +} + +// TargetCheckStatus defines model for TargetCheck.Status. +type TargetCheckStatus string + +// TargetInput defines model for TargetInput. +type TargetInput struct { + Backup *bool `json:"backup,omitempty"` + HostId *int64 `json:"host_id,omitempty"` + + // Ip ip, optionally ip:port or [v6]:port + Ip *string `json:"ip,omitempty"` + LabelSelector *string `json:"label_selector,omitempty"` + + // Port override the service destination port for this target + Port *int `json:"port,omitempty"` + Type TargetInputType `json:"type"` + UsePrivateIp *bool `json:"use_private_ip,omitempty"` + Weight *int `json:"weight,omitempty"` +} + +// TargetInputType defines model for TargetInput.Type. +type TargetInputType string + +// TargetUpdate defines model for TargetUpdate. +type TargetUpdate struct { + AdminState *TargetUpdateAdminState `json:"admin_state,omitempty"` + Backup *bool `json:"backup,omitempty"` + Weight *int `json:"weight,omitempty"` +} + +// TargetUpdateAdminState defines model for TargetUpdate.AdminState. +type TargetUpdateAdminState string + +// TargetsCreate defines model for TargetsCreate. +type TargetsCreate struct { + Targets []TargetInput `json:"targets"` +} + // User defines model for User. type User struct { CreatedAt int64 `json:"created_at"` @@ -1581,46 +1584,22 @@ type TotpEnableJSONBody struct { Code string `json:"code"` } -// ListBackendsParams defines parameters for ListBackends. -type ListBackendsParams struct { - LabelSelector *string `form:"label_selector,omitempty" json:"label_selector,omitempty"` -} - -// GetBackendMetricsParams defines parameters for GetBackendMetrics. -type GetBackendMetricsParams struct { - // Type comma separated series names - Type *string `form:"type,omitempty" json:"type,omitempty"` - Start *int64 `form:"start,omitempty" json:"start,omitempty"` - End *int64 `form:"end,omitempty" json:"end,omitempty"` - Step *int `form:"step,omitempty" json:"step,omitempty"` -} - // UpdateCertificateJSONBody defines parameters for UpdateCertificate. type UpdateCertificateJSONBody struct { Labels *Labels `json:"labels,omitempty"` Name *string `json:"name,omitempty"` } -// ListEventsParams defines parameters for ListEvents. -type ListEventsParams struct { - FrontendId *int64 `form:"frontend_id,omitempty" json:"frontend_id,omitempty"` - BackendId *int64 `form:"backend_id,omitempty" json:"backend_id,omitempty"` - Kind *string `form:"kind,omitempty" json:"kind,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` -} - -// ListFrontendsParams defines parameters for ListFrontends. -type ListFrontendsParams struct { - LabelSelector *string `form:"label_selector,omitempty" json:"label_selector,omitempty"` +// ListDomainsParams defines parameters for ListDomains. +type ListDomainsParams struct { + ProjectId *int64 `form:"project_id,omitempty" json:"project_id,omitempty"` } -// GetFrontendMetricsParams defines parameters for GetFrontendMetrics. -type GetFrontendMetricsParams struct { - // Type comma separated series names - Type *string `form:"type,omitempty" json:"type,omitempty"` - Start *int64 `form:"start,omitempty" json:"start,omitempty"` - End *int64 `form:"end,omitempty" json:"end,omitempty"` - Step *int `form:"step,omitempty" json:"step,omitempty"` +// ListEventsParams defines parameters for ListEvents. +type ListEventsParams struct { + Kind *string `form:"kind,omitempty" json:"kind,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + ProjectId *int64 `form:"project_id,omitempty" json:"project_id,omitempty"` } // PreviewLabelSelectorParams defines parameters for PreviewLabelSelector. @@ -1639,8 +1618,25 @@ type DiffRevisionParams struct { Against *int64 `form:"against,omitempty" json:"against,omitempty"` } -// CreateAlertRuleJSONRequestBody defines body for CreateAlertRule for application/json ContentType. -type CreateAlertRuleJSONRequestBody = AlertRuleInput +// ListProjectsParams defines parameters for ListProjects. +type ListProjectsParams struct { + LabelSelector *string `form:"label_selector,omitempty" json:"label_selector,omitempty"` +} + +// GetProjectMetricsParams defines parameters for GetProjectMetrics. +type GetProjectMetricsParams struct { + // Type comma separated series names + Type *string `form:"type,omitempty" json:"type,omitempty"` + Start *int64 `form:"start,omitempty" json:"start,omitempty"` + End *int64 `form:"end,omitempty" json:"end,omitempty"` + Step *int `form:"step,omitempty" json:"step,omitempty"` + + // ServiceId only this service (default: whole project) + ServiceId *int64 `form:"service_id,omitempty" json:"service_id,omitempty"` +} + +// CreateAlertRuleJSONRequestBody defines body for CreateAlertRule for application/json ContentType. +type CreateAlertRuleJSONRequestBody = AlertRuleInput // CreateAPITokenJSONRequestBody defines body for CreateAPIToken for application/json ContentType. type CreateAPITokenJSONRequestBody = APITokenCreate @@ -1657,18 +1653,6 @@ type TotpDisableJSONRequestBody TotpDisableJSONBody // TotpEnableJSONRequestBody defines body for TotpEnable for application/json ContentType. type TotpEnableJSONRequestBody TotpEnableJSONBody -// CreateBackendJSONRequestBody defines body for CreateBackend for application/json ContentType. -type CreateBackendJSONRequestBody = BackendInput - -// UpdateBackendJSONRequestBody defines body for UpdateBackend for application/json ContentType. -type UpdateBackendJSONRequestBody = BackendInput - -// CreateBackendServersJSONRequestBody defines body for CreateBackendServers for application/json ContentType. -type CreateBackendServersJSONRequestBody = BackendServersCreate - -// UpdateBackendServerJSONRequestBody defines body for UpdateBackendServer for application/json ContentType. -type UpdateBackendServerJSONRequestBody = BackendServerUpdate - // CreateCertificateJSONRequestBody defines body for CreateCertificate for application/json ContentType. type CreateCertificateJSONRequestBody = CertificateCreate @@ -1690,15 +1674,6 @@ type CreateDomainJSONRequestBody = DomainInput // UpdateDomainJSONRequestBody defines body for UpdateDomain for application/json ContentType. type UpdateDomainJSONRequestBody = DomainInput -// CreateFrontendJSONRequestBody defines body for CreateFrontend for application/json ContentType. -type CreateFrontendJSONRequestBody = FrontendInput - -// PatchFrontendJSONRequestBody defines body for PatchFrontend for application/json ContentType. -type PatchFrontendJSONRequestBody = FrontendPatch - -// UpdateFrontendJSONRequestBody defines body for UpdateFrontend for application/json ContentType. -type UpdateFrontendJSONRequestBody = FrontendInput - // CreateHostJSONRequestBody defines body for CreateHost for application/json ContentType. type CreateHostJSONRequestBody = HostInput @@ -1714,6 +1689,24 @@ type AddNodeIPJSONRequestBody AddNodeIPJSONBody // CreateChannelJSONRequestBody defines body for CreateChannel for application/json ContentType. type CreateChannelJSONRequestBody = NotificationChannelInput +// CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType. +type CreateProjectJSONRequestBody = ProjectCreate + +// UpdateProjectJSONRequestBody defines body for UpdateProject for application/json ContentType. +type UpdateProjectJSONRequestBody = ProjectUpdate + +// CreateServiceJSONRequestBody defines body for CreateService for application/json ContentType. +type CreateServiceJSONRequestBody = ServiceInput + +// UpdateServiceJSONRequestBody defines body for UpdateService for application/json ContentType. +type UpdateServiceJSONRequestBody = ServiceInput + +// CreateTargetsJSONRequestBody defines body for CreateTargets for application/json ContentType. +type CreateTargetsJSONRequestBody = TargetsCreate + +// UpdateTargetJSONRequestBody defines body for UpdateTarget for application/json ContentType. +type UpdateTargetJSONRequestBody = TargetUpdate + // CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType. type CreateUserJSONRequestBody = UserCreate @@ -1765,39 +1758,6 @@ type ServerInterface interface { // (POST /auth/totp/setup) TotpSetup(w http.ResponseWriter, r *http.Request) - // (GET /backends) - ListBackends(w http.ResponseWriter, r *http.Request, params ListBackendsParams) - - // (POST /backends) - CreateBackend(w http.ResponseWriter, r *http.Request) - - // (DELETE /backends/{id}) - DeleteBackend(w http.ResponseWriter, r *http.Request, id Id) - - // (GET /backends/{id}) - GetBackend(w http.ResponseWriter, r *http.Request, id Id) - - // (PUT /backends/{id}) - UpdateBackend(w http.ResponseWriter, r *http.Request, id Id) - - // (GET /backends/{id}/metrics) - GetBackendMetrics(w http.ResponseWriter, r *http.Request, id Id, params GetBackendMetricsParams) - - // (GET /backends/{id}/servers) - ListBackendServers(w http.ResponseWriter, r *http.Request, id Id) - - // (POST /backends/{id}/servers) - CreateBackendServers(w http.ResponseWriter, r *http.Request, id Id) - - // (DELETE /backends/{id}/servers/{sid}) - DeleteBackendServer(w http.ResponseWriter, r *http.Request, id Id, sid int64) - - // (PATCH /backends/{id}/servers/{sid}) - UpdateBackendServer(w http.ResponseWriter, r *http.Request, id Id, sid int64) - - // (GET /backends/{id}/status) - GetBackendStatus(w http.ResponseWriter, r *http.Request, id Id) - // (GET /certificates) ListCertificates(w http.ResponseWriter, r *http.Request) @@ -1838,7 +1798,7 @@ type ServerInterface interface { CreateDNSRecord(w http.ResponseWriter, r *http.Request, id Id, zone string) // (GET /domains) - ListDomains(w http.ResponseWriter, r *http.Request) + ListDomains(w http.ResponseWriter, r *http.Request, params ListDomainsParams) // (POST /domains) CreateDomain(w http.ResponseWriter, r *http.Request) @@ -1864,36 +1824,6 @@ type ServerInterface interface { // (GET /events/history) ListEvents(w http.ResponseWriter, r *http.Request, params ListEventsParams) - // (GET /frontends) - ListFrontends(w http.ResponseWriter, r *http.Request, params ListFrontendsParams) - - // (POST /frontends) - CreateFrontend(w http.ResponseWriter, r *http.Request) - - // (DELETE /frontends/{id}) - DeleteFrontend(w http.ResponseWriter, r *http.Request, id Id) - - // (GET /frontends/{id}) - GetFrontend(w http.ResponseWriter, r *http.Request, id Id) - - // (PATCH /frontends/{id}) - PatchFrontend(w http.ResponseWriter, r *http.Request, id Id) - - // (PUT /frontends/{id}) - UpdateFrontend(w http.ResponseWriter, r *http.Request, id Id) - - // (GET /frontends/{id}/config) - GetFrontendConfig(w http.ResponseWriter, r *http.Request, id Id) - - // (GET /frontends/{id}/logs/stream) - StreamFrontendLogs(w http.ResponseWriter, r *http.Request, id Id) - - // (GET /frontends/{id}/metrics) - GetFrontendMetrics(w http.ResponseWriter, r *http.Request, id Id, params GetFrontendMetricsParams) - - // (GET /frontends/{id}/metrics/stream) - StreamFrontendMetrics(w http.ResponseWriter, r *http.Request, id Id) - // (GET /healthz) Healthz(w http.ResponseWriter, r *http.Request) @@ -1948,6 +1878,57 @@ type ServerInterface interface { // (GET /overview) GetOverview(w http.ResponseWriter, r *http.Request) + // (GET /projects) + ListProjects(w http.ResponseWriter, r *http.Request, params ListProjectsParams) + + // (POST /projects) + CreateProject(w http.ResponseWriter, r *http.Request) + + // (DELETE /projects/{id}) + DeleteProject(w http.ResponseWriter, r *http.Request, id Id) + + // (GET /projects/{id}) + GetProject(w http.ResponseWriter, r *http.Request, id Id) + + // (PATCH /projects/{id}) + UpdateProject(w http.ResponseWriter, r *http.Request, id Id) + + // (GET /projects/{id}/config) + GetProjectConfig(w http.ResponseWriter, r *http.Request, id Id) + + // (GET /projects/{id}/logs/stream) + StreamProjectLogs(w http.ResponseWriter, r *http.Request, id Id) + + // (GET /projects/{id}/metrics) + GetProjectMetrics(w http.ResponseWriter, r *http.Request, id Id, params GetProjectMetricsParams) + + // (GET /projects/{id}/metrics/stream) + StreamProjectMetrics(w http.ResponseWriter, r *http.Request, id Id) + + // (GET /projects/{id}/services) + ListServices(w http.ResponseWriter, r *http.Request, id Id) + + // (POST /projects/{id}/services) + CreateService(w http.ResponseWriter, r *http.Request, id Id) + + // (DELETE /projects/{id}/services/{sid}) + DeleteService(w http.ResponseWriter, r *http.Request, id Id, sid int64) + + // (PUT /projects/{id}/services/{sid}) + UpdateService(w http.ResponseWriter, r *http.Request, id Id, sid int64) + + // (GET /projects/{id}/targets) + ListTargets(w http.ResponseWriter, r *http.Request, id Id) + + // (POST /projects/{id}/targets) + CreateTargets(w http.ResponseWriter, r *http.Request, id Id) + + // (DELETE /projects/{id}/targets/{tid}) + DeleteTarget(w http.ResponseWriter, r *http.Request, id Id, tid int64) + + // (PATCH /projects/{id}/targets/{tid}) + UpdateTarget(w http.ResponseWriter, r *http.Request, id Id, tid int64) + // (GET /readyz) Readyz(w http.ResponseWriter, r *http.Request) @@ -2223,30 +2204,11 @@ func (siw *ServerInterfaceWrapper) TotpSetup(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// ListBackends operation middleware -func (siw *ServerInterfaceWrapper) ListBackends(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // Parameter object where we will unmarshal all parameters from the context - var params ListBackendsParams - - // ------------- Optional query parameter "label_selector" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "label_selector", r.URL.Query(), ¶ms.LabelSelector, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "label_selector"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "label_selector", Err: err}) - } - return - } +// ListCertificates operation middleware +func (siw *ServerInterfaceWrapper) ListCertificates(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListBackends(w, r, params) + siw.Handler.ListCertificates(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2256,11 +2218,11 @@ func (siw *ServerInterfaceWrapper) ListBackends(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// CreateBackend operation middleware -func (siw *ServerInterfaceWrapper) CreateBackend(w http.ResponseWriter, r *http.Request) { +// CreateCertificate operation middleware +func (siw *ServerInterfaceWrapper) CreateCertificate(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateBackend(w, r) + siw.Handler.CreateCertificate(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2270,8 +2232,8 @@ func (siw *ServerInterfaceWrapper) CreateBackend(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// DeleteBackend operation middleware -func (siw *ServerInterfaceWrapper) DeleteBackend(w http.ResponseWriter, r *http.Request) { +// DeleteCertificate operation middleware +func (siw *ServerInterfaceWrapper) DeleteCertificate(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2286,7 +2248,7 @@ func (siw *ServerInterfaceWrapper) DeleteBackend(w http.ResponseWriter, r *http. } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteBackend(w, r, id) + siw.Handler.DeleteCertificate(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2296,8 +2258,8 @@ func (siw *ServerInterfaceWrapper) DeleteBackend(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// GetBackend operation middleware -func (siw *ServerInterfaceWrapper) GetBackend(w http.ResponseWriter, r *http.Request) { +// GetCertificate operation middleware +func (siw *ServerInterfaceWrapper) GetCertificate(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2312,7 +2274,7 @@ func (siw *ServerInterfaceWrapper) GetBackend(w http.ResponseWriter, r *http.Req } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetBackend(w, r, id) + siw.Handler.GetCertificate(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2322,8 +2284,8 @@ func (siw *ServerInterfaceWrapper) GetBackend(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } -// UpdateBackend operation middleware -func (siw *ServerInterfaceWrapper) UpdateBackend(w http.ResponseWriter, r *http.Request) { +// UpdateCertificate operation middleware +func (siw *ServerInterfaceWrapper) UpdateCertificate(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2338,7 +2300,7 @@ func (siw *ServerInterfaceWrapper) UpdateBackend(w http.ResponseWriter, r *http. } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateBackend(w, r, id) + siw.Handler.UpdateCertificate(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2348,8 +2310,8 @@ func (siw *ServerInterfaceWrapper) UpdateBackend(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// GetBackendMetrics operation middleware -func (siw *ServerInterfaceWrapper) GetBackendMetrics(w http.ResponseWriter, r *http.Request) { +// RenewCertificate operation middleware +func (siw *ServerInterfaceWrapper) RenewCertificate(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2363,63 +2325,36 @@ func (siw *ServerInterfaceWrapper) GetBackendMetrics(w http.ResponseWriter, r *h return } - // Parameter object where we will unmarshal all parameters from the context - var params GetBackendMetricsParams - - // ------------- Optional query parameter "type" ------------- + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RenewCertificate(w, r, id) + })) - err = runtime.BindQueryParameterWithOptions("form", true, false, "type", r.URL.Query(), ¶ms.Type, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "type"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err}) - } - return + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) } - // ------------- Optional query parameter "start" ------------- + handler.ServeHTTP(w, r) +} - err = runtime.BindQueryParameterWithOptions("form", true, false, "start", r.URL.Query(), ¶ms.Start, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "start"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "start", Err: err}) - } - return - } +// ListDNSProviders operation middleware +func (siw *ServerInterfaceWrapper) ListDNSProviders(w http.ResponseWriter, r *http.Request) { - // ------------- Optional query parameter "end" ------------- + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDNSProviders(w, r) + })) - err = runtime.BindQueryParameterWithOptions("form", true, false, "end", r.URL.Query(), ¶ms.End, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "end"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "end", Err: err}) - } - return + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) } - // ------------- Optional query parameter "step" ------------- + handler.ServeHTTP(w, r) +} - err = runtime.BindQueryParameterWithOptions("form", true, false, "step", r.URL.Query(), ¶ms.Step, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "step"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "step", Err: err}) - } - return - } +// CreateDNSProvider operation middleware +func (siw *ServerInterfaceWrapper) CreateDNSProvider(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetBackendMetrics(w, r, id, params) + siw.Handler.CreateDNSProvider(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2429,8 +2364,8 @@ func (siw *ServerInterfaceWrapper) GetBackendMetrics(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// ListBackendServers operation middleware -func (siw *ServerInterfaceWrapper) ListBackendServers(w http.ResponseWriter, r *http.Request) { +// DeleteDNSProvider operation middleware +func (siw *ServerInterfaceWrapper) DeleteDNSProvider(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2445,7 +2380,7 @@ func (siw *ServerInterfaceWrapper) ListBackendServers(w http.ResponseWriter, r * } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListBackendServers(w, r, id) + siw.Handler.DeleteDNSProvider(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2455,8 +2390,8 @@ func (siw *ServerInterfaceWrapper) ListBackendServers(w http.ResponseWriter, r * handler.ServeHTTP(w, r) } -// CreateBackendServers operation middleware -func (siw *ServerInterfaceWrapper) CreateBackendServers(w http.ResponseWriter, r *http.Request) { +// ListDNSZones operation middleware +func (siw *ServerInterfaceWrapper) ListDNSZones(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2471,7 +2406,7 @@ func (siw *ServerInterfaceWrapper) CreateBackendServers(w http.ResponseWriter, r } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateBackendServers(w, r, id) + siw.Handler.ListDNSZones(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2481,8 +2416,8 @@ func (siw *ServerInterfaceWrapper) CreateBackendServers(w http.ResponseWriter, r handler.ServeHTTP(w, r) } -// DeleteBackendServer operation middleware -func (siw *ServerInterfaceWrapper) DeleteBackendServer(w http.ResponseWriter, r *http.Request) { +// DeleteDNSRecord operation middleware +func (siw *ServerInterfaceWrapper) DeleteDNSRecord(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2496,17 +2431,17 @@ func (siw *ServerInterfaceWrapper) DeleteBackendServer(w http.ResponseWriter, r return } - // ------------- Path parameter "sid" ------------- - var sid int64 + // ------------- Path parameter "zone" ------------- + var zone string - err = runtime.BindStyledParameterWithOptions("simple", "sid", r.PathValue("sid"), &sid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "zone", r.PathValue("zone"), &zone, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sid", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "zone", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteBackendServer(w, r, id, sid) + siw.Handler.DeleteDNSRecord(w, r, id, zone) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2516,8 +2451,8 @@ func (siw *ServerInterfaceWrapper) DeleteBackendServer(w http.ResponseWriter, r handler.ServeHTTP(w, r) } -// UpdateBackendServer operation middleware -func (siw *ServerInterfaceWrapper) UpdateBackendServer(w http.ResponseWriter, r *http.Request) { +// ListDNSRecords operation middleware +func (siw *ServerInterfaceWrapper) ListDNSRecords(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2531,17 +2466,17 @@ func (siw *ServerInterfaceWrapper) UpdateBackendServer(w http.ResponseWriter, r return } - // ------------- Path parameter "sid" ------------- - var sid int64 + // ------------- Path parameter "zone" ------------- + var zone string - err = runtime.BindStyledParameterWithOptions("simple", "sid", r.PathValue("sid"), &sid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "zone", r.PathValue("zone"), &zone, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sid", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "zone", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateBackendServer(w, r, id, sid) + siw.Handler.ListDNSRecords(w, r, id, zone) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2551,8 +2486,8 @@ func (siw *ServerInterfaceWrapper) UpdateBackendServer(w http.ResponseWriter, r handler.ServeHTTP(w, r) } -// GetBackendStatus operation middleware -func (siw *ServerInterfaceWrapper) GetBackendStatus(w http.ResponseWriter, r *http.Request) { +// CreateDNSRecord operation middleware +func (siw *ServerInterfaceWrapper) CreateDNSRecord(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2566,8 +2501,17 @@ func (siw *ServerInterfaceWrapper) GetBackendStatus(w http.ResponseWriter, r *ht return } + // ------------- Path parameter "zone" ------------- + var zone string + + err = runtime.BindStyledParameterWithOptions("simple", "zone", r.PathValue("zone"), &zone, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "zone", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetBackendStatus(w, r, id) + siw.Handler.CreateDNSRecord(w, r, id, zone) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2577,11 +2521,30 @@ func (siw *ServerInterfaceWrapper) GetBackendStatus(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } -// ListCertificates operation middleware -func (siw *ServerInterfaceWrapper) ListCertificates(w http.ResponseWriter, r *http.Request) { +// ListDomains operation middleware +func (siw *ServerInterfaceWrapper) ListDomains(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListDomainsParams + + // ------------- Optional query parameter "project_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "project_id", r.URL.Query(), ¶ms.ProjectId, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "project_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "project_id", Err: err}) + } + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListCertificates(w, r) + siw.Handler.ListDomains(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2591,11 +2554,11 @@ func (siw *ServerInterfaceWrapper) ListCertificates(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } -// CreateCertificate operation middleware -func (siw *ServerInterfaceWrapper) CreateCertificate(w http.ResponseWriter, r *http.Request) { +// CreateDomain operation middleware +func (siw *ServerInterfaceWrapper) CreateDomain(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateCertificate(w, r) + siw.Handler.CreateDomain(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2605,8 +2568,8 @@ func (siw *ServerInterfaceWrapper) CreateCertificate(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// DeleteCertificate operation middleware -func (siw *ServerInterfaceWrapper) DeleteCertificate(w http.ResponseWriter, r *http.Request) { +// DeleteDomain operation middleware +func (siw *ServerInterfaceWrapper) DeleteDomain(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2621,7 +2584,7 @@ func (siw *ServerInterfaceWrapper) DeleteCertificate(w http.ResponseWriter, r *h } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteCertificate(w, r, id) + siw.Handler.DeleteDomain(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2631,8 +2594,8 @@ func (siw *ServerInterfaceWrapper) DeleteCertificate(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// GetCertificate operation middleware -func (siw *ServerInterfaceWrapper) GetCertificate(w http.ResponseWriter, r *http.Request) { +// GetDomain operation middleware +func (siw *ServerInterfaceWrapper) GetDomain(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2647,7 +2610,7 @@ func (siw *ServerInterfaceWrapper) GetCertificate(w http.ResponseWriter, r *http } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetCertificate(w, r, id) + siw.Handler.GetDomain(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2657,8 +2620,8 @@ func (siw *ServerInterfaceWrapper) GetCertificate(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// UpdateCertificate operation middleware -func (siw *ServerInterfaceWrapper) UpdateCertificate(w http.ResponseWriter, r *http.Request) { +// UpdateDomain operation middleware +func (siw *ServerInterfaceWrapper) UpdateDomain(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2673,7 +2636,7 @@ func (siw *ServerInterfaceWrapper) UpdateCertificate(w http.ResponseWriter, r *h } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateCertificate(w, r, id) + siw.Handler.UpdateDomain(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2683,8 +2646,8 @@ func (siw *ServerInterfaceWrapper) UpdateCertificate(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// RenewCertificate operation middleware -func (siw *ServerInterfaceWrapper) RenewCertificate(w http.ResponseWriter, r *http.Request) { +// IssueDomainCertificate operation middleware +func (siw *ServerInterfaceWrapper) IssueDomainCertificate(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2699,7 +2662,7 @@ func (siw *ServerInterfaceWrapper) RenewCertificate(w http.ResponseWriter, r *ht } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.RenewCertificate(w, r, id) + siw.Handler.IssueDomainCertificate(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2709,12 +2672,24 @@ func (siw *ServerInterfaceWrapper) RenewCertificate(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } -// ListDNSProviders operation middleware -func (siw *ServerInterfaceWrapper) ListDNSProviders(w http.ResponseWriter, r *http.Request) { +// VerifyDomain operation middleware +func (siw *ServerInterfaceWrapper) VerifyDomain(w http.ResponseWriter, r *http.Request) { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListDNSProviders(w, r) - })) + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id Id + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.VerifyDomain(w, r, id) + })) for _, middleware := range siw.HandlerMiddlewares { handler = middleware(handler) @@ -2723,11 +2698,11 @@ func (siw *ServerInterfaceWrapper) ListDNSProviders(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } -// CreateDNSProvider operation middleware -func (siw *ServerInterfaceWrapper) CreateDNSProvider(w http.ResponseWriter, r *http.Request) { +// StreamEvents operation middleware +func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateDNSProvider(w, r) + siw.Handler.StreamEvents(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2737,23 +2712,56 @@ func (siw *ServerInterfaceWrapper) CreateDNSProvider(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// DeleteDNSProvider operation middleware -func (siw *ServerInterfaceWrapper) DeleteDNSProvider(w http.ResponseWriter, r *http.Request) { +// ListEvents operation middleware +func (siw *ServerInterfaceWrapper) ListEvents(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "id" ------------- - var id Id + // Parameter object where we will unmarshal all parameters from the context + var params ListEventsParams - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + // ------------- Optional query parameter "kind" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "kind", r.URL.Query(), ¶ms.Kind, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "kind"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "kind", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "project_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "project_id", r.URL.Query(), ¶ms.ProjectId, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "project_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "project_id", Err: err}) + } return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteDNSProvider(w, r, id) + siw.Handler.ListEvents(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2763,23 +2771,11 @@ func (siw *ServerInterfaceWrapper) DeleteDNSProvider(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// ListDNSZones operation middleware -func (siw *ServerInterfaceWrapper) ListDNSZones(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id Id - - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) - return - } +// Healthz operation middleware +func (siw *ServerInterfaceWrapper) Healthz(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListDNSZones(w, r, id) + siw.Handler.Healthz(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2789,32 +2785,25 @@ func (siw *ServerInterfaceWrapper) ListDNSZones(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// DeleteDNSRecord operation middleware -func (siw *ServerInterfaceWrapper) DeleteDNSRecord(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err +// ListHosts operation middleware +func (siw *ServerInterfaceWrapper) ListHosts(w http.ResponseWriter, r *http.Request) { - // ------------- Path parameter "id" ------------- - var id Id + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListHosts(w, r) + })) - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) - return + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) } - // ------------- Path parameter "zone" ------------- - var zone string + handler.ServeHTTP(w, r) +} - err = runtime.BindStyledParameterWithOptions("simple", "zone", r.PathValue("zone"), &zone, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "zone", Err: err}) - return - } +// CreateHost operation middleware +func (siw *ServerInterfaceWrapper) CreateHost(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteDNSRecord(w, r, id, zone) + siw.Handler.CreateHost(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2824,8 +2813,8 @@ func (siw *ServerInterfaceWrapper) DeleteDNSRecord(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } -// ListDNSRecords operation middleware -func (siw *ServerInterfaceWrapper) ListDNSRecords(w http.ResponseWriter, r *http.Request) { +// DeleteHost operation middleware +func (siw *ServerInterfaceWrapper) DeleteHost(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2839,17 +2828,8 @@ func (siw *ServerInterfaceWrapper) ListDNSRecords(w http.ResponseWriter, r *http return } - // ------------- Path parameter "zone" ------------- - var zone string - - err = runtime.BindStyledParameterWithOptions("simple", "zone", r.PathValue("zone"), &zone, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "zone", Err: err}) - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListDNSRecords(w, r, id, zone) + siw.Handler.DeleteHost(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2859,8 +2839,8 @@ func (siw *ServerInterfaceWrapper) ListDNSRecords(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// CreateDNSRecord operation middleware -func (siw *ServerInterfaceWrapper) CreateDNSRecord(w http.ResponseWriter, r *http.Request) { +// UpdateHost operation middleware +func (siw *ServerInterfaceWrapper) UpdateHost(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2874,17 +2854,8 @@ func (siw *ServerInterfaceWrapper) CreateDNSRecord(w http.ResponseWriter, r *htt return } - // ------------- Path parameter "zone" ------------- - var zone string - - err = runtime.BindStyledParameterWithOptions("simple", "zone", r.PathValue("zone"), &zone, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "zone", Err: err}) - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateDNSRecord(w, r, id, zone) + siw.Handler.UpdateHost(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2894,11 +2865,30 @@ func (siw *ServerInterfaceWrapper) CreateDNSRecord(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } -// ListDomains operation middleware -func (siw *ServerInterfaceWrapper) ListDomains(w http.ResponseWriter, r *http.Request) { +// PreviewLabelSelector operation middleware +func (siw *ServerInterfaceWrapper) PreviewLabelSelector(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params PreviewLabelSelectorParams + + // ------------- Required query parameter "selector" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "selector", r.URL.Query(), ¶ms.Selector, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "selector"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "selector", Err: err}) + } + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListDomains(w, r) + siw.Handler.PreviewLabelSelector(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2908,11 +2898,11 @@ func (siw *ServerInterfaceWrapper) ListDomains(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } -// CreateDomain operation middleware -func (siw *ServerInterfaceWrapper) CreateDomain(w http.ResponseWriter, r *http.Request) { +// ListNodes operation middleware +func (siw *ServerInterfaceWrapper) ListNodes(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateDomain(w, r) + siw.Handler.ListNodes(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2922,8 +2912,8 @@ func (siw *ServerInterfaceWrapper) CreateDomain(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// DeleteDomain operation middleware -func (siw *ServerInterfaceWrapper) DeleteDomain(w http.ResponseWriter, r *http.Request) { +// UpdateNode operation middleware +func (siw *ServerInterfaceWrapper) UpdateNode(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2938,7 +2928,7 @@ func (siw *ServerInterfaceWrapper) DeleteDomain(w http.ResponseWriter, r *http.R } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteDomain(w, r, id) + siw.Handler.UpdateNode(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2948,8 +2938,8 @@ func (siw *ServerInterfaceWrapper) DeleteDomain(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// GetDomain operation middleware -func (siw *ServerInterfaceWrapper) GetDomain(w http.ResponseWriter, r *http.Request) { +// AddNodeIP operation middleware +func (siw *ServerInterfaceWrapper) AddNodeIP(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2964,7 +2954,7 @@ func (siw *ServerInterfaceWrapper) GetDomain(w http.ResponseWriter, r *http.Requ } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetDomain(w, r, id) + siw.Handler.AddNodeIP(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -2974,8 +2964,8 @@ func (siw *ServerInterfaceWrapper) GetDomain(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// UpdateDomain operation middleware -func (siw *ServerInterfaceWrapper) UpdateDomain(w http.ResponseWriter, r *http.Request) { +// DeleteNodeIP operation middleware +func (siw *ServerInterfaceWrapper) DeleteNodeIP(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -2989,8 +2979,17 @@ func (siw *ServerInterfaceWrapper) UpdateDomain(w http.ResponseWriter, r *http.R return } + // ------------- Path parameter "ipid" ------------- + var ipid int64 + + err = runtime.BindStyledParameterWithOptions("simple", "ipid", r.PathValue("ipid"), &ipid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ipid", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateDomain(w, r, id) + siw.Handler.DeleteNodeIP(w, r, id, ipid) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3000,8 +2999,8 @@ func (siw *ServerInterfaceWrapper) UpdateDomain(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// IssueDomainCertificate operation middleware -func (siw *ServerInterfaceWrapper) IssueDomainCertificate(w http.ResponseWriter, r *http.Request) { +// ListRevisions operation middleware +func (siw *ServerInterfaceWrapper) ListRevisions(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3016,7 +3015,7 @@ func (siw *ServerInterfaceWrapper) IssueDomainCertificate(w http.ResponseWriter, } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.IssueDomainCertificate(w, r, id) + siw.Handler.ListRevisions(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3026,8 +3025,8 @@ func (siw *ServerInterfaceWrapper) IssueDomainCertificate(w http.ResponseWriter, handler.ServeHTTP(w, r) } -// VerifyDomain operation middleware -func (siw *ServerInterfaceWrapper) VerifyDomain(w http.ResponseWriter, r *http.Request) { +// RollbackRevision operation middleware +func (siw *ServerInterfaceWrapper) RollbackRevision(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3041,22 +3040,17 @@ func (siw *ServerInterfaceWrapper) VerifyDomain(w http.ResponseWriter, r *http.R return } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.VerifyDomain(w, r, id) - })) + // ------------- Path parameter "rev" ------------- + var rev int64 - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) + err = runtime.BindStyledParameterWithOptions("simple", "rev", r.PathValue("rev"), &rev, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rev", Err: err}) + return } - handler.ServeHTTP(w, r) -} - -// StreamEvents operation middleware -func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.StreamEvents(w, r) + siw.Handler.RollbackRevision(w, r, id, rev) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3066,69 +3060,102 @@ func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// ListEvents operation middleware -func (siw *ServerInterfaceWrapper) ListEvents(w http.ResponseWriter, r *http.Request) { +// DiffRevision operation middleware +func (siw *ServerInterfaceWrapper) DiffRevision(w http.ResponseWriter, r *http.Request) { var err error _ = err - // Parameter object where we will unmarshal all parameters from the context - var params ListEventsParams - - // ------------- Optional query parameter "frontend_id" ------------- + // ------------- Path parameter "id" ------------- + var id Id - err = runtime.BindQueryParameterWithOptions("form", true, false, "frontend_id", r.URL.Query(), ¶ms.FrontendId, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "frontend_id"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "frontend_id", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } - // ------------- Optional query parameter "backend_id" ------------- + // ------------- Path parameter "rev" ------------- + var rev int64 - err = runtime.BindQueryParameterWithOptions("form", true, false, "backend_id", r.URL.Query(), ¶ms.BackendId, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + err = runtime.BindStyledParameterWithOptions("simple", "rev", r.PathValue("rev"), &rev, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "backend_id"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "backend_id", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rev", Err: err}) return } - // ------------- Optional query parameter "kind" ------------- + // Parameter object where we will unmarshal all parameters from the context + var params DiffRevisionParams - err = runtime.BindQueryParameterWithOptions("form", true, false, "kind", r.URL.Query(), ¶ms.Kind, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + // ------------- Optional query parameter "against" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "against", r.URL.Query(), ¶ms.Against, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) if err != nil { var requiredError *runtime.RequiredParameterError if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "kind"}) + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "against"}) } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "kind", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "against", Err: err}) } return } - // ------------- Optional query parameter "limit" ------------- + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DiffRevision(w, r, id, rev, params) + })) - err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListChannels operation middleware +func (siw *ServerInterfaceWrapper) ListChannels(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListChannels(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateChannel operation middleware +func (siw *ServerInterfaceWrapper) CreateChannel(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateChannel(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteChannel operation middleware +func (siw *ServerInterfaceWrapper) DeleteChannel(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id Id + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListEvents(w, r, params) + siw.Handler.DeleteChannel(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3138,30 +3165,23 @@ func (siw *ServerInterfaceWrapper) ListEvents(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } -// ListFrontends operation middleware -func (siw *ServerInterfaceWrapper) ListFrontends(w http.ResponseWriter, r *http.Request) { +// TestChannel operation middleware +func (siw *ServerInterfaceWrapper) TestChannel(w http.ResponseWriter, r *http.Request) { var err error _ = err - // Parameter object where we will unmarshal all parameters from the context - var params ListFrontendsParams - - // ------------- Optional query parameter "label_selector" ------------- + // ------------- Path parameter "id" ------------- + var id Id - err = runtime.BindQueryParameterWithOptions("form", true, false, "label_selector", r.URL.Query(), ¶ms.LabelSelector, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "label_selector"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "label_selector", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListFrontends(w, r, params) + siw.Handler.TestChannel(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3171,11 +3191,11 @@ func (siw *ServerInterfaceWrapper) ListFrontends(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// CreateFrontend operation middleware -func (siw *ServerInterfaceWrapper) CreateFrontend(w http.ResponseWriter, r *http.Request) { +// GetOverview operation middleware +func (siw *ServerInterfaceWrapper) GetOverview(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateFrontend(w, r) + siw.Handler.GetOverview(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3185,23 +3205,30 @@ func (siw *ServerInterfaceWrapper) CreateFrontend(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// DeleteFrontend operation middleware -func (siw *ServerInterfaceWrapper) DeleteFrontend(w http.ResponseWriter, r *http.Request) { +// ListProjects operation middleware +func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "id" ------------- - var id Id + // Parameter object where we will unmarshal all parameters from the context + var params ListProjectsParams - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + // ------------- Optional query parameter "label_selector" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "label_selector", r.URL.Query(), ¶ms.LabelSelector, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "label_selector"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "label_selector", Err: err}) + } return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteFrontend(w, r, id) + siw.Handler.ListProjects(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3211,8 +3238,22 @@ func (siw *ServerInterfaceWrapper) DeleteFrontend(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// GetFrontend operation middleware -func (siw *ServerInterfaceWrapper) GetFrontend(w http.ResponseWriter, r *http.Request) { +// CreateProject operation middleware +func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateProject(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteProject operation middleware +func (siw *ServerInterfaceWrapper) DeleteProject(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3227,7 +3268,7 @@ func (siw *ServerInterfaceWrapper) GetFrontend(w http.ResponseWriter, r *http.Re } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetFrontend(w, r, id) + siw.Handler.DeleteProject(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3237,8 +3278,8 @@ func (siw *ServerInterfaceWrapper) GetFrontend(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } -// PatchFrontend operation middleware -func (siw *ServerInterfaceWrapper) PatchFrontend(w http.ResponseWriter, r *http.Request) { +// GetProject operation middleware +func (siw *ServerInterfaceWrapper) GetProject(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3253,7 +3294,7 @@ func (siw *ServerInterfaceWrapper) PatchFrontend(w http.ResponseWriter, r *http. } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PatchFrontend(w, r, id) + siw.Handler.GetProject(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3263,8 +3304,8 @@ func (siw *ServerInterfaceWrapper) PatchFrontend(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// UpdateFrontend operation middleware -func (siw *ServerInterfaceWrapper) UpdateFrontend(w http.ResponseWriter, r *http.Request) { +// UpdateProject operation middleware +func (siw *ServerInterfaceWrapper) UpdateProject(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3279,7 +3320,7 @@ func (siw *ServerInterfaceWrapper) UpdateFrontend(w http.ResponseWriter, r *http } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateFrontend(w, r, id) + siw.Handler.UpdateProject(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3289,8 +3330,8 @@ func (siw *ServerInterfaceWrapper) UpdateFrontend(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// GetFrontendConfig operation middleware -func (siw *ServerInterfaceWrapper) GetFrontendConfig(w http.ResponseWriter, r *http.Request) { +// GetProjectConfig operation middleware +func (siw *ServerInterfaceWrapper) GetProjectConfig(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3305,7 +3346,7 @@ func (siw *ServerInterfaceWrapper) GetFrontendConfig(w http.ResponseWriter, r *h } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetFrontendConfig(w, r, id) + siw.Handler.GetProjectConfig(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3315,8 +3356,8 @@ func (siw *ServerInterfaceWrapper) GetFrontendConfig(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// StreamFrontendLogs operation middleware -func (siw *ServerInterfaceWrapper) StreamFrontendLogs(w http.ResponseWriter, r *http.Request) { +// StreamProjectLogs operation middleware +func (siw *ServerInterfaceWrapper) StreamProjectLogs(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3331,7 +3372,7 @@ func (siw *ServerInterfaceWrapper) StreamFrontendLogs(w http.ResponseWriter, r * } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.StreamFrontendLogs(w, r, id) + siw.Handler.StreamProjectLogs(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3341,8 +3382,8 @@ func (siw *ServerInterfaceWrapper) StreamFrontendLogs(w http.ResponseWriter, r * handler.ServeHTTP(w, r) } -// GetFrontendMetrics operation middleware -func (siw *ServerInterfaceWrapper) GetFrontendMetrics(w http.ResponseWriter, r *http.Request) { +// GetProjectMetrics operation middleware +func (siw *ServerInterfaceWrapper) GetProjectMetrics(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3357,7 +3398,7 @@ func (siw *ServerInterfaceWrapper) GetFrontendMetrics(w http.ResponseWriter, r * } // Parameter object where we will unmarshal all parameters from the context - var params GetFrontendMetricsParams + var params GetProjectMetricsParams // ------------- Optional query parameter "type" ------------- @@ -3411,76 +3452,21 @@ func (siw *ServerInterfaceWrapper) GetFrontendMetrics(w http.ResponseWriter, r * return } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetFrontendMetrics(w, r, id, params) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// StreamFrontendMetrics operation middleware -func (siw *ServerInterfaceWrapper) StreamFrontendMetrics(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id Id + // ------------- Optional query parameter "service_id" ------------- - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + err = runtime.BindQueryParameterWithOptions("form", true, false, "service_id", r.URL.Query(), ¶ms.ServiceId, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "service_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "service_id", Err: err}) + } return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.StreamFrontendMetrics(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// Healthz operation middleware -func (siw *ServerInterfaceWrapper) Healthz(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.Healthz(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ListHosts operation middleware -func (siw *ServerInterfaceWrapper) ListHosts(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListHosts(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// CreateHost operation middleware -func (siw *ServerInterfaceWrapper) CreateHost(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateHost(w, r) + siw.Handler.GetProjectMetrics(w, r, id, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3490,8 +3476,8 @@ func (siw *ServerInterfaceWrapper) CreateHost(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } -// DeleteHost operation middleware -func (siw *ServerInterfaceWrapper) DeleteHost(w http.ResponseWriter, r *http.Request) { +// StreamProjectMetrics operation middleware +func (siw *ServerInterfaceWrapper) StreamProjectMetrics(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3506,7 +3492,7 @@ func (siw *ServerInterfaceWrapper) DeleteHost(w http.ResponseWriter, r *http.Req } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteHost(w, r, id) + siw.Handler.StreamProjectMetrics(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3516,8 +3502,8 @@ func (siw *ServerInterfaceWrapper) DeleteHost(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } -// UpdateHost operation middleware -func (siw *ServerInterfaceWrapper) UpdateHost(w http.ResponseWriter, r *http.Request) { +// ListServices operation middleware +func (siw *ServerInterfaceWrapper) ListServices(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3532,7 +3518,7 @@ func (siw *ServerInterfaceWrapper) UpdateHost(w http.ResponseWriter, r *http.Req } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateHost(w, r, id) + siw.Handler.ListServices(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3542,44 +3528,23 @@ func (siw *ServerInterfaceWrapper) UpdateHost(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } -// PreviewLabelSelector operation middleware -func (siw *ServerInterfaceWrapper) PreviewLabelSelector(w http.ResponseWriter, r *http.Request) { +// CreateService operation middleware +func (siw *ServerInterfaceWrapper) CreateService(w http.ResponseWriter, r *http.Request) { var err error _ = err - // Parameter object where we will unmarshal all parameters from the context - var params PreviewLabelSelectorParams - - // ------------- Required query parameter "selector" ------------- + // ------------- Path parameter "id" ------------- + var id Id - err = runtime.BindQueryParameterWithOptions("form", true, true, "selector", r.URL.Query(), ¶ms.Selector, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "selector"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "selector", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PreviewLabelSelector(w, r, params) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ListNodes operation middleware -func (siw *ServerInterfaceWrapper) ListNodes(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListNodes(w, r) + siw.Handler.CreateService(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3589,8 +3554,8 @@ func (siw *ServerInterfaceWrapper) ListNodes(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// UpdateNode operation middleware -func (siw *ServerInterfaceWrapper) UpdateNode(w http.ResponseWriter, r *http.Request) { +// DeleteService operation middleware +func (siw *ServerInterfaceWrapper) DeleteService(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3604,34 +3569,17 @@ func (siw *ServerInterfaceWrapper) UpdateNode(w http.ResponseWriter, r *http.Req return } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateNode(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// AddNodeIP operation middleware -func (siw *ServerInterfaceWrapper) AddNodeIP(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id Id + // ------------- Path parameter "sid" ------------- + var sid int64 - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "sid", r.PathValue("sid"), &sid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sid", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.AddNodeIP(w, r, id) + siw.Handler.DeleteService(w, r, id, sid) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3641,8 +3589,8 @@ func (siw *ServerInterfaceWrapper) AddNodeIP(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// DeleteNodeIP operation middleware -func (siw *ServerInterfaceWrapper) DeleteNodeIP(w http.ResponseWriter, r *http.Request) { +// UpdateService operation middleware +func (siw *ServerInterfaceWrapper) UpdateService(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3656,17 +3604,17 @@ func (siw *ServerInterfaceWrapper) DeleteNodeIP(w http.ResponseWriter, r *http.R return } - // ------------- Path parameter "ipid" ------------- - var ipid int64 + // ------------- Path parameter "sid" ------------- + var sid int64 - err = runtime.BindStyledParameterWithOptions("simple", "ipid", r.PathValue("ipid"), &ipid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "sid", r.PathValue("sid"), &sid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ipid", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sid", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteNodeIP(w, r, id, ipid) + siw.Handler.UpdateService(w, r, id, sid) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3676,8 +3624,8 @@ func (siw *ServerInterfaceWrapper) DeleteNodeIP(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// ListRevisions operation middleware -func (siw *ServerInterfaceWrapper) ListRevisions(w http.ResponseWriter, r *http.Request) { +// ListTargets operation middleware +func (siw *ServerInterfaceWrapper) ListTargets(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3692,7 +3640,7 @@ func (siw *ServerInterfaceWrapper) ListRevisions(w http.ResponseWriter, r *http. } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListRevisions(w, r, id) + siw.Handler.ListTargets(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3702,8 +3650,8 @@ func (siw *ServerInterfaceWrapper) ListRevisions(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// RollbackRevision operation middleware -func (siw *ServerInterfaceWrapper) RollbackRevision(w http.ResponseWriter, r *http.Request) { +// CreateTargets operation middleware +func (siw *ServerInterfaceWrapper) CreateTargets(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3717,17 +3665,8 @@ func (siw *ServerInterfaceWrapper) RollbackRevision(w http.ResponseWriter, r *ht return } - // ------------- Path parameter "rev" ------------- - var rev int64 - - err = runtime.BindStyledParameterWithOptions("simple", "rev", r.PathValue("rev"), &rev, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rev", Err: err}) - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.RollbackRevision(w, r, id, rev) + siw.Handler.CreateTargets(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3737,8 +3676,8 @@ func (siw *ServerInterfaceWrapper) RollbackRevision(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } -// DiffRevision operation middleware -func (siw *ServerInterfaceWrapper) DiffRevision(w http.ResponseWriter, r *http.Request) { +// DeleteTarget operation middleware +func (siw *ServerInterfaceWrapper) DeleteTarget(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3752,61 +3691,17 @@ func (siw *ServerInterfaceWrapper) DiffRevision(w http.ResponseWriter, r *http.R return } - // ------------- Path parameter "rev" ------------- - var rev int64 - - err = runtime.BindStyledParameterWithOptions("simple", "rev", r.PathValue("rev"), &rev, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rev", Err: err}) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params DiffRevisionParams - - // ------------- Optional query parameter "against" ------------- + // ------------- Path parameter "tid" ------------- + var tid int64 - err = runtime.BindQueryParameterWithOptions("form", true, false, "against", r.URL.Query(), ¶ms.Against, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + err = runtime.BindStyledParameterWithOptions("simple", "tid", r.PathValue("tid"), &tid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "against"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "against", Err: err}) - } + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "tid", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DiffRevision(w, r, id, rev, params) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ListChannels operation middleware -func (siw *ServerInterfaceWrapper) ListChannels(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListChannels(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// CreateChannel operation middleware -func (siw *ServerInterfaceWrapper) CreateChannel(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateChannel(w, r) + siw.Handler.DeleteTarget(w, r, id, tid) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3816,8 +3711,8 @@ func (siw *ServerInterfaceWrapper) CreateChannel(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// DeleteChannel operation middleware -func (siw *ServerInterfaceWrapper) DeleteChannel(w http.ResponseWriter, r *http.Request) { +// UpdateTarget operation middleware +func (siw *ServerInterfaceWrapper) UpdateTarget(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -3831,48 +3726,17 @@ func (siw *ServerInterfaceWrapper) DeleteChannel(w http.ResponseWriter, r *http. return } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteChannel(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// TestChannel operation middleware -func (siw *ServerInterfaceWrapper) TestChannel(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id Id + // ------------- Path parameter "tid" ------------- + var tid int64 - err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) + err = runtime.BindStyledParameterWithOptions("simple", "tid", r.PathValue("tid"), &tid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "tid", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.TestChannel(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetOverview operation middleware -func (siw *ServerInterfaceWrapper) GetOverview(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetOverview(w, r) + siw.Handler.UpdateTarget(w, r, id, tid) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4122,34 +3986,30 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/alert_rules", wrapper.ListAlertRules) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/alert_rules", wrapper.CreateAlertRule) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/alert_rules/{id}", wrapper.DeleteAlertRule) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/frontends", wrapper.ListFrontends) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/frontends", wrapper.CreateFrontend) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/frontends/{id}", wrapper.DeleteFrontend) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/frontends/{id}", wrapper.GetFrontend) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/frontends/{id}", wrapper.PatchFrontend) - m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/frontends/{id}", wrapper.UpdateFrontend) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/frontends/{id}/config", wrapper.GetFrontendConfig) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/frontends/{id}/metrics", wrapper.GetFrontendMetrics) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/frontends/{id}/metrics/stream", wrapper.StreamFrontendMetrics) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/frontends/{id}/logs/stream", wrapper.StreamFrontendLogs) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/apply/retry", wrapper.RetryApply) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/backends", wrapper.ListBackends) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/backends", wrapper.CreateBackend) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/backends/{id}", wrapper.DeleteBackend) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/backends/{id}", wrapper.GetBackend) - m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/backends/{id}", wrapper.UpdateBackend) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/backends/{id}/status", wrapper.GetBackendStatus) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/backends/{id}/metrics", wrapper.GetBackendMetrics) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/backends/{id}/servers", wrapper.ListBackendServers) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/backends/{id}/servers", wrapper.CreateBackendServers) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/backends/{id}/servers/{sid}", wrapper.DeleteBackendServer) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/backends/{id}/servers/{sid}", wrapper.UpdateBackendServer) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/hosts", wrapper.ListHosts) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/hosts", wrapper.CreateHost) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/hosts/{id}", wrapper.DeleteHost) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/hosts/{id}", wrapper.UpdateHost) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/label_selector/preview", wrapper.PreviewLabelSelector) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/overview", wrapper.GetOverview) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects", wrapper.ListProjects) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/projects", wrapper.CreateProject) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/projects/{id}", wrapper.DeleteProject) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}", wrapper.GetProject) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/projects/{id}", wrapper.UpdateProject) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}/config", wrapper.GetProjectConfig) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}/metrics", wrapper.GetProjectMetrics) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}/metrics/stream", wrapper.StreamProjectMetrics) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}/logs/stream", wrapper.StreamProjectLogs) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}/services", wrapper.ListServices) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/projects/{id}/services", wrapper.CreateService) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/projects/{id}/services/{sid}", wrapper.DeleteService) + m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/projects/{id}/services/{sid}", wrapper.UpdateService) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{id}/targets", wrapper.ListTargets) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/projects/{id}/targets", wrapper.CreateTargets) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/projects/{id}/targets/{tid}", wrapper.DeleteTarget) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/projects/{id}/targets/{tid}", wrapper.UpdateTarget) return m } diff --git a/internal/api/live.go b/internal/api/live.go new file mode 100644 index 0000000..10ced63 --- /dev/null +++ b/internal/api/live.go @@ -0,0 +1,71 @@ +package api + +import ( + "context" + "fmt" + "hash/fnv" + "time" + + "github.com/2shiraz/loxy/internal/events" + "github.com/2shiraz/loxy/internal/metrics" +) + +// LiveInterval is how often the `live.tick` snapshot is pushed on /events. +const LiveInterval = 2 * time.Second + +// RunLiveTicker pushes a compact live snapshot of every project (status, health, traffic) and +// the node totals as the ephemeral SSE event `live.tick`, so every page updates in real time +// without polling. `health_sig` changes whenever any target check of the project changes, +// which tells detail pages to refetch targets. +func (s *Server) RunLiveTicker(ctx context.Context) { + t := time.NewTicker(LiveInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + s.Hub.Publish(events.Event{Kind: "live.tick", Ephemeral: true, Data: s.liveSnapshot()}) + } +} + +func (s *Server) liveSnapshot() map[string]any { + snap := s.Snap.Get() + projects := make([]map[string]any, 0, len(snap.Projects)) + for _, p := range snap.Projects { + sum, rs := s.projectHealth(p) + h := fnv.New64a() + for _, r := range rs { + fmt.Fprintf(h, "%d:%s;", r.SlotID, r.Status) + for _, c := range r.Checks { + fmt.Fprintf(h, "%d=%s,", c.ServiceID, c.Status) + } + } + var pt metrics.Point + if s.Metrics != nil { + pt = s.Metrics.Latest(metrics.PR, p.ID) + } + projects = append(projects, map[string]any{ + "id": p.ID, "status": p.Status, "maintenance": p.Maintenance != 0, "version": p.Version, + "health": map[string]any{"status": sum.Status, "healthy": sum.Healthy, "total": sum.Total}, + "health_sig": fmt.Sprintf("%x", h.Sum64()), + "stats": map[string]any{"open_connections": pt.Open, "requests_per_second": pt.RPS, + "bytes_in_per_second": pt.In, "bytes_out_per_second": pt.Out, "response_time_ms": pt.RespMS}, + }) + } + var t metrics.Point + if s.Metrics != nil { + t = s.Metrics.Latest(metrics.FE, 0) + } + out := map[string]any{"ts": time.Now().UnixMilli(), "projects": projects, + "traffic": map[string]any{"open_connections": t.Open, "requests_per_second": t.RPS, "bytes_in_per_second": t.In, "bytes_out_per_second": t.Out}} + if len(snap.Nodes) > 0 { + n := snap.Nodes[0] + out["node"] = map[string]any{"status": n.Status, "applied_version": n.AppliedVersion, "desired_version": n.DesiredVersion} + } + return out +} + +// LiveSnapshotForTest exposes the live.tick payload to tests. +func (s *Server) LiveSnapshotForTest() map[string]any { return s.liveSnapshot() } diff --git a/internal/api/misc.go b/internal/api/misc.go index 156d123..8860541 100644 --- a/internal/api/misc.go +++ b/internal/api/misc.go @@ -450,16 +450,10 @@ func (s *Server) RenewCertificate(w http.ResponseWriter, r *http.Request, id gen func (s *Server) viewHost(h *state.Host) map[string]any { used := []int64{} - for _, b := range s.Snap.Get().Backends { - for _, sv := range b.Servers { - hit := false - for _, sl := range sv.Slots { - if sl.HostID == h.ID { - hit = true - } - } - if hit { - used = append(used, b.ID) + for _, p := range s.Snap.Get().Projects { + for _, sl := range p.Slots() { + if sl.HostID == h.ID { + used = append(used, p.ID) break } } @@ -793,11 +787,8 @@ func (s *Server) ListEvents(w http.ResponseWriter, r *http.Request, p gen.ListEv lim = int64(*p.Limit) } arg := db.ListEventsParams{MaxRows: lim} - if p.FrontendId != nil { - arg.FrontendID = *p.FrontendId - } - if p.BackendId != nil { - arg.BackendID = *p.BackendId + if p.ProjectId != nil { + arg.BalancerID = *p.ProjectId } if p.Kind != nil && *p.Kind != "" { arg.Kind = *p.Kind @@ -811,7 +802,7 @@ func (s *Server) ListEvents(w http.ResponseWriter, r *http.Request, p gen.ListEv for _, e := range evs { var data map[string]any _ = json.Unmarshal([]byte(e.Data), &data) - out = append(out, map[string]any{"id": e.ID, "ts": e.Ts, "frontend_id": nullI64(e.FrontendID), "backend_id": nullI64(e.BackendID), "kind": e.Kind, "severity": e.Severity, "message": e.Message, "data": data}) + out = append(out, map[string]any{"id": e.ID, "ts": e.Ts, "project_id": nullI64(e.BalancerID), "kind": e.Kind, "severity": e.Severity, "message": e.Message, "data": data}) } writeJSON(w, 200, map[string]any{"events": out}) } @@ -878,9 +869,9 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request) { var _ = context.Background -// StreamFrontendLogs streams sampled HAProxy log lines of one frontend (§20 live logs). -func (s *Server) StreamFrontendLogs(w http.ResponseWriter, r *http.Request, id gen.Id) { - if _, err := s.frontend(id); err != nil { +// StreamProjectLogs streams sampled HAProxy log lines of one project (§20 live logs). +func (s *Server) StreamProjectLogs(w http.ResponseWriter, r *http.Request, id gen.Id) { + if _, err := s.project(id); err != nil { fail(w, err) return } diff --git a/internal/api/projects.go b/internal/api/projects.go new file mode 100644 index 0000000..cca675c --- /dev/null +++ b/internal/api/projects.go @@ -0,0 +1,384 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/2shiraz/loxy/internal/api/gen" + "github.com/2shiraz/loxy/internal/haproxy/render" + "github.com/2shiraz/loxy/internal/metrics" + "github.com/2shiraz/loxy/internal/model" + "github.com/2shiraz/loxy/internal/state" + "github.com/2shiraz/loxy/internal/store/db" +) + +var algorithms = map[string]bool{"roundrobin": true, "leastconn": true, "source": true, "random": true} + +func (s *Server) project(id int64) (*state.Project, error) { + p := s.Snap.Get().Project(id) + if p == nil { + return nil, errNotFound("project") + } + return p, nil +} + +func (s *Server) respondProject(w http.ResponseWriter, status int, id int64) { + p := s.Snap.Get().Project(id) + if p == nil { + w.WriteHeader(204) + return + } + writeJSON(w, status, map[string]any{"project": s.viewProject(p)}) +} + +func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request, prm gen.ListProjectsParams) { + var sel model.Selector + if prm.LabelSelector != nil && *prm.LabelSelector != "" { + var err error + if sel, err = model.ParseSelector(*prm.LabelSelector); err != nil { + fail(w, errField("label_selector", err.Error())) + return + } + } + out := []map[string]any{} + for _, p := range s.Snap.Get().Projects { + if sel != nil && !sel.Matches(p.Labels) { + continue + } + out = append(out, s.viewProject(p)) + } + writeJSON(w, 200, map[string]any{"projects": out}) +} + +func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + writeJSON(w, 200, map[string]any{"project": s.viewProject(p)}) +} + +// validProjectName checks name rules and uniqueness (self = existing id or 0). +func (s *Server) validProjectName(fe model.FieldErrors, name string, self int64) { + if !model.ValidName(name) { + fe.Add("name", "1–64 printable characters") + return + } + for _, o := range s.Snap.Get().Projects { + if o.ID != self && strings.EqualFold(o.Name, name) { + fe.Add("name", "already in use") + } + } +} + +// CreateProject creates a project with its first service (own free port by default) and +// optional targets, in one transaction (§8.2). +func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) { + s.listenMu.Lock() + defer s.listenMu.Unlock() + var in gen.ProjectCreate + if err := decode(r, &in); err != nil { + fail(w, err) + return + } + fe := model.FieldErrors{} + name := strings.TrimSpace(in.Name) + s.validProjectName(fe, name, 0) + bp := db.CreateBalancerParams{Name: name, Algorithm: "roundrobin", Labels: "{}", CreatedAt: nowMs(), UpdatedAt: nowMs()} + if in.Description != nil { + bp.Description = strings.TrimSpace(*in.Description) + if len(bp.Description) > 500 { + fe.Add("description", "at most 500 characters") + } + } + if in.Algorithm != nil { + bp.Algorithm = string(*in.Algorithm) + } + if !algorithms[bp.Algorithm] { + fe.Add("algorithm", "must be roundrobin, leastconn, source or random") + } + if in.Labels != nil { + model.ValidateLabels(fe, "labels", *in.Labels) + bp.Labels = jsonStr(*in.Labels) + } + svcIn := gen.ServiceInput{Protocol: "http", DestinationPort: 80} + if in.Service != nil { + svcIn = *in.Service + } + draft := &state.Project{} + sp, sfe := s.buildService(draft, nil, svcIn, "service") + for k, msgs := range sfe { + fe[k] = append(fe[k], msgs...) + } + var targets []db.CreateTargetParams + if in.Targets != nil && len(*in.Targets) > 0 { + var tfe model.FieldErrors + targets, tfe = s.buildTargets(nil, *in.Targets, "targets") + for k, msgs := range tfe { + fe[k] = append(fe[k], msgs...) + } + } + if !fe.Empty() { + fail(w, errInput(fe)) + return + } + var id int64 + if err := s.mutate(r, "project.create", "project", func() int64 { return id }, in, func(q *db.Queries) error { + b, err := q.CreateBalancer(r.Context(), bp) + if err != nil { + return err + } + id = b.ID + sp.p.BalancerID = id + if _, err := s.insertService(r.Context(), q, sp); err != nil { + return err + } + for _, t := range targets { + t.BalancerID = id + if _, err := q.CreateTarget(r.Context(), t); err != nil { + return err + } + } + return nil + }); err != nil { + fail(w, err) + return + } + s.respondProject(w, 201, id) +} + +func (s *Server) UpdateProject(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + if err := checkIfMatch(r, p.Version); err != nil { + fail(w, err) + return + } + var in gen.ProjectUpdate + if err := decode(r, &in); err != nil { + fail(w, err) + return + } + u := db.UpdateBalancerParams{Name: p.Name, Description: p.Description, Algorithm: p.Algorithm, Labels: jsonStr(p.Labels), + Maintenance: p.Maintenance, UpdatedAt: nowMs(), ID: id} + fe := model.FieldErrors{} + if in.Name != nil { + u.Name = strings.TrimSpace(*in.Name) + s.validProjectName(fe, u.Name, id) + } + if in.Description != nil { + u.Description = strings.TrimSpace(*in.Description) + if len(u.Description) > 500 { + fe.Add("description", "at most 500 characters") + } + } + if in.Algorithm != nil { + u.Algorithm = string(*in.Algorithm) + if !algorithms[u.Algorithm] { + fe.Add("algorithm", "must be roundrobin, leastconn, source or random") + } + } + if in.Labels != nil { + model.ValidateLabels(fe, "labels", *in.Labels) + u.Labels = jsonStr(*in.Labels) + } + if in.Maintenance != nil { + u.Maintenance = b2i(*in.Maintenance) + } + if !fe.Empty() { + fail(w, errInput(fe)) + return + } + if err := s.mutate(r, "project.update", "project", func() int64 { return id }, in, func(q *db.Queries) error { + return q.UpdateBalancer(r.Context(), u) + }); err != nil { + fail(w, err) + return + } + s.respondProject(w, 200, id) +} + +func (s *Server) DeleteProject(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + doms := s.Snap.Get().DomainsOf(id) + for _, d := range doms { + s.removeManagedRecords(r.Context(), d) + } + if err := s.mutate(r, "project.delete", "project", func() int64 { return id }, map[string]any{"name": p.Name}, func(q *db.Queries) error { + for _, d := range doms { + if err := q.DeleteDomain(r.Context(), d.ID); err != nil { + return err + } + } + return q.DeleteBalancer(r.Context(), id) + }); err != nil { + fail(w, err) + return + } + w.WriteHeader(204) +} + +// GetProjectConfig returns the rendered HAProxy sections of a project's services and the +// domain routing lines that point at them. +func (s *Server) GetProjectConfig(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + out, err := s.Rec.RenderLive(r.Context()) + if err != nil { + fail(w, err) + return + } + cfg := out.Config() + var b strings.Builder + snap := s.Snap.Get() + for _, sv := range p.Services { + if f := snap.Frontend(sv.ID); f != nil { + b.WriteString(render.FrontendSections(cfg, f)) + b.WriteString("\n") + } + } + var dom []string + for _, line := range strings.Split(cfg, "\n") { + for _, sv := range p.Services { + if strings.Contains(line, "use_backend "+render.BackendName(sv.ID)+" ") { + dom = append(dom, strings.TrimSpace(line)) + } + } + } + if len(dom) > 0 { + b.WriteString("# domains (frontends sys_http / sys_https)\n") + for _, l := range dom { + b.WriteString(" " + l + "\n") + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(b.String())) +} + +func (s *Server) RetryApply(w http.ResponseWriter, r *http.Request) { + s.Rec.Trigger() + writeJSON(w, 202, map[string]any{"action": map[string]any{"command": "retry_apply", "status": "running"}}) +} + +func metricsParams(start, end *int64, step *int, typ *string) (int64, int64, int64, []string) { + var a, b, st int64 + if start != nil { + a = *start + } + if end != nil { + b = *end + } + if step != nil { + st = int64(*step) + } + var types []string + if typ != nil && *typ != "" { + types = strings.Split(*typ, ",") + } + return a, b, st, types +} + +// GetProjectMetrics returns project series (all services, port and domain traffic) or one +// service's backend series with service_id. +func (s *Server) GetProjectMetrics(w http.ResponseWriter, r *http.Request, id gen.Id, prm gen.GetProjectMetricsParams) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + scope, sid := metrics.PR, id + if prm.ServiceId != nil { + if p.Service(*prm.ServiceId) == nil { + fail(w, errField("service_id", "not a service of this project")) + return + } + scope, sid = metrics.BE, *prm.ServiceId + } + a, b, st, types := metricsParams(prm.Start, prm.End, prm.Step, prm.Type) + series, err := s.Metrics.Query(r.Context(), scope, sid, a, b, st, types) + if err != nil { + fail(w, err) + return + } + writeJSON(w, 200, map[string]any{"start": series.Start, "end": series.End, "step": series.Step, "timestamps": series.Timestamps, "series": series.Values}) +} + +func (s *Server) StreamProjectMetrics(w http.ResponseWriter, r *http.Request, id gen.Id) { + if _, err := s.project(id); err != nil { + fail(w, err) + return + } + fl, ok := w.(http.Flusher) + if !ok { + fail(w, fmt.Errorf("streaming unsupported")) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + ch, cancel := s.Metrics.Subscribe(id) + defer cancel() + fl.Flush() + for { + select { + case <-r.Context().Done(): + return + case p := <-ch: + b, _ := json.Marshal(map[string]any{"ts": p.TS, "open_connections": p.Open, "connections_per_second": p.CPS, + "requests_per_second": p.RPS, "bandwidth.in": p.In, "bandwidth.out": p.Out, "response_time_ms": p.RespMS}) + fmt.Fprintf(w, "event: metrics.tick\ndata: %s\n\n", b) + fl.Flush() + } + } +} + +// GetOverview is the dashboard summary. +func (s *Server) GetOverview(w http.ResponseWriter, r *http.Request) { + snap := s.Snap.Get() + projects := map[string]int{"total": len(snap.Projects), "apply_errors": 0, "healthy": 0, "degraded": 0, "unhealthy": 0} + tg := map[string]int{"total": 0, "healthy": 0} + for _, p := range snap.Projects { + if p.Status == "error" { + projects["apply_errors"]++ + } + sum, _ := s.projectHealth(p) + if _, ok := projects[sum.Status]; ok { + projects[sum.Status]++ + } + tg["total"] += sum.Total + tg["healthy"] += sum.Healthy + } + certs := map[string]int{"total": len(snap.Certs), "expiring_soon": 0, "failed": 0} + soon := nowMs() + 21*24*3600*1000 + for _, c := range snap.Certs { + if c.Status == "failed" { + certs["failed"]++ + } + if c.NotAfter.Valid && c.NotAfter.Int64 < soon { + certs["expiring_soon"]++ + } + } + var t metrics.Point + if s.Metrics != nil { + t = s.Metrics.Latest(metrics.FE, 0) + } + var node any + if len(snap.Nodes) > 0 { + node = s.viewNode(snap.Nodes[0]) + } + writeJSON(w, 200, map[string]any{"projects": projects, "targets": tg, "certificates": certs, "node": node, + "traffic": map[string]any{"open_connections": t.Open, "requests_per_second": t.RPS, "bytes_in_per_second": t.In, "bytes_out_per_second": t.Out}}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 958e61b..940eb9b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -38,6 +38,7 @@ type Server struct { // listenMu serializes changes that add listeners (frontend create/update/enable, domain // attach) so a collision check and its commit cannot interleave with another request. listenMu sync.Mutex + issueMu sync.Mutex // serializes automatic certificate issuance for domains Cfg *config.Config Store *store.Store Box *secrets.Box @@ -52,7 +53,7 @@ type Server struct { Static http.Handler Limiter *auth.Limiter NodeInfo func() (version string, features []string) - Drain func(backendID, serverID int64) + Drain func(projectID, targetID int64) Notifier *notify.Notifier Logs *logrecv.Receiver } diff --git a/internal/api/services.go b/internal/api/services.go new file mode 100644 index 0000000..eac8654 --- /dev/null +++ b/internal/api/services.go @@ -0,0 +1,393 @@ +package api + +import ( + "context" + "fmt" + "net" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/2shiraz/loxy/internal/api/gen" + "github.com/2shiraz/loxy/internal/model" + "github.com/2shiraz/loxy/internal/state" + "github.com/2shiraz/loxy/internal/store/db" +) + +// Automatic listen ports for projects without an explicit port ("each project has its own port"). +const autoPortFirst, autoPortLast = 20000, 29999 + +// servicePlan is a validated ServiceInput; ListenPort 0 means "pick a free port in the transaction". +type servicePlan struct { + p db.CreateServiceParams + certs []int64 +} + +// reservedPort explains why a listen port cannot be used, or returns "". +func (s *Server) reservedPort(port int) string { + switch port { + case 80, 443: + return "reserved for domains (shared :80/:443)" + case s.Cfg.PanelPort: + return "reserved for the Loxy panel" + case 8405, 5514: + return "reserved for Loxy internals" + } + if _, p, err := net.SplitHostPort(s.Cfg.Listen); err == nil && strconv.Itoa(port) == p { + return "used by the Loxy API" + } + return "" +} + +// buildService validates a service input (§8.5). existing is nil on create. +func (s *Server) buildService(p *state.Project, existing *state.Service, in gen.ServiceInput, field string) (*servicePlan, model.FieldErrors) { + fe := model.FieldErrors{} + f := func(name string) string { + if field == "" { + return name + } + return field + "." + name + } + sp := &servicePlan{p: db.CreateServiceParams{BalancerID: p.ID, Protocol: string(in.Protocol), DestinationPort: int64(in.DestinationPort), + ProxyProtocol: "none", TimeoutS: 60, StickyCookieName: "LOXYSTICKY", StickyCookieLifetimeS: 300, + HcProtocol: "tcp", HcIntervalS: 15, HcTimeoutS: 10, HcRetries: 3, HcPath: "/", CreatedAt: nowMs()}} + c := &sp.p + if c.Protocol != "http" && c.Protocol != "https" && c.Protocol != "tcp" { + fe.Add(f("protocol"), "must be http, https or tcp") + } + if c.Protocol != "tcp" { + c.HcProtocol = "http" // http checks by default for http services + } + if !model.ValidPort(in.DestinationPort) { + fe.Add(f("destination_port"), "must be 1–65535") + } + if in.ListenPort != nil && *in.ListenPort != 0 { + lp := *in.ListenPort + c.ListenPort = int64(lp) + if !model.ValidPort(lp) { + fe.Add(f("listen_port"), "must be 1–65535") + } else if why := s.reservedPort(lp); why != "" { + fe.Add(f("listen_port"), why) + } else { + for _, o := range s.Snap.Get().Projects { + for _, os := range o.Services { + if os.ListenPort == c.ListenPort && (existing == nil || os.ID != existing.ID) { + fe.Add(f("listen_port"), fmt.Sprintf("already used by project %q", o.Name)) + } + } + } + } + } else if existing != nil { + c.ListenPort = existing.ListenPort // keep the port on update when none is given + } + if in.ProxyProtocol != nil { + c.ProxyProtocol = string(*in.ProxyProtocol) + if c.ProxyProtocol != "none" && c.ProxyProtocol != "v1" && c.ProxyProtocol != "v2" { + fe.Add(f("proxy_protocol"), "must be none, v1 or v2") + } + } + if in.TimeoutS != nil { + c.TimeoutS = int64(*in.TimeoutS) + } + if c.TimeoutS < 1 || c.TimeoutS > 86400 { + fe.Add(f("timeout_s"), "must be 1–86400") + } + if in.StickySessions != nil { + c.StickyEnabled = b2i(*in.StickySessions) + } + if in.CookieName != nil && *in.CookieName != "" { + c.StickyCookieName = *in.CookieName + } + if in.CookieLifetimeS != nil { + c.StickyCookieLifetimeS = int64(*in.CookieLifetimeS) + } + if c.StickyEnabled != 0 && c.Protocol == "tcp" { + fe.Add(f("sticky_sessions"), "only for http and https services") + } + if !model.ValidCookieName(c.StickyCookieName) { + fe.Add(f("cookie_name"), "letters, digits, - and _ only (1–64)") + } + if c.StickyCookieLifetimeS < 1 || c.StickyCookieLifetimeS > 31536000 { + fe.Add(f("cookie_lifetime_s"), "must be 1–31536000") + } + codes := []string{"2??", "3??"} + if hc := in.HealthCheck; hc != nil { + if hc.Protocol != nil { + c.HcProtocol = string(*hc.Protocol) + } + if hc.Port != nil { + c.HcPort = int64(*hc.Port) + } + if hc.IntervalS != nil { + c.HcIntervalS = int64(*hc.IntervalS) + } + if hc.TimeoutS != nil { + c.HcTimeoutS = int64(*hc.TimeoutS) + } + if hc.Retries != nil { + c.HcRetries = int64(*hc.Retries) + } + if h := hc.Http; h != nil && c.HcProtocol != "tcp" { + if h.Domain != nil { + c.HcDomain = strings.ToLower(strings.TrimSpace(*h.Domain)) + } + if h.Path != nil { + c.HcPath = *h.Path + } + if h.Response != nil { + c.HcResponse = *h.Response + } + if h.StatusCodes != nil { + codes = *h.StatusCodes + } + if h.TlsVerify != nil { + c.HcTlsVerify = b2i(*h.TlsVerify) + } + } + } + hf := func(n string) string { return f("health_check." + n) } + if c.HcProtocol != "tcp" && c.HcProtocol != "http" && c.HcProtocol != "https" { + fe.Add(hf("protocol"), "must be tcp, http or https") + } + if c.HcPort != 0 && !model.ValidPort(int(c.HcPort)) { + fe.Add(hf("port"), "must be 1–65535") + } + if c.HcIntervalS < 3 || c.HcIntervalS > 3600 { + fe.Add(hf("interval_s"), "must be 3–3600") + } + if c.HcTimeoutS < 1 || c.HcTimeoutS > 3600 { + fe.Add(hf("timeout_s"), "must be 1–3600") + } else if c.HcTimeoutS >= c.HcIntervalS { + fe.Add(hf("timeout_s"), "must be less than the interval") + } + if c.HcRetries < 1 || c.HcRetries > 10 { + fe.Add(hf("retries"), "must be 1–10") + } + if c.HcProtocol == "tcp" { + c.HcDomain, c.HcResponse, c.HcTlsVerify = "", "", 0 + } else { + if !model.ValidHCPath(c.HcPath) { + fe.Add(hf("http.path"), "must start with / and contain no spaces, quotes, #, $ or \\") + } + if c.HcDomain != "" && !model.ValidHostname(c.HcDomain, false) { + fe.Add(hf("http.domain"), "invalid hostname") + } + if c.HcResponse != "" && !model.ValidHCResponse(c.HcResponse) { + fe.Add(hf("http.response"), "printable ASCII without ' \" \\ # $ (1–128)") + } + if _, err := model.StatusCodesToHAProxy(codes); err != nil { + fe.Add(hf("http.status_codes"), err.Error()) + } + if c.HcTlsVerify != 0 && c.HcDomain == "" { + fe.Add(hf("http.domain"), "required when TLS verification is on") + } + } + c.HcStatusCodes = jsonStr(codes) + if c.Protocol == "https" { + snap := s.Snap.Get() + if in.Certificates != nil { + for _, id := range *in.Certificates { + if snap.Cert(id) == nil { + fe.Add(f("certificates"), fmt.Sprintf("unknown certificate %d", id)) + } else if slices.Contains(sp.certs, id) { + fe.Add(f("certificates"), fmt.Sprintf("certificate %d listed twice", id)) + } else { + sp.certs = append(sp.certs, id) + } + } + } + if len(sp.certs) == 0 { + fe.Add(f("certificates"), "https needs at least one certificate (a pending managed certificate is fine)") + } + } + return sp, fe +} + +// domainsNeed reports whether removing web service sid (deleting it or turning it into tcp) +// would leave the project's domains without an http/https service. +func (s *Server) domainsNeed(p *state.Project, sid int64) bool { + if len(s.Snap.Get().DomainsOf(p.ID)) == 0 { + return false + } + for _, o := range p.Services { + if o.ID != sid && o.Protocol != "tcp" { + return false + } + } + return true +} + +// insertService writes a planned service, picking a free listen port when none was given. +// Runs inside the write transaction (callers hold listenMu). +func (s *Server) insertService(ctx context.Context, q *db.Queries, sp *servicePlan) (db.Service, error) { + if sp.p.ListenPort == 0 { + port, err := s.freePort(ctx, q) + if err != nil { + return db.Service{}, err + } + sp.p.ListenPort = port + } + sv, err := q.CreateService(ctx, sp.p) + if err != nil { + return sv, err + } + for i, c := range sp.certs { + if err := q.AddServiceCertificate(ctx, db.AddServiceCertificateParams{ServiceID: sv.ID, CertificateID: c, Position: int64(i)}); err != nil { + return sv, err + } + } + return sv, nil +} + +func (s *Server) freePort(ctx context.Context, q *db.Queries) (int64, error) { + used, err := q.ListListenPorts(ctx) + if err != nil { + return 0, err + } + taken := map[int64]bool{} + for _, p := range used { + taken[p] = true + } + for p := int64(autoPortFirst); p <= autoPortLast; p++ { + if !taken[p] && s.reservedPort(int(p)) == "" { + return p, nil + } + } + return 0, errField("listen_port", "no free port left in 20000–29999; set one explicitly") +} + +func (s *Server) ListServices(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + out := []map[string]any{} + for _, sv := range p.Services { + out = append(out, s.viewService(p, sv)) + } + writeJSON(w, 200, map[string]any{"services": out}) +} + +func (s *Server) respondService(w http.ResponseWriter, status int, projectID, sid int64) { + p := s.Snap.Get().Project(projectID) + if p == nil || p.Service(sid) == nil { + w.WriteHeader(204) + return + } + writeJSON(w, status, map[string]any{"service": s.viewService(p, p.Service(sid))}) +} + +func (s *Server) CreateService(w http.ResponseWriter, r *http.Request, id gen.Id) { + s.listenMu.Lock() + defer s.listenMu.Unlock() + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + var in gen.ServiceInput + if err := decode(r, &in); err != nil { + fail(w, err) + return + } + if len(p.Services) >= 20 { + fail(w, errField("protocol", "at most 20 services per project")) + return + } + sp, fe := s.buildService(p, nil, in, "") + if !fe.Empty() { + fail(w, errInput(fe)) + return + } + var sid int64 + if err := s.mutate(r, "service.create", "project", func() int64 { return id }, in, func(q *db.Queries) error { + sv, err := s.insertService(r.Context(), q, sp) + sid = sv.ID + return err + }); err != nil { + fail(w, err) + return + } + s.respondService(w, 201, id, sid) +} + +func (s *Server) UpdateService(w http.ResponseWriter, r *http.Request, id gen.Id, sid int64) { + s.listenMu.Lock() + defer s.listenMu.Unlock() + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + old := p.Service(sid) + if old == nil { + fail(w, errNotFound("service")) + return + } + if err := checkIfMatch(r, old.Version); err != nil { + fail(w, err) + return + } + var in gen.ServiceInput + if err := decode(r, &in); err != nil { + fail(w, err) + return + } + sp, fe := s.buildService(p, old, in, "") + if !fe.Empty() { + fail(w, errInput(fe)) + return + } + if sp.p.Protocol == "tcp" && old.Protocol != "tcp" && s.domainsNeed(p, sid) { + fail(w, errConflict("the project's domains need an http or https service; remove the domains or add another service first")) + return + } + c := sp.p + if err := s.mutate(r, "service.update", "project", func() int64 { return id }, in, func(q *db.Queries) error { + if err := q.UpdateService(r.Context(), db.UpdateServiceParams{Protocol: c.Protocol, ListenPort: c.ListenPort, DestinationPort: c.DestinationPort, + ProxyProtocol: c.ProxyProtocol, TimeoutS: c.TimeoutS, StickyEnabled: c.StickyEnabled, StickyCookieName: c.StickyCookieName, + StickyCookieLifetimeS: c.StickyCookieLifetimeS, HcProtocol: c.HcProtocol, HcPort: c.HcPort, HcIntervalS: c.HcIntervalS, + HcTimeoutS: c.HcTimeoutS, HcRetries: c.HcRetries, HcDomain: c.HcDomain, HcPath: c.HcPath, HcResponse: c.HcResponse, + HcStatusCodes: c.HcStatusCodes, HcTlsVerify: c.HcTlsVerify, ID: sid}); err != nil { + return err + } + if err := q.ClearServiceCertificates(r.Context(), sid); err != nil { + return err + } + for i, cid := range sp.certs { + if err := q.AddServiceCertificate(r.Context(), db.AddServiceCertificateParams{ServiceID: sid, CertificateID: cid, Position: int64(i)}); err != nil { + return err + } + } + return nil + }); err != nil { + fail(w, err) + return + } + s.respondService(w, 200, id, sid) +} + +func (s *Server) DeleteService(w http.ResponseWriter, r *http.Request, id gen.Id, sid int64) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + sv := p.Service(sid) + if sv == nil { + fail(w, errNotFound("service")) + return + } + if sv.Protocol != "tcp" && s.domainsNeed(p, sid) { + fail(w, errConflict("the project's domains need an http or https service; remove the domains or add another service first")) + return + } + if err := s.mutate(r, "service.delete", "project", func() int64 { return id }, map[string]any{"service_id": sid, "listen_port": sv.ListenPort}, + func(q *db.Queries) error { return q.DeleteService(r.Context(), sid) }); err != nil { + fail(w, err) + return + } + w.WriteHeader(204) +} diff --git a/internal/api/targets.go b/internal/api/targets.go new file mode 100644 index 0000000..26be628 --- /dev/null +++ b/internal/api/targets.go @@ -0,0 +1,254 @@ +package api + +import ( + "database/sql" + "fmt" + "net/http" + "net/netip" + "strings" + + "github.com/2shiraz/loxy/internal/api/gen" + "github.com/2shiraz/loxy/internal/model" + "github.com/2shiraz/loxy/internal/state" + "github.com/2shiraz/loxy/internal/store/db" +) + +const maxTargets = 500 + +// splitIPPort accepts "ip", "ip:port" and "[v6]:port". +func splitIPPort(s string) (string, int, bool) { + s = strings.TrimSpace(s) + if ap, err := netip.ParseAddrPort(s); err == nil { + return ap.Addr().String(), int(ap.Port()), true + } + return s, 0, false +} + +// buildTargets validates target inputs (§8.4). field prefixes error names ("targets"). +func (s *Server) buildTargets(p *state.Project, ins []gen.TargetInput, field string) ([]db.CreateTargetParams, model.FieldErrors) { + snap := s.Snap.Get() + key := func(typ, ip string, host int64, sel string, port int64) string { + return fmt.Sprintf("%s|%s|%d|%s|%d", typ, ip, host, sel, port) + } + existing := map[string]bool{} + n := 0 + if p != nil { + n = len(p.Targets) + for _, t := range p.Targets { + existing[key(t.Type, t.Ip, t.HostID.Int64, t.LabelSelector, t.PortOr(0))] = true + } + } + fe := model.FieldErrors{} + var out []db.CreateTargetParams + if n+len(ins) > maxTargets { + fe.Add(field, fmt.Sprintf("at most %d targets per project", maxTargets)) + } + for i, in := range ins { + f := fmt.Sprintf("%s[%d]", field, i) + t := db.CreateTargetParams{Type: string(in.Type), Weight: 100, CreatedAt: nowMs()} + if p != nil { + t.BalancerID = p.ID + } + if in.Weight != nil { + t.Weight = int64(*in.Weight) + } + if t.Weight < 0 || t.Weight > 256 { + fe.Add(f+".weight", "must be 0–256") + } + if in.Backup != nil { + t.Backup = b2i(*in.Backup) + } + if in.UsePrivateIp != nil { + t.UsePrivateIp = b2i(*in.UsePrivateIp) + } + port := 0 + if in.Port != nil { + port = *in.Port + } + switch t.Type { + case "ip": + if in.Ip == nil || strings.TrimSpace(*in.Ip) == "" { + fe.Add(f+".ip", "required") + continue + } + ip, pp, hasPort := splitIPPort(*in.Ip) + if hasPort { + port = pp + } + a, err := model.ParseTargetIP(ip) + if err != nil { + // Dev mode (make dev) allows loopback targets such as local test containers. + if la, perr := netip.ParseAddr(ip); s.Cfg.Dev && perr == nil && la.IsLoopback() { + a = la + } else { + fe.Add(f+".ip", err.Error()) + continue + } + } + t.Ip = a.String() + case "host": + if in.HostId == nil || snap.Host(*in.HostId) == nil { + fe.Add(f+".host_id", "unknown host") + continue + } + t.HostID = sql.NullInt64{Int64: *in.HostId, Valid: true} + case "label_selector": + if in.LabelSelector == nil || strings.TrimSpace(*in.LabelSelector) == "" { + fe.Add(f+".label_selector", "required") + continue + } + if _, err := model.ParseSelector(*in.LabelSelector); err != nil { + fe.Add(f+".label_selector", err.Error()) + continue + } + t.LabelSelector = strings.TrimSpace(*in.LabelSelector) + default: + fe.Add(f+".type", "must be ip, host or label_selector") + continue + } + if port != 0 { + if !model.ValidPort(port) { + fe.Add(f+".port", "must be 1–65535") + } + t.Port = sql.NullInt64{Int64: int64(port), Valid: true} + } + k := key(t.Type, t.Ip, t.HostID.Int64, t.LabelSelector, t.Port.Int64) + if existing[k] { + fe.Add(f, "target already exists") + } + existing[k] = true + out = append(out, t) + } + return out, fe +} + +func (s *Server) ListTargets(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + view := s.targetViewer(p) + out := []map[string]any{} + for _, t := range p.Targets { + out = append(out, view(t)) + } + writeJSON(w, 200, map[string]any{"targets": out}) +} + +func (s *Server) CreateTargets(w http.ResponseWriter, r *http.Request, id gen.Id) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + var in gen.TargetsCreate + if err := decode(r, &in); err != nil { + fail(w, err) + return + } + if len(in.Targets) == 0 { + fail(w, errField("targets", "add at least one target")) + return + } + ts, fe := s.buildTargets(p, in.Targets, "targets") + if !fe.Empty() { + fail(w, errInput(fe)) + return + } + var ids []int64 + if err := s.mutate(r, "target.create", "project", func() int64 { return id }, in, func(q *db.Queries) error { + for _, t := range ts { + row, err := q.CreateTarget(r.Context(), t) + if err != nil { + return err + } + ids = append(ids, row.ID) + } + return nil + }); err != nil { + fail(w, err) + return + } + out := []map[string]any{} + if np := s.Snap.Get().Project(id); np != nil { + view := s.targetViewer(np) + for _, tid := range ids { + if t := np.Target(tid); t != nil { + out = append(out, view(t)) + } + } + } + writeJSON(w, 201, map[string]any{"targets": out}) +} + +func (s *Server) UpdateTarget(w http.ResponseWriter, r *http.Request, id gen.Id, tid int64) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + t := p.Target(tid) + if t == nil { + fail(w, errNotFound("target")) + return + } + var in gen.TargetUpdate + if err := decode(r, &in); err != nil { + fail(w, err) + return + } + u := db.UpdateTargetParams{Weight: t.Weight, Backup: t.Backup, AdminState: t.AdminState, ID: tid} + if in.Weight != nil { + if *in.Weight < 0 || *in.Weight > 256 { + fail(w, errField("weight", "must be 0–256")) + return + } + u.Weight = int64(*in.Weight) + } + if in.AdminState != nil { + u.AdminState = string(*in.AdminState) + if u.AdminState != "ready" && u.AdminState != "drain" && u.AdminState != "maint" { + fail(w, errField("admin_state", "must be ready, drain or maint")) + return + } + } + if in.Backup != nil { + u.Backup = b2i(*in.Backup) + } + if err := s.mutate(r, "target.update", "project", func() int64 { return id }, in, func(q *db.Queries) error { + return q.UpdateTarget(r.Context(), u) + }); err != nil { + fail(w, err) + return + } + np := s.Snap.Get().Project(id) + if np == nil || np.Target(tid) == nil { + w.WriteHeader(204) + return + } + writeJSON(w, 200, map[string]any{"target": s.targetViewer(np)(np.Target(tid))}) +} + +// DeleteTarget starts a graceful removal: drain → wait for sessions to end or timeout → delete (§7.6). +func (s *Server) DeleteTarget(w http.ResponseWriter, r *http.Request, id gen.Id, tid int64) { + p, err := s.project(id) + if err != nil { + fail(w, err) + return + } + if p.Target(tid) == nil { + fail(w, errNotFound("target")) + return + } + if err := s.mutate(r, "target.delete", "project", func() int64 { return id }, map[string]any{"target_id": tid}, func(q *db.Queries) error { + return q.MarkTargetRemoving(r.Context(), tid) + }); err != nil { + fail(w, err) + return + } + if s.Drain != nil { + go s.Drain(id, tid) + } + w.WriteHeader(202) +} diff --git a/internal/api/views.go b/internal/api/views.go index 1262ae3..86fb51d 100644 --- a/internal/api/views.go +++ b/internal/api/views.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "net/netip" "github.com/2shiraz/loxy/internal/health" "github.com/2shiraz/loxy/internal/metrics" @@ -15,182 +16,219 @@ type healthSummary struct { Messages []string `json:"messages"` } +type targetCheck struct { + ServiceID int64 `json:"service_id"` + Status string `json:"status"` + Check string `json:"check,omitempty"` + Sessions float64 `json:"sessions"` +} + type resolvedView struct { - SlotID int64 `json:"slot_id"` - ServerID int64 `json:"server_id"` - HostID *int64 `json:"host_id"` - Name *string `json:"name"` - Address string `json:"address"` - Port int64 `json:"port"` - Status string `json:"status"` - Check string `json:"check,omitempty"` - Sessions float64 `json:"sessions"` + SlotID int64 `json:"slot_id"` + TargetID int64 `json:"-"` + HostID *int64 `json:"host_id"` + Name *string `json:"name"` + Address string `json:"address"` + Status string `json:"status"` + Checks []targetCheck `json:"checks"` +} + +func isV6(a string) bool { + x, err := netip.ParseAddr(a) + return err == nil && x.Is6() } -// backendHealth computes the health of every resolved server of a backend (§9). -func (s *Server) backendHealth(b *state.Backend) (healthSummary, []resolvedView) { +// projectHealth computes per-target health across all services of a project (§9): a slot is +// healthy when every service's check passes, unhealthy when all fail, degraded in between. +func (s *Server) projectHealth(p *state.Project) (healthSummary, []resolvedView) { snap := s.Snap.Get() + checks := map[int64]map[int64]metrics.Check{} + if s.Metrics != nil { + for _, sv := range p.Services { + checks[sv.ID] = s.Metrics.Checks(sv.ID) + } + } out := []resolvedView{} var statuses []string var msgs []string - var checks map[int64]metrics.Check - if s.Metrics != nil { - checks = s.Metrics.Checks(b.ID) - } - for _, sv := range b.Servers { - for _, sl := range sv.Slots { - rv := resolvedView{SlotID: sl.ID, ServerID: sv.ID, Address: sl.Address, Port: sv.PortOr(b.Port), Status: "unknown"} + for _, t := range p.Targets { + for _, sl := range t.Slots { + rv := resolvedView{SlotID: sl.ID, TargetID: t.ID, Address: sl.Address, Status: "unknown", Checks: []targetCheck{}} if sl.HostID != 0 { rv.HostID = ptr(sl.HostID) if h := snap.Host(sl.HostID); h != nil { rv.Name = ptr(h.Name) } } - if ch, ok := checks[sl.ID]; ok { - rv.Status, rv.Check, rv.Sessions = ch.Status, ch.Detail, float64(ch.Scur) - } - if rv.Status == "unhealthy" && len(msgs) < 3 { - msgs = append(msgs, fmt.Sprintf("%s:%d · %s", sl.Address, rv.Port, rv.Check)) + var st []string + for _, sv := range p.Services { + tc := targetCheck{ServiceID: sv.ID, Status: "unknown"} + if ch, ok := checks[sv.ID][sl.ID]; ok { + tc.Status, tc.Check, tc.Sessions = ch.Status, ch.Detail, float64(ch.Scur) + } + if tc.Status == "unhealthy" && len(msgs) < 3 { + msgs = append(msgs, fmt.Sprintf("%s:%d (:%d) · %s", sl.Address, t.PortOr(sv.DestinationPort), sv.ListenPort, tc.Check)) + } + rv.Checks = append(rv.Checks, tc) + st = append(st, tc.Status) } + rv.Status = health.Aggregate([]health.Service{{ID: t.ID, Checks: st}}).Status statuses = append(statuses, rv.Status) out = append(out, rv) } } - sum := health.Aggregate([]health.Service{{ID: b.ID, Checks: statuses}}) + sum := health.Aggregate([]health.Service{{ID: p.ID, Checks: statuses}}) if msgs == nil { msgs = []string{} if sum.Status == "healthy" { - msgs = append(msgs, "All servers are healthy.") + msgs = append(msgs, "All targets are healthy.") } } return healthSummary{Status: sum.Status, Healthy: sum.Healthy, Total: sum.Total, Messages: msgs}, out } -// frontendHealth aggregates the backends a frontend routes to. -func (s *Server) frontendHealth(f *state.Frontend) healthSummary { - snap := s.Snap.Get() - var svcs []health.Service - var msgs []string - for _, id := range f.BackendIDs() { - b := snap.Backend(id) - if b == nil { - continue - } - sum, rs := s.backendHealth(b) - hs := health.Service{ID: b.ID} - for _, r := range rs { - hs.Checks = append(hs.Checks, r.Status) - } - svcs = append(svcs, hs) - if sum.Status == "unhealthy" || sum.Status == "degraded" { - msgs = append(msgs, fmt.Sprintf("Backend %s: %d/%d healthy", b.Name, sum.Healthy, sum.Total)) - } +// serviceHealth is the health of a project's targets as seen by one service's checks. +func (s *Server) serviceHealth(p *state.Project, sv *state.Service) healthSummary { + var checks map[int64]metrics.Check + if s.Metrics != nil { + checks = s.Metrics.Checks(sv.ID) } - sum := health.Aggregate(svcs) - if msgs == nil { - msgs = []string{} + var st []string + for _, sl := range p.Slots() { + status := "unknown" + if ch, ok := checks[sl.ID]; ok { + status = ch.Status + } + st = append(st, status) } - return healthSummary{Status: sum.Status, Healthy: sum.Healthy, Total: sum.Total, Messages: msgs} + sum := health.Aggregate([]health.Service{{ID: sv.ID, Checks: st}}) + return healthSummary{Status: sum.Status, Healthy: sum.Healthy, Total: sum.Total, Messages: []string{}} } -func (s *Server) viewFrontend(f *state.Frontend) map[string]any { - certs := f.CertIDs - if certs == nil { - certs = []int64{} +// endpoints lists where a project is reachable: one URL per service port, then its domains. +func (s *Server) endpoints(p *state.Project) []map[string]any { + snap := s.Snap.Get() + host := "" + if n := snap.Node(p.NodeID); n != nil && len(n.Addresses) > 0 { + host = n.Addresses[0].Address + if isV6(host) { + host = "[" + host + "]" + } } - binds := f.Binds - if binds == nil { - binds = []state.Bind{} + out := []map[string]any{} + for _, sv := range p.Services { + scheme := sv.Protocol + out = append(out, map[string]any{"kind": "port", "service_id": sv.ID, "domain_id": nil, + "url": fmt.Sprintf("%s://%s:%d", scheme, host, sv.ListenPort)}) } - routes := []map[string]any{} - for _, r := range f.Routes { - hosts := r.Hosts - if hosts == nil { - hosts = []string{} - } - routes = append(routes, map[string]any{"id": r.ID, "hosts": hosts, "path_prefix": r.PathPrefix, "backend_id": r.BackendID}) + for _, d := range snap.DomainsOf(p.ID) { + out = append(out, map[string]any{"kind": "domain", "service_id": nil, "domain_id": d.ID, "url": s.domainURL(d)}) } - var p metrics.Point + return out +} + +func (s *Server) viewProject(p *state.Project) map[string]any { + sum, _ := s.projectHealth(p) + var pt metrics.Point if s.Metrics != nil { - p = s.Metrics.Latest(metrics.FE, f.ID) + pt = s.Metrics.Latest(metrics.PR, p.ID) } return map[string]any{ - "id": f.ID, "name": f.Name, "version": f.Version, "description": f.Description, "mode": f.Mode, "tls": f.Tls != 0, - "binds": binds, "certificates": certs, "http_redirect": f.HttpRedirect != 0, "default_backend_id": nullI64(f.DefaultBackendID), - "routes": routes, "timeout_client_s": f.TimeoutClientS, "maxconn": f.Maxconn, "forwarded_headers": f.ForwardedHeaders != 0, - "enabled": f.Enabled != 0, "maintenance": f.Maintenance != 0, "labels": f.Labels, "status": f.Status, - "last_error": nullStr(f.LastError.String), "created_at": f.CreatedAt, - "stats": map[string]any{"open_connections": p.Open, "requests_per_second": p.RPS, "bytes_out_per_second": p.Out}, - "health": s.frontendHealth(f), + "id": p.ID, "name": p.Name, "version": p.Version, "description": p.Description, "algorithm": p.Algorithm, "labels": p.Labels, + "maintenance": p.Maintenance != 0, "status": p.Status, "last_error": nullStr(p.LastError.String), "created_at": p.CreatedAt, + "endpoints": s.endpoints(p), + "counts": map[string]int{"targets": len(p.Targets), "services": len(p.Services), "domains": len(s.Snap.Get().DomainsOf(p.ID))}, + "health": sum, + "stats": map[string]any{"open_connections": pt.Open, "requests_per_second": pt.RPS, "bytes_out_per_second": pt.Out}, } } -func viewHealthCheck(b *state.Backend) map[string]any { - codes := b.StatusCodes +func viewHealthCheck(sv *state.Service) map[string]any { + codes := sv.StatusCodes if codes == nil { codes = []string{} } var hcHTTP any - if b.HcProtocol != "tcp" { - hcHTTP = map[string]any{"domain": nullStr(b.HcDomain), "path": b.HcPath, "response": nullStr(b.HcResponse), - "status_codes": codes, "tls_verify": b.HcTlsVerify != 0} + if sv.HcProtocol != "tcp" { + hcHTTP = map[string]any{"domain": nullStr(sv.HcDomain), "path": sv.HcPath, "response": nullStr(sv.HcResponse), + "status_codes": codes, "tls_verify": sv.HcTlsVerify != 0} } - var port any // null: each server is checked on its own port - if b.HcPort != 0 { - port = b.HcPort + var port any // null: each target is checked on its own port + if sv.HcPort != 0 { + port = sv.HcPort } - return map[string]any{"protocol": b.HcProtocol, "port": port, "interval_s": b.HcIntervalS, "timeout_s": b.HcTimeoutS, - "retries": b.HcRetries, "http": hcHTTP} + return map[string]any{"protocol": sv.HcProtocol, "port": port, "interval_s": sv.HcIntervalS, "timeout_s": sv.HcTimeoutS, + "retries": sv.HcRetries, "http": hcHTTP} } -func (s *Server) viewBackend(b *state.Backend) map[string]any { - sum, rs := s.backendHealth(b) - var p metrics.Point - if s.Metrics != nil { - p = s.Metrics.Latest(metrics.BE, b.ID) +func (s *Server) viewService(p *state.Project, sv *state.Service) map[string]any { + certs := sv.CertIDs + if certs == nil { + certs = []int64{} + } + snap := s.Snap.Get() + doms := []string{} + for _, d := range snap.DomainsOf(p.ID) { + if ds := snap.DomainService(d); ds != nil && ds.ID == sv.ID { + doms = append(doms, d.Fqdn) + } } return map[string]any{ - "id": b.ID, "name": b.Name, "version": b.Version, "description": b.Description, "mode": b.Mode, "algorithm": b.Algorithm, - "port": b.Port, "health_check": viewHealthCheck(b), "sticky_sessions": b.StickyEnabled != 0, "cookie_name": b.StickyCookieName, - "cookie_lifetime_s": b.StickyCookieLifetimeS, "proxy_protocol": b.ProxyProtocol, "timeout_server_s": b.TimeoutServerS, - "labels": b.Labels, "created_at": b.CreatedAt, "used_by": s.Snap.Get().UsedBy(b.ID), "servers_count": len(rs), - "health": sum, "stats": map[string]any{"sessions": p.Open, "queue": p.Queue, "response_time_ms": p.RespMS}, + "id": sv.ID, "project_id": p.ID, "version": sv.Version, "protocol": sv.Protocol, "listen_port": sv.ListenPort, "destination_port": sv.DestinationPort, + "proxy_protocol": sv.ProxyProtocol, "timeout_s": sv.TimeoutS, "certificates": certs, "sticky_sessions": sv.StickyEnabled != 0, + "cookie_name": sv.StickyCookieName, "cookie_lifetime_s": sv.StickyCookieLifetimeS, "health_check": viewHealthCheck(sv), + "health": s.serviceHealth(p, sv), "domains": doms, } } -// serverViewer computes the backend's health once and returns a view func for its servers -// (avoids a full health pass per server on large pools). -func (s *Server) serverViewer(b *state.Backend) func(sv *state.Server) map[string]any { - _, all := s.backendHealth(b) - byServer := map[int64][]resolvedView{} +// targetViewer computes the project's health once and returns a view func for its targets. +func (s *Server) targetViewer(p *state.Project) func(t *state.Server) map[string]any { + _, all := s.projectHealth(p) + byTarget := map[int64][]resolvedView{} for _, r := range all { - byServer[r.ServerID] = append(byServer[r.ServerID], r) + byTarget[r.TargetID] = append(byTarget[r.TargetID], r) } - return func(sv *state.Server) map[string]any { - resolved := byServer[sv.ID] + return func(t *state.Server) map[string]any { + resolved := byTarget[t.ID] if resolved == nil { resolved = []resolvedView{} } var port any - if sv.Port.Valid { - port = sv.Port.Int64 + if t.Port.Valid { + port = t.Port.Int64 } return map[string]any{ - "id": sv.ID, "backend_id": sv.BackendID, "type": sv.Type, "ip": nullStr(sv.Ip), "host_id": nullI64(sv.HostID), - "label_selector": nullStr(sv.LabelSelector), "port": port, "use_private_ip": sv.UsePrivateIp != 0, "weight": sv.Weight, - "backup": sv.Backup != 0, "admin_state": sv.AdminState, "removing": sv.Removing != 0, "resolved": resolved, + "id": t.ID, "project_id": t.BalancerID, "type": t.Type, "ip": nullStr(t.Ip), "host_id": nullI64(t.HostID), + "label_selector": nullStr(t.LabelSelector), "port": port, "use_private_ip": t.UsePrivateIp != 0, "weight": t.Weight, + "backup": t.Backup != 0, "admin_state": t.AdminState, "removing": t.Removing != 0, "resolved": resolved, } } } func (s *Server) viewCert(c *state.Cert) map[string]any { + snap := s.Snap.Get() used := []int64{} - for _, f := range s.Snap.Get().Frontends { - for _, id := range f.CertIDs { - if id == c.ID { - used = append(used, f.ID) + add := func(id int64) { + for _, u := range used { + if u == id { + return } } + used = append(used, id) + } + for _, p := range snap.Projects { + for _, sv := range p.Services { + for _, id := range sv.CertIDs { + if id == c.ID { + add(p.ID) + } + } + } + } + for _, d := range snap.Domains { + if d.CertificateID.Valid && d.CertificateID.Int64 == c.ID && d.BalancerID.Valid { + add(d.BalancerID.Int64) + } } domains := c.Domains if domains == nil { @@ -208,55 +246,41 @@ func (s *Server) viewCert(c *state.Cert) map[string]any { } } -// frontendAddresses returns the public IPs clients should resolve to: the frontend's explicit -// bind addresses, or the node's addresses when it binds on all interfaces. -func (s *Server) frontendAddresses(f *state.Frontend) (v4, v6 []string) { - wildcard := false - seen := map[string]bool{} - for _, b := range f.Binds { - if b.Address == "*" || b.Address == "" { - wildcard = true - continue - } - if seen[b.Address] { - continue - } - seen[b.Address] = true - if isV6(b.Address) { - v6 = append(v6, b.Address) +// nodeAddresses returns the public addresses domains must resolve to (the node's addresses). +func (s *Server) nodeAddresses() (v4, v6 []string) { + snap := s.Snap.Get() + if len(snap.Nodes) == 0 { + return nil, nil + } + for _, a := range snap.Nodes[0].Addresses { + if a.Family == 6 { + v6 = append(v6, a.Address) } else { - v4 = append(v4, b.Address) + v4 = append(v4, a.Address) } } - if wildcard { - if n := s.Snap.Get().Node(f.NodeID); n != nil { - for _, a := range n.Addresses { - if seen[a.Address] { - continue - } - seen[a.Address] = true - if a.Family == 6 { - v6 = append(v6, a.Address) - } else { - v4 = append(v4, a.Address) - } - } + return v4, v6 +} + +// domainURL is https://fqdn once the domain serves its certificate, else http://fqdn. +func (s *Server) domainURL(d *state.Domain) string { + if d.Https != 0 && d.CertificateID.Valid { + if c := s.Snap.Get().Cert(d.CertificateID.Int64); c != nil && c.PEM() != "" { + return "https://" + d.Fqdn } } - return v4, v6 + return "http://" + d.Fqdn } func (s *Server) viewDomain(d *state.Domain) map[string]any { req := []map[string]any{} - if d.FrontendID.Valid { - if f := s.Snap.Get().Frontend(d.FrontendID.Int64); f != nil { - v4, v6 := s.frontendAddresses(f) - for _, a := range v4 { - req = append(req, map[string]any{"type": "A", "name": d.Fqdn, "value": a, "ttl": d.Ttl}) - } - for _, a := range v6 { - req = append(req, map[string]any{"type": "AAAA", "name": d.Fqdn, "value": a, "ttl": d.Ttl}) - } + if d.BalancerID.Valid { + v4, v6 := s.nodeAddresses() + for _, a := range v4 { + req = append(req, map[string]any{"type": "A", "name": d.Fqdn, "value": a, "ttl": d.Ttl}) + } + for _, a := range v6 { + req = append(req, map[string]any{"type": "AAAA", "name": d.Fqdn, "value": a, "ttl": d.Ttl}) } } a, aaaa := d.Observed.A, d.Observed.AAAA @@ -266,10 +290,14 @@ func (s *Server) viewDomain(d *state.Domain) map[string]any { if aaaa == nil { aaaa = []string{} } + var svcID any + if sv := s.Snap.Get().DomainService(d); sv != nil { + svcID = sv.ID + } return map[string]any{ - "id": d.ID, "fqdn": d.Fqdn, "frontend_id": nullI64(d.FrontendID), "dns_provider_id": nullI64(d.DnsProviderID), - "zone": nullStr(d.Zone), "auto_records": d.AutoRecords != 0, "status": d.Status, "status_detail": nullStr(d.StatusDetail.String), - "required_records": req, "observed": map[string]any{"a": a, "aaaa": aaaa}, "checked_at": nullI64(d.CheckedAt), - "certificate_id": nullI64(d.CertificateID), "created_at": d.CreatedAt, + "id": d.ID, "fqdn": d.Fqdn, "project_id": nullI64(d.BalancerID), "service_id": svcID, "https": d.Https != 0, "url": s.domainURL(d), + "dns_provider_id": nullI64(d.DnsProviderID), "zone": nullStr(d.Zone), "auto_records": d.AutoRecords != 0, "status": d.Status, + "status_detail": nullStr(d.StatusDetail.String), "required_records": req, "observed": map[string]any{"a": a, "aaaa": aaaa}, + "checked_at": nullI64(d.CheckedAt), "certificate_id": nullI64(d.CertificateID), "created_at": d.CreatedAt, } } diff --git a/internal/app/app.go b/internal/app/app.go index 47e30d2..ff5d6ef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -146,6 +146,12 @@ func New(cfg *config.Config, version string) (*App, error) { } } a.Metrics = metrics.New(node, st.Q, a.Hub, cfg.StatsInterval) + a.Metrics.Owner = func(serviceID int64) (int64, string) { + if p := a.Snap.Get().ServiceProject(serviceID); p != nil { + return p.ID, p.Name + } + return 0, "" + } if _, err := a.Snap.Refresh(ctx, st.Q, box); err != nil { return nil, err } @@ -173,15 +179,10 @@ func New(cfg *config.Config, version string) (*App, error) { if n == nil || n.AppliedVersion < n.DesiredVersion { return false } - for _, f := range snap.Frontends { - for _, c := range f.CertIDs { - if c == id && f.Enabled != 0 { - return true - } - } - } - for _, d := range snap.Domains { - if d.CertificateID.Valid && d.CertificateID.Int64 == id && d.FrontendID.Valid { + // HTTP-01 is answered by sys_http, which exists once any managed HTTP-01 certificate or + // domain is in the applied config; DNS-01 needs no listener at all. + for _, c := range snap.Certs { + if c.ID == id { return true } } @@ -195,23 +196,12 @@ func New(cfg *config.Config, version string) (*App, error) { a.Issuer.Kick() } a.API.Notifier = notify.New(st.Q, box, a.Hub, func(e events.Event) map[string]string { - snap := a.Snap.Get() - out := map[string]string{} - if e.BackendID != nil { - if b := snap.Backend(*e.BackendID); b != nil { - for k, v := range b.Labels { - out[k] = v - } + if e.ProjectID != nil { + if p := a.Snap.Get().Project(*e.ProjectID); p != nil { + return p.Labels } } - if e.FrontendID != nil { - if f := snap.Frontend(*e.FrontendID); f != nil { - for k, v := range f.Labels { - out[k] = v - } - } - } - return out + return nil }) a.API.Drain = a.drain return a, nil @@ -287,9 +277,9 @@ func (a *App) selfSigned(ctx context.Context, key, cn, dnsName string) (string, return pem, nil } -// drain gracefully removes a backend server: it is already in drain state; wait for open -// connections to reach zero (or the timeout), then delete it (§7.6). -func (a *App) drain(backendID, serverID int64) { +// drain gracefully removes a target: it is already in drain state; wait for open connections +// on every service of the project to reach zero (or the timeout), then delete it (§7.6). +func (a *App) drain(projectID, targetID int64) { timeout := 30 * time.Second if v := os.Getenv("LOXY_DRAIN_TIMEOUT"); v != "" { if d, err := time.ParseDuration(v); err == nil { @@ -299,15 +289,17 @@ func (a *App) drain(backendID, serverID int64) { deadline := time.Now().Add(timeout) time.Sleep(1500 * time.Millisecond) // let the drain state reach HAProxy for time.Now().Before(deadline) && !a.Cfg.NoHAProxy { - b := a.Snap.Get().Backend(backendID) - if b == nil || b.Server(serverID) == nil { + p := a.Snap.Get().Project(projectID) + if p == nil || p.Target(targetID) == nil { return } busy := false - checks := a.Metrics.Checks(backendID) - for _, sl := range b.Server(serverID).Slots { - if ch, ok := checks[sl.ID]; ok && ch.Scur > 0 { - busy = true + for _, sv := range p.Services { + checks := a.Metrics.Checks(sv.ID) + for _, sl := range p.Target(targetID).Slots { + if ch, ok := checks[sl.ID]; ok && ch.Scur > 0 { + busy = true + } } } if !busy { @@ -317,7 +309,7 @@ func (a *App) drain(backendID, serverID int64) { } ctx := context.Background() err := a.Store.Tx(ctx, func(q *db.Queries) error { - if err := q.DeleteBackendServer(ctx, serverID); err != nil { + if err := q.DeleteTarget(ctx, targetID); err != nil { return err } if err := state.SyncSlots(ctx, q); err != nil { @@ -327,7 +319,7 @@ func (a *App) drain(backendID, serverID int64) { return err }) if err != nil { - slog.Warn("remove backend server", "err", err) + slog.Warn("remove target", "err", err) return } a.API.Changed(ctx) @@ -344,6 +336,7 @@ func (a *App) Run(ctx context.Context) error { } go a.Issuer.RunScheduler(ctx) go a.API.RunDomainLoop(ctx) + go a.API.RunLiveTicker(ctx) go a.API.Notifier.Run(ctx) go a.runBackups(ctx) if a.API.Logs != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 09e8520..a9e16ed 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -48,13 +48,18 @@ func TestSeedDemo(t *testing.T) { t.Fatal(err) } snap, _ := a.Snap.Refresh(context.Background(), a.Store.Q, a.Box) - if len(snap.Frontends) != 3 || len(snap.Backends) != 3 || len(snap.Backends[0].Slots()) != 3 || len(snap.Frontends[0].Routes) != 1 { - t.Fatalf("seed: %d frontends, %d backends", len(snap.Frontends), len(snap.Backends)) + if len(snap.Projects) != 2 || len(snap.Frontends) != 3 || len(snap.Projects[0].Slots()) != 3 || len(snap.DomainsOf(snap.Projects[0].ID)) != 1 { + t.Fatalf("seed: %d projects, %d services", len(snap.Projects), len(snap.Frontends)) } out, err := a.Rec.RenderLive(context.Background()) - if err != nil || !strings.Contains(out.Config(), "frontend fe_2") || !strings.Contains(out.Config(), "backend be_3") { + if err != nil { t.Fatalf("render: %v", err) } + for _, want := range []string{"frontend fe_1 ", "frontend fe_3 from loxy_tcp", "frontend sys_http", "use_backend be_1 if", "backend be_2"} { + if !strings.Contains(out.Config(), want) { + t.Fatalf("config misses %q:\n%s", want, out.Config()) + } + } } func newTestApp(t *testing.T) *App { diff --git a/internal/app/seed.go b/internal/app/seed.go index b4fcb5f..f69c786 100644 --- a/internal/app/seed.go +++ b/internal/app/seed.go @@ -11,10 +11,10 @@ import ( "github.com/2shiraz/loxy/internal/store/db" ) -// SeedDemo creates demo data (§17 `make seed`): hosts, an http backend pool fed by an IP -// and a label selector, a tcp backend, an http + https frontend with a host route, a tcp -// frontend and an uploaded certificate. serverIP is the address demo IP servers point to -// (e.g. the dev whoami host). +// SeedDemo creates demo data (§17 `make seed`): hosts, a "shop" project with an http service +// on its own port, an https service with an uploaded certificate, IP and label-selector +// targets and a demo domain, and a "db" tcp project. serverIP is the address demo IP targets +// point to (e.g. the dev whoami host). func (a *App) SeedDemo(ctx context.Context, serverIP string) error { now := time.Now().UnixMilli() c, k, err := certs.SelfSigned("demo.loxy.test", []string{"demo.loxy.test"}, 90*24*time.Hour) @@ -39,63 +39,54 @@ func (a *App) SeedDemo(ctx context.Context, serverIP string) error { if err != nil { return err } - be := func(name, mode, alg string, port int64) (db.Backend, error) { + project := func(name, alg string) (db.Balancer, error) { + return q.CreateBalancer(ctx, db.CreateBalancerParams{Name: name, Algorithm: alg, Labels: `{"env":"demo"}`, CreatedAt: now, UpdatedAt: now}) + } + service := func(b int64, proto string, listen, dest int64) (db.Service, error) { hc := "http" - if mode == "tcp" { + if proto == "tcp" { hc = "tcp" } - return q.CreateBackend(ctx, db.CreateBackendParams{Name: name, Mode: mode, Algorithm: alg, Port: port, StickyCookieName: "LOXYSTICKY", - StickyCookieLifetimeS: 300, ProxyProtocol: "none", TimeoutServerS: 60, HcProtocol: hc, HcPort: port, HcIntervalS: 15, HcTimeoutS: 10, - HcRetries: 3, HcPath: "/", HcStatusCodes: `["2??","3??"]`, Labels: `{"env":"demo"}`, CreatedAt: now}) + return q.CreateService(ctx, db.CreateServiceParams{BalancerID: b, Protocol: proto, ListenPort: listen, DestinationPort: dest, ProxyProtocol: "none", + TimeoutS: 60, StickyCookieName: "LOXYSTICKY", StickyCookieLifetimeS: 300, HcProtocol: hc, HcIntervalS: 15, HcTimeoutS: 10, HcRetries: 3, + HcPath: "/", HcStatusCodes: `["2??","3??"]`, CreatedAt: now}) } - srv := func(b int64, typ, ip, sel string) error { - _, err := q.CreateBackendServer(ctx, db.CreateBackendServerParams{BackendID: b, Type: typ, Ip: ip, LabelSelector: sel, Weight: 100, CreatedAt: now}) + target := func(b int64, typ, ip, sel string) error { + _, err := q.CreateTarget(ctx, db.CreateTargetParams{BalancerID: b, Type: typ, Ip: ip, LabelSelector: sel, Weight: 100, CreatedAt: now}) return err } - fe := func(name, mode string, tls int64, port int64, def int64) (db.Frontend, error) { - return q.CreateFrontend(ctx, db.CreateFrontendParams{Name: name, Mode: mode, Tls: tls, Binds: fmt.Sprintf(`[{"address":"*","port":%d}]`, port), - DefaultBackendID: sql.NullInt64{Int64: def, Valid: true}, TimeoutClientS: 60, Maxconn: 10000, ForwardedHeaders: b2i(mode == "http"), - Enabled: 1, Labels: `{"env":"demo"}`, CreatedAt: now, UpdatedAt: now}) - } - web, err := be("demo-web", "http", "roundrobin", 9001) + shop, err := project("shop", "roundrobin") if err != nil { return err } - if err := srv(web.ID, "ip", serverIP, ""); err != nil { - return err - } - if err := srv(web.ID, "label_selector", "", "role=web"); err != nil { + if _, err := service(shop.ID, "http", 20000, 9001); err != nil { return err } - apiBE, err := be("demo-api", "http", "leastconn", 9001) + tls, err := service(shop.ID, "https", 20001, 9001) if err != nil { return err } - if err := srv(apiBE.ID, "ip", serverIP, ""); err != nil { + if err := q.AddServiceCertificate(ctx, db.AddServiceCertificateParams{ServiceID: tls.ID, CertificateID: cert.ID}); err != nil { return err } - tcp, err := be("demo-tcp", "tcp", "leastconn", 9002) - if err != nil { + if err := target(shop.ID, "ip", serverIP, ""); err != nil { return err } - if err := srv(tcp.ID, "ip", serverIP, ""); err != nil { + if err := target(shop.ID, "label_selector", "", "role=web"); err != nil { return err } - h, err := fe("demo-http", "http", 0, 8080, web.ID) - if err != nil { + if _, err := q.CreateDomain(ctx, db.CreateDomainParams{Fqdn: "demo.loxy.test", BalancerID: sql.NullInt64{Int64: shop.ID, Valid: true}, Https: 0, + CreatedAt: now, UpdatedAt: now}); err != nil { return err } - if err := q.AddRoute(ctx, db.AddRouteParams{FrontendID: h.ID, Hosts: `["api.loxy.test"]`, BackendID: apiBE.ID}); err != nil { - return err - } - hs, err := fe("demo-https", "http", 1, 8444, web.ID) + dbp, err := project("db", "leastconn") if err != nil { return err } - if err := q.AddFrontendCertificate(ctx, db.AddFrontendCertificateParams{FrontendID: hs.ID, CertificateID: cert.ID}); err != nil { + if _, err := service(dbp.ID, "tcp", 20002, 9002); err != nil { return err } - if _, err := fe("demo-tcp", "tcp", 0, 7443, tcp.ID); err != nil { + if err := target(dbp.ID, "ip", serverIP, ""); err != nil { return err } if err := state.SyncSlots(ctx, q); err != nil { @@ -105,10 +96,3 @@ func (a *App) SeedDemo(ctx context.Context, serverIP string) error { return err }) } - -func b2i(b bool) int64 { - if b { - return 1 - } - return 0 -} diff --git a/internal/events/events.go b/internal/events/events.go index efc5c12..20fabb3 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -13,14 +13,13 @@ import ( ) type Event struct { - ID int64 `json:"id"` - TS int64 `json:"ts"` - FrontendID *int64 `json:"frontend_id"` - BackendID *int64 `json:"backend_id"` - Kind string `json:"kind"` - Severity string `json:"severity"` - Message string `json:"message"` - Data map[string]any `json:"data,omitempty"` + ID int64 `json:"id"` + TS int64 `json:"ts"` + ProjectID *int64 `json:"project_id"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Message string `json:"message"` + Data map[string]any `json:"data,omitempty"` // Ephemeral events are only streamed, not stored (e.g. config.updated). Ephemeral bool `json:"-"` } @@ -54,7 +53,7 @@ func (h *Hub) Publish(e Event) { } return sql.NullInt64{Int64: *p, Valid: true} } - id, err := h.q.CreateEvent(context.Background(), db.CreateEventParams{Ts: e.TS, FrontendID: nn(e.FrontendID), BackendID: nn(e.BackendID), + id, err := h.q.CreateEvent(context.Background(), db.CreateEventParams{Ts: e.TS, BalancerID: nn(e.ProjectID), Kind: e.Kind, Severity: e.Severity, Message: e.Message, Data: string(data)}) if err != nil { slog.Warn("store event", "err", err) @@ -66,7 +65,9 @@ func (h *Hub) Publish(e Event) { if e.ID == 0 { e.ID = -h.seq } - h.replay = append(h.replay, e) + if !e.Ephemeral { // live ticks are superseded every few seconds: never replay them + h.replay = append(h.replay, e) + } cut := time.Now().Add(-2 * time.Minute).UnixMilli() i := 0 for i < len(h.replay) && h.replay[i].TS < cut { diff --git a/internal/haproxy/render/fixture_test.go b/internal/haproxy/render/fixture_test.go index 3cb54a2..3748746 100644 --- a/internal/haproxy/render/fixture_test.go +++ b/internal/haproxy/render/fixture_test.go @@ -11,24 +11,24 @@ import ( // two HTTP backends and one TCP backend (spec §7.4 reference rendering). func fixture(certPEM, keyPEM string) ([]*state.Frontend, []*state.Backend, func(int64) *state.Cert) { cert := &state.Cert{Certificate: db.Certificate{ID: 12, CertPem: certPEM}, KeyPEM: keyPEM} - app := &state.Backend{Backend: db.Backend{ID: 4, Name: "app", Mode: "http", Algorithm: "roundrobin", Port: 9000, ProxyProtocol: "none", + app := &state.Backend{BackendCfg: state.BackendCfg{ID: 4, Name: "app", Mode: "http", Algorithm: "roundrobin", Port: 9000, ProxyProtocol: "none", TimeoutServerS: 50, HcProtocol: "http", HcPort: 9000, HcIntervalS: 15, HcTimeoutS: 13, HcRetries: 3, HcDomain: "app.example.com", HcPath: "/health", HcResponse: "OK", StickyCookieName: "LBSTICKY", StickyCookieLifetimeS: 300}, StatusCodes: []string{"2??", "3??"}} app.Servers = []*state.Server{ - {BackendServer: db.BackendServer{ID: 101, BackendID: 4, Type: "ip", Ip: "10.0.0.38", Weight: 100, AdminState: "ready"}, Slots: []*state.Slot{{ID: 101, ServerID: 101, Address: "10.0.0.38"}}}, - {BackendServer: db.BackendServer{ID: 102, BackendID: 4, Type: "host", HostID: sql.NullInt64{Int64: 5, Valid: true}, Weight: 50, AdminState: "maint", Port: sql.NullInt64{Int64: 9001, Valid: true}}, + {Target: db.Target{ID: 101, BalancerID: 4, Type: "ip", Ip: "10.0.0.38", Weight: 100, AdminState: "ready"}, Slots: []*state.Slot{{ID: 101, ServerID: 101, Address: "10.0.0.38"}}}, + {Target: db.Target{ID: 102, BalancerID: 4, Type: "host", HostID: sql.NullInt64{Int64: 5, Valid: true}, Weight: 50, AdminState: "maint", Port: sql.NullInt64{Int64: 9001, Valid: true}}, Slots: []*state.Slot{{ID: 102, ServerID: 102, HostID: 5, Address: "2001:db8::5"}}}, } - api := &state.Backend{Backend: db.Backend{ID: 5, Name: "api", Mode: "http", Algorithm: "leastconn", Port: 8080, ProxyProtocol: "none", + api := &state.Backend{BackendCfg: state.BackendCfg{ID: 5, Name: "api", Mode: "http", Algorithm: "leastconn", Port: 8080, ProxyProtocol: "none", TimeoutServerS: 60, StickyEnabled: 1, StickyCookieName: "SRV", StickyCookieLifetimeS: 600, HcProtocol: "https", HcPort: 8443, HcIntervalS: 5, HcTimeoutS: 2, HcRetries: 2, HcPath: "/"}, StatusCodes: []string{"200"}} - api.Servers = []*state.Server{{BackendServer: db.BackendServer{ID: 103, BackendID: 5, Type: "ip", Ip: "10.0.0.9", Weight: 100, Backup: 1, AdminState: "ready"}, + api.Servers = []*state.Server{{Target: db.Target{ID: 103, BalancerID: 5, Type: "ip", Ip: "10.0.0.9", Weight: 100, Backup: 1, AdminState: "ready"}, Slots: []*state.Slot{{ID: 103, ServerID: 103, Address: "10.0.0.9"}}}} - db5 := &state.Backend{Backend: db.Backend{ID: 6, Name: "postgres", Mode: "tcp", Algorithm: "source", Port: 5432, ProxyProtocol: "v2", + db5 := &state.Backend{BackendCfg: state.BackendCfg{ID: 6, Name: "postgres", Mode: "tcp", Algorithm: "source", Port: 5432, ProxyProtocol: "v2", TimeoutServerS: 3600, HcProtocol: "tcp", HcIntervalS: 15, HcTimeoutS: 10, HcRetries: 3, HcPath: "/"}, StatusCodes: []string{"2??"}} - db5.Servers = []*state.Server{{BackendServer: db.BackendServer{ID: 104, BackendID: 6, Type: "ip", Ip: "10.0.1.1", Weight: 100, AdminState: "ready"}, + db5.Servers = []*state.Server{{Target: db.Target{ID: 104, BalancerID: 6, Type: "ip", Ip: "10.0.1.1", Weight: 100, AdminState: "ready"}, Slots: []*state.Slot{{ID: 104, ServerID: 104, Address: "10.0.1.1"}}}} - web := &state.Frontend{Frontend: db.Frontend{ID: 3, Name: "web", Mode: "http", Tls: 1, HttpRedirect: 1, TimeoutClientS: 50, Maxconn: 10000, + web := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 3, Name: "web", Mode: "http", Tls: 1, TimeoutClientS: 50, Maxconn: 10000, ForwardedHeaders: 1, Enabled: 1, DefaultBackendID: sql.NullInt64{Int64: 4, Valid: true}}, Binds: []state.Bind{{Address: "203.0.113.10", Port: 443}, {Address: "2001:db8::1", Port: 443}}, CertIDs: []int64{12}, @@ -36,7 +36,7 @@ func fixture(certPEM, keyPEM string) ([]*state.Frontend, []*state.Backend, func( {Hosts: []string{"api.example.com", "*.api.example.com"}, PathPrefix: "/v1", BackendID: 5}, {PathPrefix: "/api", BackendID: 5}, }} - pg := &state.Frontend{Frontend: db.Frontend{ID: 7, Name: "postgres", Mode: "tcp", TimeoutClientS: 3600, Enabled: 1, Maintenance: 1, + pg := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 7, Name: "postgres", Mode: "tcp", TimeoutClientS: 3600, Enabled: 1, Maintenance: 1, DefaultBackendID: sql.NullInt64{Int64: 6, Valid: true}}, Binds: []state.Bind{{Address: "*", Port: 5432}}} certs := func(id int64) *state.Cert { if id == 12 { diff --git a/internal/haproxy/render/fuzz_test.go b/internal/haproxy/render/fuzz_test.go index 284ee75..c56aedc 100644 --- a/internal/haproxy/render/fuzz_test.go +++ b/internal/haproxy/render/fuzz_test.go @@ -25,12 +25,12 @@ func FuzzUserValues(f *testing.F) { return } mk := func(path, resp, domain, cookie, host, prefix string) Input { - be := &state.Backend{Backend: db.Backend{ID: 1, Mode: "http", Algorithm: "roundrobin", Port: 80, ProxyProtocol: "none", TimeoutServerS: 60, + be := &state.Backend{BackendCfg: state.BackendCfg{ID: 1, Mode: "http", Algorithm: "roundrobin", Port: 80, ProxyProtocol: "none", TimeoutServerS: 60, StickyEnabled: 1, StickyCookieName: cookie, StickyCookieLifetimeS: 300, HcProtocol: "http", HcPort: 80, HcIntervalS: 15, HcTimeoutS: 10, HcRetries: 3, HcPath: path, HcResponse: resp, HcDomain: domain}, StatusCodes: []string{code}} - be.Servers = []*state.Server{{BackendServer: db.BackendServer{ID: 1, Type: "ip", Ip: "10.0.0.1", Weight: 100, AdminState: "ready"}, + be.Servers = []*state.Server{{Target: db.Target{ID: 1, Type: "ip", Ip: "10.0.0.1", Weight: 100, AdminState: "ready"}, Slots: []*state.Slot{{ID: 1, ServerID: 1, Address: "10.0.0.1"}}}} - fe := &state.Frontend{Frontend: db.Frontend{ID: 1, Mode: "http", TimeoutClientS: 60, ForwardedHeaders: 1, Enabled: 1, + fe := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 1, Mode: "http", TimeoutClientS: 60, ForwardedHeaders: 1, Enabled: 1, DefaultBackendID: sql.NullInt64{Int64: 1, Valid: true}}, Binds: []state.Bind{{Address: "*", Port: 80}}, Routes: []state.Route{{Hosts: []string{host}, PathPrefix: prefix, BackendID: 1}}} return Input{Root: "/l", Frontends: []*state.Frontend{fe}, Backends: []*state.Backend{be}} diff --git a/internal/haproxy/render/render.go b/internal/haproxy/render/render.go index f8eccfd..3273123 100644 --- a/internal/haproxy/render/render.go +++ b/internal/haproxy/render/render.go @@ -9,6 +9,7 @@ import ( "fmt" "net/netip" "path" + "slices" "sort" "strconv" "strings" @@ -41,12 +42,15 @@ type Input struct { Frontends []*state.Frontend Backends []*state.Backend Cert func(id int64) *state.Cert - // ACMEHTTP01 lists frontend ids that need HTTP-01 challenge answering on :80. - ACMEHTTP01 map[int64]bool PanelPEM string PlaceholderPEM string MaintPage string OmitServers bool // skeleton mode used for change classification + // Domains are routed by Host on the shared sys_http (:80) / sys_https (:443) frontends. + Domains []DomainRoute + // SharedHTTP renders sys_http even without domains (managed HTTP-01 certificates are + // validated through it). + SharedHTTP bool // IPv6 adds an `ipv6@:::port v6only` listener for "*" binds. HAProxy refuses to start // when the host has no IPv6 support, so this follows a startup probe (LOXY_IPV6). IPv6 bool @@ -109,8 +113,6 @@ func defaultsIn(in *Input) { // Names (§7.3). Frontend and backend names always differ (HAProxy 3.3+ requirement). func FrontendName(id int64) string { return fmt.Sprintf("fe_%d", id) } -func RedirectName(id int64) string { return fmt.Sprintf("fe_%d_rd", id) } -func ACMEFrontend(id int64) string { return fmt.Sprintf("sys_acme_fe%d", id) } func BackendName(id int64) string { return fmt.Sprintf("be_%d", id) } func ServerName(sv *state.Server, sl *state.Slot) string { if sl.HostID == 0 { @@ -207,19 +209,6 @@ func ServerArgs(be *state.Backend, sv *state.Server, sl *state.Slot) string { return b.String() } -// HasPort80 reports whether a frontend already listens on :80 itself (or via its redirect). -func HasPort80(f *state.Frontend) bool { - if f.Mode == "http" && f.Tls != 0 && f.HttpRedirect != 0 { - return true - } - for _, b := range f.Binds { - if b.Port == 80 && f.Mode == "http" && f.Tls == 0 { - return true - } - } - return false -} - // Render produces the full tree. func Render(in Input) (*Output, error) { defaultsIn(&in) @@ -306,9 +295,16 @@ func Render(in Input) (*Output, error) { } c.Blocks = append(c.Blocks, blk) } + if len(in.Domains) > 0 || in.SharedHTTP { + blk, err := renderDomains(&in, usedCerts, out) + if err != nil { + return nil, err + } + c.Blocks = append(c.Blocks, blk) + } bes := append([]*state.Backend(nil), in.Backends...) sort.Slice(bes, func(i, j int) bool { return bes[i].ID < bes[j].ID }) - tlsOnly := tlsOnlyBackends(in.Frontends) + tlsOnly := tlsOnlyBackends(in.Frontends, in.Domains) for _, b := range bes { sec, err := renderBackend(&in, b, tlsOnly) if err != nil { @@ -340,6 +336,109 @@ func Render(in Input) (*Output, error) { return out, nil } +// DomainRoute sends one hostname arriving on the shared :80/:443 to a backend. +type DomainRoute struct { + Host string // fqdn or *.suffix (validated again here) + BackendID int64 + CertID int64 // certificate served on :443 for this host (0 = none yet) + HTTPS bool // certificate issued and HTTPS wanted: :80 redirects to :443 +} + +// renderDomains builds sys_http (:80: ACME answers, https redirects, Host routing) and, when +// any domain has a certificate, sys_https (:443). Unknown hosts get 404. +func renderDomains(in *Input, usedCerts map[int64]bool, out *Output) (block, error) { + blk := block{Comment: "# ---------- domains (shared :80/:443) ----------"} + doms := append([]DomainRoute(nil), in.Domains...) + sort.Slice(doms, func(i, j int) bool { return doms[i].Host < doms[j].Host }) + var all, redirect []string + byBackend := map[int64][]string{} + var backends []int64 + var crt []string + for _, d := range doms { + h := strings.ToLower(strings.TrimSpace(d.Host)) + if !model.ValidHostname(h, true) { + return blk, fmt.Errorf("invalid domain %q", d.Host) + } + all = append(all, h) + if _, ok := byBackend[d.BackendID]; !ok { + backends = append(backends, d.BackendID) + } + byBackend[d.BackendID] = append(byBackend[d.BackendID], h) + if d.CertID != 0 { + if c := in.Cert(d.CertID); c != nil && c.PEM() != "" { + if line := fmt.Sprintf("%d.pem [alpn h2,http/1.1]", d.CertID); !slices.Contains(crt, line) { + crt = append(crt, line) + } + usedCerts[d.CertID] = true + if d.HTTPS { + redirect = append(redirect, h) + } + } + } + } + routes := func(sec *section) error { + if len(all) > 0 { + cond, err := routeCond(state.Route{Hosts: all}) + if err != nil { + return err + } + sec.Lines = append(sec.Lines, `http-request return status 404 content-type text/plain string "Unknown host" unless `+cond) + } else { + sec.Lines = append(sec.Lines, `http-request return status 404 content-type text/plain string "Unknown host"`) + } + // Exact hosts first so that a wildcard never shadows a more specific domain. + for _, wildcard := range []bool{false, true} { + for _, id := range backends { + var hs []string + for _, h := range byBackend[id] { + if strings.HasPrefix(h, "*.") == wildcard { + hs = append(hs, h) + } + } + if len(hs) == 0 { + continue + } + cond, err := routeCond(state.Route{Hosts: hs}) + if err != nil { + return err + } + sec.Lines = append(sec.Lines, fmt.Sprintf("use_backend %s if %s", BackendName(id), cond)) + } + } + return nil + } + h := section{Header: "frontend sys_http from loxy_http"} + for _, b := range bindAddrs(in, "*", 80) { + h.Lines = append(h.Lines, "bind "+b) + } + h.Lines = append(h.Lines, acmeLine) + if len(redirect) > 0 { + cond, err := routeCond(state.Route{Hosts: redirect}) + if err != nil { + return blk, err + } + h.Lines = append(h.Lines, "http-request redirect scheme https code 301 if "+cond) + } + h.Lines = append(h.Lines, "option forwardfor", "http-request set-header X-Forwarded-Proto http") + if err := routes(&h); err != nil { + return blk, err + } + blk.Sections = append(blk.Sections, h) + if len(crt) > 0 { + out.Files["crt-lists/sys_https.lst"] = []byte(strings.Join(crt, "\n") + "\n") + t := section{Header: "frontend sys_https from loxy_http"} + for _, b := range bindAddrs(in, "*", 443) { + t.Lines = append(t.Lines, "bind "+b+" ssl crt-list "+path.Join(in.Root, "crt-lists", "sys_https.lst")+" alpn h2,http/1.1") + } + t.Lines = append(t.Lines, "option forwardfor", "http-request set-header X-Forwarded-Proto https") + if err := routes(&t); err != nil { + return blk, err + } + blk.Sections = append(blk.Sections, t) + } + return blk, nil +} + const acmeLine = `http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/),map(virt@acme_http01)]" if { path_beg /.well-known/acme-challenge/ } { path,field(-1,/),map(virt@acme_http01) -m found }` // routeCond builds the ACL condition of a routing rule: (exact hosts) / (suffix hosts) AND path prefix. @@ -424,16 +523,6 @@ func renderFrontend(in *Input, f *state.Frontend, usedCerts map[int64]bool, need "http-request set-header X-Forwarded-Proto "+proto, "http-request set-header X-Forwarded-Port %[dst_port]") } - if !tls { - for _, b := range f.Binds { - if b.Port == 80 { - fe.Lines = append(fe.Lines, acmeLine) - break - } - } - } - fe.Lines = append(fe.Lines, fmt.Sprintf("http-request return status 503 content-type text/html file %s if { str(%d),map(opt@%s) -m found }", - path.Join(in.Root, "errors", "maint.http"), f.ID, maintMap)) for _, r := range f.Routes { cond, err := routeCond(r) if err != nil { @@ -462,63 +551,13 @@ func renderFrontend(in *Input, f *state.Frontend, usedCerts map[int64]bool, need lst = append(lst, "_placeholder.pem [alpn h2,http/1.1]") } out.Files[fmt.Sprintf("crt-lists/fe%d.lst", f.ID)] = []byte(strings.Join(lst, "\n") + "\n") - if f.HttpRedirect != 0 { - rd := section{Header: fmt.Sprintf("frontend %s from loxy_http", RedirectName(f.ID))} - for _, a := range addrs(f) { - for _, b := range bindAddrs(in, a, 80) { - rd.Lines = append(rd.Lines, "bind "+b) - } - } - rd.Lines = append(rd.Lines, acmeLine, "http-request redirect scheme https code 301") - blk.Sections = append(blk.Sections, rd) - } - } - if in.ACMEHTTP01[f.ID] && !HasPort80(f) { - ac := section{Header: fmt.Sprintf("frontend %s from loxy_http", ACMEFrontend(f.ID))} - for _, a := range addrs(f) { - for _, b := range bindAddrs(in, a, 80) { - ac.Lines = append(ac.Lines, "bind "+b) - } - } - ac.Lines = append(ac.Lines, acmeLine, "http-request return status 404") - blk.Sections = append(blk.Sections, ac) } return blk, nil } -// addrs returns the distinct bind addresses of a frontend (sorted). -func addrs(f *state.Frontend) []string { - seen := map[string]bool{} - var out []string - for _, b := range f.Binds { - a := b.Address - if a == "" { - a = "*" - } - if !seen[a] { - seen[a] = true - out = append(out, a) - } - } - sort.Strings(out) - return out -} - -// Ports80 returns the "address:80" listeners a frontend occupies implicitly (redirect/ACME). -func Ports80(f *state.Frontend, acme bool) []string { - if f.Enabled == 0 { - return nil - } - implicit := (f.Mode == "http" && f.Tls != 0 && f.HttpRedirect != 0) || (acme && !HasPort80(f)) - if !implicit { - return nil - } - return addrs(f) -} - // tlsOnlyBackends returns the backends that are used, and only by enabled frontends that // terminate TLS: their sticky cookie can be marked secure (§7.5). -func tlsOnlyBackends(fes []*state.Frontend) map[int64]bool { +func tlsOnlyBackends(fes []*state.Frontend, domains []DomainRoute) map[int64]bool { out := map[int64]bool{} for _, f := range fes { if f.Enabled == 0 { @@ -531,6 +570,12 @@ func tlsOnlyBackends(fes []*state.Frontend) map[int64]bool { } } } + // Plain-HTTP domain traffic (no issued certificate or HTTPS off) reaches the backend too. + for _, d := range domains { + if !d.HTTPS { + out[d.BackendID] = false + } + } return out } @@ -541,6 +586,13 @@ func renderBackend(in *Input, b *state.Backend, tlsOnly map[int64]bool) (section } be := section{Header: fmt.Sprintf("backend %s from %s", BackendName(b.ID), mode)} be.Lines = append(be.Lines, "balance "+b.Algorithm) + if b.Mode == "http" { + // Maintenance lives in the backend so it covers every way in: the service port (fe_) + // and domains (sys_http/sys_https). Same map key as the service id. + maintMap := MaintMapPath(in.Root) + be.Lines = append(be.Lines, fmt.Sprintf("http-request return status 503 content-type text/html file %s if { str(%d),map(opt@%s) -m found }", + path.Join(in.Root, "errors", "maint.http"), b.ID, maintMap)) + } if b.TimeoutServerS != 60 { be.Lines = append(be.Lines, fmt.Sprintf("timeout server %ds", b.TimeoutServerS)) } @@ -602,7 +654,7 @@ func renderBackend(in *Input, b *state.Backend, tlsOnly map[int64]bool) (section // FrontendSections extracts the config of one frontend and the backends it uses (GET /config). func FrontendSections(cfgText string, f *state.Frontend) string { - names := map[string]bool{FrontendName(f.ID): true, RedirectName(f.ID): true, ACMEFrontend(f.ID): true} + names := map[string]bool{FrontendName(f.ID): true} for _, id := range f.BackendIDs() { names[BackendName(id)] = true } diff --git a/internal/haproxy/render/render_test.go b/internal/haproxy/render/render_test.go index 32565dc..25d606c 100644 --- a/internal/haproxy/render/render_test.go +++ b/internal/haproxy/render/render_test.go @@ -13,7 +13,6 @@ import ( "github.com/2shiraz/loxy/internal/certs" "github.com/2shiraz/loxy/internal/haproxy/validate" "github.com/2shiraz/loxy/internal/state" - "github.com/2shiraz/loxy/internal/store/db" "github.com/2shiraz/loxy/internal/testutil" ) @@ -27,7 +26,9 @@ func input(t *testing.T, root string) Input { fes, bes, cf := fixture(c, k) return Input{ Root: root, NodeID: 1, Revision: 123, DesiredVersion: 456, PanelPort: 8443, PanelAllow: []string{"10.0.0.0/8", "192.0.2.1"}, - Promex: true, Frontends: fes, Backends: bes, Cert: cf, PanelPEM: c + k, PlaceholderPEM: c + k, ACMEHTTP01: map[int64]bool{7: true}, IPv6: true, + Promex: true, Frontends: fes, Backends: bes, Cert: cf, PanelPEM: c + k, PlaceholderPEM: c + k, IPv6: true, + Domains: []DomainRoute{{Host: "shop.example.com", BackendID: 4, CertID: 12, HTTPS: true}, {Host: "*.api.example.com", BackendID: 5}, + {Host: "beta.api.example.com", BackendID: 4}}, } } @@ -93,12 +94,16 @@ func WriteTree(t testing.TB, dir string, out *Output) { func TestSecureCookieOnlyWhenAllFrontendsTLS(t *testing.T) { be := func(id int64) sql.NullInt64 { return sql.NullInt64{Int64: id, Valid: true} } - tls := &state.Frontend{Frontend: db.Frontend{ID: 1, Mode: "http", Tls: 1, Enabled: 1, DefaultBackendID: be(5)}} - plain := &state.Frontend{Frontend: db.Frontend{ID: 2, Mode: "http", Enabled: 1, DefaultBackendID: be(6)}, Routes: []state.Route{{BackendID: 5}}} - off := &state.Frontend{Frontend: db.Frontend{ID: 3, Mode: "http", Enabled: 0, DefaultBackendID: be(7)}} - tlsOnly := &state.Frontend{Frontend: db.Frontend{ID: 4, Mode: "http", Tls: 1, Enabled: 1, DefaultBackendID: be(7)}} - got := tlsOnlyBackends([]*state.Frontend{tls, plain, off, tlsOnly}) + tls := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 1, Mode: "http", Tls: 1, Enabled: 1, DefaultBackendID: be(5)}} + plain := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 2, Mode: "http", Enabled: 1, DefaultBackendID: be(6)}, Routes: []state.Route{{BackendID: 5}}} + off := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 3, Mode: "http", Enabled: 0, DefaultBackendID: be(7)}} + tlsOnly := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 4, Mode: "http", Tls: 1, Enabled: 1, DefaultBackendID: be(7)}} + got := tlsOnlyBackends([]*state.Frontend{tls, plain, off, tlsOnly}, nil) if got[5] || got[6] || !got[7] { t.Fatalf("tlsOnly = %v (5: shared with plain HTTP, 6: plain only, 7: TLS + disabled plain)", got) } + // a plain-HTTP domain (no certificate yet) also reaches backend 7: no secure cookie + if got = tlsOnlyBackends([]*state.Frontend{tlsOnly}, []DomainRoute{{Host: "a.example.com", BackendID: 7}}); got[7] { + t.Fatalf("plain domain kept secure: %v", got) + } } diff --git a/internal/haproxy/render/testdata/reference.cfg b/internal/haproxy/render/testdata/reference.cfg index 0ecac33..e2275e5 100644 --- a/internal/haproxy/render/testdata/reference.cfg +++ b/internal/haproxy/render/testdata/reference.cfg @@ -70,17 +70,10 @@ frontend fe_3 from loxy_http option forwardfor http-request set-header X-Forwarded-Proto https http-request set-header X-Forwarded-Port %[dst_port] - http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(3),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } use_backend be_5 if { req.hdr(host),field(1,:),lower -m str api.example.com } { path_beg /v1 } || { req.hdr(host),field(1,:),lower -m end .api.example.com } { path_beg /v1 } use_backend be_5 if { path_beg /api } default_backend be_4 -frontend fe_3_rd from loxy_http - bind ipv6@2001:db8::1:80 - bind 203.0.113.10:80 - http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/),map(virt@acme_http01)]" if { path_beg /.well-known/acme-challenge/ } { path,field(-1,/),map(virt@acme_http01) -m found } - http-request redirect scheme https code 301 - # ---------- frontend 7 ---------- frontend fe_7 from loxy_tcp bind 0.0.0.0:5432 @@ -89,15 +82,31 @@ frontend fe_7 from loxy_tcp tcp-request connection reject if { str(7),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } default_backend be_6 -frontend sys_acme_fe7 from loxy_http +# ---------- domains (shared :80/:443) ---------- +frontend sys_http from loxy_http bind 0.0.0.0:80 bind ipv6@:::80 v6only http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/),map(virt@acme_http01)]" if { path_beg /.well-known/acme-challenge/ } { path,field(-1,/),map(virt@acme_http01) -m found } - http-request return status 404 + http-request redirect scheme https code 301 if { req.hdr(host),field(1,:),lower -m str shop.example.com } + option forwardfor + http-request set-header X-Forwarded-Proto http + http-request return status 404 content-type text/plain string "Unknown host" unless { req.hdr(host),field(1,:),lower -m str beta.api.example.com shop.example.com } || { req.hdr(host),field(1,:),lower -m end .api.example.com } + use_backend be_4 if { req.hdr(host),field(1,:),lower -m str beta.api.example.com shop.example.com } + use_backend be_5 if { req.hdr(host),field(1,:),lower -m end .api.example.com } + +frontend sys_https from loxy_http + bind 0.0.0.0:443 ssl crt-list /usr/local/etc/haproxy/live/crt-lists/sys_https.lst alpn h2,http/1.1 + bind ipv6@:::443 v6only ssl crt-list /usr/local/etc/haproxy/live/crt-lists/sys_https.lst alpn h2,http/1.1 + option forwardfor + http-request set-header X-Forwarded-Proto https + http-request return status 404 content-type text/plain string "Unknown host" unless { req.hdr(host),field(1,:),lower -m str beta.api.example.com shop.example.com } || { req.hdr(host),field(1,:),lower -m end .api.example.com } + use_backend be_4 if { req.hdr(host),field(1,:),lower -m str beta.api.example.com shop.example.com } + use_backend be_5 if { req.hdr(host),field(1,:),lower -m end .api.example.com } # ---------- backend 4 ---------- backend be_4 from loxy_http balance roundrobin + http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(4),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } timeout server 50s option httpchk http-check send meth GET uri /health ver HTTP/1.1 hdr Host app.example.com @@ -110,7 +119,8 @@ backend be_4 from loxy_http # ---------- backend 5 ---------- backend be_5 from loxy_http balance leastconn - cookie SRV insert indirect nocache httponly secure maxlife 600s + http-request return status 503 content-type text/html file /usr/local/etc/haproxy/live/errors/maint.http if { str(5),map(opt@/usr/local/etc/haproxy/live/maps/maint.map) -m found } + cookie SRV insert indirect nocache httponly maxlife 600s option httpchk http-check send meth GET uri / http-check expect status 200 diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index a5bb146..a4db307 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -73,6 +73,8 @@ type Scope string const ( FE Scope = "fe" BE Scope = "be" + // PR is a project: the sum of its services' backends (covers port and domain traffic). + PR Scope = "pr" ) type key struct { @@ -100,6 +102,8 @@ type Collector struct { Q *db.Queries Hub *events.Hub Interval time.Duration + // Owner maps a service id (backend be_) to its project id and name for events. + Owner func(serviceID int64) (int64, string) mu sync.RWMutex rings map[key]*ring @@ -185,7 +189,15 @@ func (c *Collector) poll(ctx context.Context, now time.Time) error { case 0: id, ok := parseName(r.PxName, "fe_") if !ok { - continue + // Shared domain frontends count towards the node total only. + switch r.PxName { + case "sys_http": + id = -80 + case "sys_https": + id = -443 + default: + continue + } } k := key{FE, id} cc := counters{connTot: float64(r.Int("conn_tot")), reqTot: float64(r.Int("req_tot")), bin: float64(r.Int("bin")), bout: float64(r.Int("bout")), @@ -204,7 +216,11 @@ func (c *Collector) poll(ctx context.Context, now time.Time) error { continue } k := key{BE, id} - cur[k] = counters{connTot: float64(r.Int("stot")), reqTot: float64(r.Int("stot")), bin: float64(r.Int("bin")), bout: float64(r.Int("bout")), + req := r.Int("req_tot") // http backends; tcp backends count sessions + if req == 0 { + req = r.Int("stot") + } + cur[k] = counters{connTot: float64(r.Int("stot")), reqTot: float64(req), bin: float64(r.Int("bin")), bout: float64(r.Int("bout")), h2: float64(r.Int("hrsp_2xx")), h3: float64(r.Int("hrsp_3xx")), h4: float64(r.Int("hrsp_4xx")), h5: float64(r.Int("hrsp_5xx")), errs: float64(r.Int("econ") + r.Int("eresp"))} p := get(k) @@ -272,10 +288,40 @@ func (c *Collector) poll(ctx context.Context, now time.Time) error { total.Errors += p.Errors } c.addPoint(k, *p) - if k.scope == FE { - for ch := range c.subs[k.id] { + } + if c.Owner != nil { + agg := map[int64]*Point{} + for k, p := range pts { + if k.scope != BE { + continue + } + pid, _ := c.Owner(k.id) + if pid == 0 { + continue + } + a := agg[pid] + if a == nil { + a = &Point{TS: ts} + agg[pid] = a + } + a.Open += p.Open + a.CPS += p.CPS + a.RPS += p.RPS + a.In += p.In + a.Out += p.Out + a.H2 += p.H2 + a.H3 += p.H3 + a.H4 += p.H4 + a.H5 += p.H5 + a.Errors += p.Errors + a.Queue += p.Queue + a.RespMS = max(a.RespMS, p.RespMS) + } + for pid, a := range agg { + c.addPoint(key{PR, pid}, *a) + for ch := range c.subs[pid] { select { - case ch <- *p: + case ch <- *a: default: } } @@ -303,9 +349,16 @@ func (c *Collector) poll(ctx context.Context, now time.Time) error { if ch.Status == "unhealthy" { sev, msg = "warning", "is unhealthy: "+ch.Detail } - c.Hub.Publish(events.Event{Kind: "server.health.changed", BackendID: events.I64(ch.BackendID), Severity: sev, - Message: fmt.Sprintf("Server %d of backend %d %s", ch.SlotID, ch.BackendID, msg), - Data: map[string]any{"backend_id": ch.BackendID, "slot_id": ch.SlotID, "status": ch.Status}}) + var project *int64 + name := fmt.Sprintf("service %d", ch.BackendID) + if c.Owner != nil { + if id, pname := c.Owner(ch.BackendID); id != 0 { + project, name = events.I64(id), pname + } + } + c.Hub.Publish(events.Event{Kind: "target.health.changed", ProjectID: project, Severity: sev, + Message: fmt.Sprintf("Target #%d of %s %s", ch.SlotID, name, msg), + Data: map[string]any{"service_id": ch.BackendID, "slot_id": ch.SlotID, "status": ch.Status}}) } if flush != nil && c.Q != nil { c.rollup(ctx, flush) @@ -367,18 +420,18 @@ func (c *Collector) rollup(ctx context.Context, m map[key][]Point) { _ = c.Q.PurgeMetrics1m(ctx, time.Now().Add(-35*24*time.Hour).Unix()) } -// Subscribe streams frontend-level points (1 s) for SSE. -func (c *Collector) Subscribe(fe int64) (<-chan Point, func()) { +// Subscribe streams project-level points (1 s) for SSE. +func (c *Collector) Subscribe(project int64) (<-chan Point, func()) { ch := make(chan Point, 16) c.mu.Lock() - if c.subs[fe] == nil { - c.subs[fe] = map[chan Point]struct{}{} + if c.subs[project] == nil { + c.subs[project] = map[chan Point]struct{}{} } - c.subs[fe][ch] = struct{}{} + c.subs[project][ch] = struct{}{} c.mu.Unlock() return ch, func() { c.mu.Lock() - delete(c.subs[fe], ch) + delete(c.subs[project], ch) c.mu.Unlock() } } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 16f76d3..452d5bb 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -152,10 +152,8 @@ func (n *Notifier) handle(ctx context.Context, e events.Event) { if n.Labels != nil { labels = n.Labels(e) } - if e.FrontendID != nil { - obj = *e.FrontendID - } else if e.BackendID != nil { - obj = -*e.BackendID + if e.ProjectID != nil { + obj = *e.ProjectID } for _, r := range rules { if !Matches(r, e, labels) { diff --git a/internal/reconcile/classify_test.go b/internal/reconcile/classify_test.go index 001489b..1ac4fe7 100644 --- a/internal/reconcile/classify_test.go +++ b/internal/reconcile/classify_test.go @@ -18,17 +18,17 @@ type srvSpec struct { } func mk(alg string, maint bool, port int64, servers ...srvSpec) *state.Snapshot { - be := &state.Backend{Backend: db.Backend{ID: 1, NodeID: 1, Mode: "http", Algorithm: alg, Port: port, ProxyProtocol: "none", TimeoutServerS: 60, + be := &state.Backend{BackendCfg: state.BackendCfg{ID: 1, NodeID: 1, Mode: "http", Algorithm: alg, Port: port, ProxyProtocol: "none", TimeoutServerS: 60, HcProtocol: "tcp", HcIntervalS: 15, HcTimeoutS: 10, HcRetries: 3, HcPath: "/"}, StatusCodes: []string{"2??"}} for _, s := range servers { - be.Servers = append(be.Servers, &state.Server{BackendServer: db.BackendServer{ID: s.id, BackendID: 1, Type: "ip", Ip: s.ip, Weight: s.weight, AdminState: s.state}, + be.Servers = append(be.Servers, &state.Server{Target: db.Target{ID: s.id, BalancerID: 1, Type: "ip", Ip: s.ip, Weight: s.weight, AdminState: s.state}, Slots: []*state.Slot{{ID: s.id, ServerID: s.id, Address: s.ip}}}) } m := int64(0) if maint { m = 1 } - fe := &state.Frontend{Frontend: db.Frontend{ID: 1, NodeID: 1, Mode: "http", TimeoutClientS: 60, ForwardedHeaders: 1, Enabled: 1, Maintenance: m, + fe := &state.Frontend{FrontendCfg: state.FrontendCfg{ID: 1, NodeID: 1, Mode: "http", TimeoutClientS: 60, ForwardedHeaders: 1, Enabled: 1, Maintenance: m, DefaultBackendID: sql.NullInt64{Int64: 1, Valid: true}}, Binds: []state.Bind{{Address: "*", Port: 8080}}} return state.TestSnapshot([]*state.Frontend{fe}, []*state.Backend{be}) } diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go index 7ee9911..1feedda 100644 --- a/internal/reconcile/reconcile.go +++ b/internal/reconcile/reconcile.go @@ -121,29 +121,32 @@ func (r *Reconciler) Run(ctx context.Context) { } } -// ACMEFrontends returns frontend ids that must answer HTTP-01 challenges on :80 -// (managed HTTP-01 certificates attached, or domains attached). -func ACMEFrontends(snap *state.Snapshot) map[int64]bool { - acme := map[int64]bool{} - for _, f := range snap.Frontends { - if NeedsACME(snap, f.ID, f.CertIDs) { - acme[f.ID] = true +// DomainRoutes lists the domains routed on the shared :80/:443 frontends. A domain needs a +// project with an http/https service; it redirects to HTTPS once its certificate is issued. +func DomainRoutes(snap *state.Snapshot) []render.DomainRoute { + var out []render.DomainRoute + for _, d := range snap.Domains { + sv := snap.DomainService(d) + if sv == nil { + continue + } + dr := render.DomainRoute{Host: d.Fqdn, BackendID: sv.ID} + if d.CertificateID.Valid { + if c := snap.Cert(d.CertificateID.Int64); c != nil && c.PEM() != "" { + dr.CertID = c.ID + dr.HTTPS = d.Https != 0 + } } + out = append(out, dr) } - return acme + return out } -// NeedsACME reports whether frontend id (0 = not created yet) with the given certificates -// answers HTTP-01 challenges on :80: it has a domain or a managed http01 certificate. -// The API's listener-collision checks use the same rule as the renderer. -func NeedsACME(snap *state.Snapshot, id int64, certIDs []int64) bool { - for _, d := range snap.Domains { - if id != 0 && d.FrontendID.Valid && d.FrontendID.Int64 == id { - return true - } - } - for _, cid := range certIDs { - if c := snap.Cert(cid); c != nil && c.Type == "managed" && c.Challenge == "http01" { +// SharedHTTP reports whether sys_http must listen on :80 without domains: managed HTTP-01 +// certificates are validated through it. +func SharedHTTP(snap *state.Snapshot) bool { + for _, c := range snap.Certs { + if c.Type == "managed" && c.Challenge == "http01" { return true } } @@ -156,7 +159,7 @@ func (r *Reconciler) Input(snap *state.Snapshot, root string, rev, desired int64 Root: root, NodeID: r.Opt.NodeID, Revision: rev, DesiredVersion: desired, PanelPort: r.Opt.PanelPort, PanelAllow: r.Opt.PanelAllow, LoxyAddr: r.Opt.LoxyAddr, Promex: r.promex, StateFile: filepath.Join(r.Opt.StateDir, "server-state"), SocketPath: r.Opt.SocketPath, GlobalMaxConn: r.Opt.GlobalMaxConn, - Frontends: snap.FrontendsOnNode(r.Opt.NodeID), Backends: snap.BackendsOnNode(r.Opt.NodeID), Cert: snap.Cert, ACMEHTTP01: ACMEFrontends(snap), + Frontends: snap.FrontendsOnNode(r.Opt.NodeID), Backends: snap.BackendsOnNode(r.Opt.NodeID), Cert: snap.Cert, Domains: DomainRoutes(snap), SharedHTTP: SharedHTTP(snap), PanelPEM: r.Opt.PanelPEM, PlaceholderPEM: r.Opt.PlaceholderPEM, IPv6: r.Opt.IPv6, } } @@ -366,9 +369,9 @@ func (r *Reconciler) succeed(ctx context.Context, snap *state.Snapshot, desired q := r.Store.Q _ = q.SetAppliedVersion(ctx, db.SetAppliedVersionParams{AppliedVersion: desired, ID: r.Opt.NodeID}) changed := false - for _, f := range snap.FrontendsOnNode(r.Opt.NodeID) { - if f.Status != "active" || f.LastError.Valid { - _ = q.SetFrontendStatus(ctx, db.SetFrontendStatusParams{Status: "active", ID: f.ID}) + for _, p := range snap.Projects { + if p.NodeID == r.Opt.NodeID && (p.Status != "ready" || p.LastError.Valid) { + _ = q.SetBalancerStatus(ctx, db.SetBalancerStatusParams{Status: "ready", ID: p.ID}) changed = true } } @@ -384,8 +387,10 @@ func (r *Reconciler) fail(ctx context.Context, snap *state.Snapshot, rev, desire StateJson: "{}", CreatedBy: by, CreatedAt: time.Now().UnixMilli()}) _ = os.RemoveAll(r.FS.RevDir(rev)) Stats.record(kind, "failed", time.Since(start).Milliseconds()) - for _, f := range snap.FrontendsOnNode(r.Opt.NodeID) { - _ = q.SetFrontendStatus(ctx, db.SetFrontendStatusParams{Status: "apply_error", LastError: sql.NullString{String: truncate(msg, 4000), Valid: true}, ID: f.ID}) + for _, p := range snap.Projects { + if p.NodeID == r.Opt.NodeID { + _ = q.SetBalancerStatus(ctx, db.SetBalancerStatusParams{Status: "error", LastError: sql.NullString{String: truncate(msg, 4000), Valid: true}, ID: p.ID}) + } } r.Hub.Publish(events.Event{Kind: "apply.failed", Severity: "error", Message: "Apply failed: " + firstLine(msg)}) return fmt.Errorf("apply rev %d failed: %s", rev, firstLine(msg)) diff --git a/internal/state/state.go b/internal/state/state.go index e33e6c8..2daa3d2 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -56,13 +56,14 @@ type Slot struct { Address string } -// Server is a configured backend server (IP, host or label selector) with its resolved slots. +// Server is one target of a project as rendered into a service's backend, with its +// resolved slots. The same targets are shared by all services of a project. type Server struct { - db.BackendServer + db.Target Slots []*Slot } -// Port returns the effective port of the server. +// PortOr returns the target's port override or def (the service destination port). func (s *Server) PortOr(def int64) int64 { if s.Port.Valid && s.Port.Int64 > 0 { return s.Port.Int64 @@ -70,8 +71,33 @@ func (s *Server) PortOr(def int64) int64 { return def } +// BackendCfg is the HAProxy backend a service compiles to (`be_`). +type BackendCfg struct { + ID int64 + BalancerID int64 + NodeID int64 + Name string + Mode string + Algorithm string + Port int64 + StickyEnabled int64 + StickyCookieName string + StickyCookieLifetimeS int64 + ProxyProtocol string + TimeoutServerS int64 + HcProtocol string + HcPort int64 + HcIntervalS int64 + HcTimeoutS int64 + HcRetries int64 + HcDomain string + HcPath string + HcResponse string + HcTlsVerify int64 +} + type Backend struct { - db.Backend + BackendCfg Labels map[string]string StatusCodes []string Servers []*Server @@ -109,8 +135,24 @@ type Route struct { BackendID int64 } +// FrontendCfg is the HAProxy frontend a service compiles to (`fe_`). +type FrontendCfg struct { + ID int64 + BalancerID int64 + NodeID int64 + Name string + Mode string + Tls int64 + DefaultBackendID sql.NullInt64 + TimeoutClientS int64 + Maxconn int64 + ForwardedHeaders int64 + Enabled int64 + Maintenance int64 +} + type Frontend struct { - db.Frontend + FrontendCfg Labels map[string]string Binds []Bind CertIDs []int64 @@ -134,6 +176,67 @@ func (f *Frontend) BackendIDs() []int64 { return out } +// Project is one load balancer (table balancers): targets, services and domains. +type Project struct { + db.Balancer + Labels map[string]string + Services []*Service + Targets []*Server +} + +// Service is a port a project listens on. +type Service struct { + db.Service + StatusCodes []string + CertIDs []int64 +} + +// Mode is the HAProxy mode of the service (https terminates TLS in http mode). +func (s *Service) Mode() string { + if s.Protocol == "tcp" { + return "tcp" + } + return "http" +} + +func (p *Project) Service(id int64) *Service { + for _, s := range p.Services { + if s.ID == id { + return s + } + } + return nil +} + +func (p *Project) Target(id int64) *Server { + for _, t := range p.Targets { + if t.ID == id { + return t + } + } + return nil +} + +// WebService is the service domains route to by default: the first http/https service. +func (p *Project) WebService() *Service { + for _, s := range p.Services { + if s.Protocol != "tcp" { + return s + } + } + return nil +} + +// Slots returns all resolved target addresses of the project, sorted by id. +func (p *Project) Slots() []*Slot { + var out []*Slot + for _, t := range p.Targets { + out = append(out, t.Slots...) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + type Domain struct { db.Domain Observed struct { @@ -144,13 +247,19 @@ type Domain struct { type Snapshot struct { Nodes []*Node - Frontends []*Frontend - Backends []*Backend + Projects []*Project Hosts []*Host Certs []*Cert Domains []*Domain DNSProviders []db.DnsProvider + // Frontends and Backends are the compiled rendering view: one pair per service. + Frontends []*Frontend + Backends []*Backend + + projByID map[int64]*Project + domsByP map[int64][]*Domain + svcProj map[int64]*Project feByID map[int64]*Frontend beByID map[int64]*Backend certByID map[int64]*Cert @@ -158,12 +267,16 @@ type Snapshot struct { nodeByID map[int64]*Node } +func (s *Snapshot) Project(id int64) *Project { return s.projByID[id] } func (s *Snapshot) Frontend(id int64) *Frontend { return s.feByID[id] } func (s *Snapshot) Backend(id int64) *Backend { return s.beByID[id] } func (s *Snapshot) Cert(id int64) *Cert { return s.certByID[id] } func (s *Snapshot) Host(id int64) *Host { return s.hostByID[id] } func (s *Snapshot) Node(id int64) *Node { return s.nodeByID[id] } +// ServiceProject returns the project owning service id. +func (s *Snapshot) ServiceProject(id int64) *Project { return s.svcProj[id] } + // FrontendsOnNode / BackendsOnNode return the objects placed on a node, sorted by id. func (s *Snapshot) FrontendsOnNode(nodeID int64) []*Frontend { var out []*Frontend @@ -185,18 +298,45 @@ func (s *Snapshot) BackendsOnNode(nodeID int64) []*Backend { return out } -// UsedBy returns ids of frontends that reference a backend. -func (s *Snapshot) UsedBy(backendID int64) []int64 { - out := []int64{} - for _, f := range s.Frontends { - for _, id := range f.BackendIDs() { - if id == backendID { - out = append(out, f.ID) - break +// DomainsOf returns the domains attached to a project. +func (s *Snapshot) DomainsOf(projectID int64) []*Domain { + if s.domsByP == nil { + var out []*Domain + for _, d := range s.Domains { + if d.BalancerID.Valid && d.BalancerID.Int64 == projectID { + out = append(out, d) } } + return out } - return out + return s.domsByP[projectID] +} + +func (s *Snapshot) indexDomains() { + s.domsByP = map[int64][]*Domain{} + for _, d := range s.Domains { + if d.BalancerID.Valid { + s.domsByP[d.BalancerID.Int64] = append(s.domsByP[d.BalancerID.Int64], d) + } + } +} + +// DomainService resolves the service a domain routes to: its explicit service, else the +// project's first http/https service. nil when unattached or the project has none. +func (s *Snapshot) DomainService(d *Domain) *Service { + if !d.BalancerID.Valid { + return nil + } + p := s.projByID[d.BalancerID.Int64] + if p == nil { + return nil + } + if d.ServiceID.Valid { + if sv := p.Service(d.ServiceID.Int64); sv != nil && sv.Protocol != "tcp" { + return sv + } + } + return p.WebService() } func labels(js string) map[string]string { @@ -206,7 +346,8 @@ func labels(js string) map[string]string { } func newSnapshot() *Snapshot { - return &Snapshot{feByID: map[int64]*Frontend{}, beByID: map[int64]*Backend{}, certByID: map[int64]*Cert{}, hostByID: map[int64]*Host{}, nodeByID: map[int64]*Node{}} + return &Snapshot{projByID: map[int64]*Project{}, svcProj: map[int64]*Project{}, feByID: map[int64]*Frontend{}, beByID: map[int64]*Backend{}, + certByID: map[int64]*Cert{}, hostByID: map[int64]*Host{}, nodeByID: map[int64]*Node{}} } // Load reads the whole desired state. box may be nil (keys then stay empty). @@ -258,65 +399,57 @@ func Load(ctx context.Context, q *db.Queries, box *secrets.Box) (*Snapshot, erro if s.DNSProviders, err = q.ListDNSProviders(ctx); err != nil { return nil, err } - bes, err := q.ListBackends(ctx) + bals, err := q.ListBalancers(ctx) if err != nil { return nil, err } - for _, b := range bes { - x := &Backend{Backend: b, Labels: labels(b.Labels)} - _ = json.Unmarshal([]byte(b.HcStatusCodes), &x.StatusCodes) - s.Backends = append(s.Backends, x) - s.beByID[b.ID] = x + for _, b := range bals { + p := &Project{Balancer: b, Labels: labels(b.Labels)} + s.Projects = append(s.Projects, p) + s.projByID[b.ID] = p } - srvs, err := q.ListBackendServers(ctx) + svcs, err := q.ListServices(ctx) if err != nil { return nil, err } - srvByID := map[int64]*Server{} - for _, sv := range srvs { - x := &Server{BackendServer: sv} - srvByID[sv.ID] = x - if b := s.beByID[sv.BackendID]; b != nil { - b.Servers = append(b.Servers, x) + svcByID := map[int64]*Service{} + for _, sv := range svcs { + x := &Service{Service: sv} + _ = json.Unmarshal([]byte(sv.HcStatusCodes), &x.StatusCodes) + svcByID[sv.ID] = x + if p := s.projByID[sv.BalancerID]; p != nil { + p.Services = append(p.Services, x) + s.svcProj[sv.ID] = p } } - slots, err := q.ListSlots(ctx) + scs, err := q.ListServiceCertificates(ctx) if err != nil { return nil, err } - for _, sl := range slots { - if sv := srvByID[sl.ServerID]; sv != nil { - sv.Slots = append(sv.Slots, &Slot{ID: sl.ID, ServerID: sl.ServerID, HostID: sl.HostID.Int64, Address: sl.Address}) + for _, sc := range scs { + if sv := svcByID[sc.ServiceID]; sv != nil { + sv.CertIDs = append(sv.CertIDs, sc.CertificateID) } } - fes, err := q.ListFrontends(ctx) - if err != nil { - return nil, err - } - for _, f := range fes { - x := &Frontend{Frontend: f, Labels: labels(f.Labels)} - _ = json.Unmarshal([]byte(f.Binds), &x.Binds) - s.Frontends = append(s.Frontends, x) - s.feByID[f.ID] = x - } - routes, err := q.ListRoutes(ctx) + tgts, err := q.ListTargets(ctx) if err != nil { return nil, err } - for _, r := range routes { - if f := s.feByID[r.FrontendID]; f != nil { - rt := Route{ID: r.ID, PathPrefix: r.PathPrefix, BackendID: r.BackendID} - _ = json.Unmarshal([]byte(r.Hosts), &rt.Hosts) - f.Routes = append(f.Routes, rt) + tgtByID := map[int64]*Server{} + for _, t := range tgts { + x := &Server{Target: t} + tgtByID[t.ID] = x + if p := s.projByID[t.BalancerID]; p != nil { + p.Targets = append(p.Targets, x) } } - fcs, err := q.ListFrontendCertificates(ctx) + slots, err := q.ListSlots(ctx) if err != nil { return nil, err } - for _, fc := range fcs { - if f := s.feByID[fc.FrontendID]; f != nil { - f.CertIDs = append(f.CertIDs, fc.CertificateID) + for _, sl := range slots { + if t := tgtByID[sl.TargetID]; t != nil { + t.Slots = append(t.Slots, &Slot{ID: sl.ID, ServerID: sl.TargetID, HostID: sl.HostID.Int64, Address: sl.Address}) } } doms, err := q.ListDomains(ctx) @@ -328,19 +461,51 @@ func Load(ctx context.Context, q *db.Queries, box *secrets.Box) (*Snapshot, erro _ = json.Unmarshal([]byte(d.Observed), &x.Observed) s.Domains = append(s.Domains, x) } + s.indexDomains() + for _, p := range s.Projects { + for _, sv := range p.Services { + fe, be := Compile(p, sv) + s.Frontends = append(s.Frontends, fe) + s.Backends = append(s.Backends, be) + s.feByID[fe.ID] = fe + s.beByID[be.ID] = be + } + } return s, nil } -// SlotKey identifies a resolved address of a server; HostID is 0 for IP servers. +// Compile turns one service of a project into the HAProxy frontend/backend pair the renderer +// understands (ids = service id). All services of a project share its targets. +func Compile(p *Project, sv *Service) (*Frontend, *Backend) { + mode := sv.Mode() + be := &Backend{BackendCfg: BackendCfg{ID: sv.ID, BalancerID: p.ID, NodeID: p.NodeID, Name: p.Name, Mode: mode, Algorithm: p.Algorithm, + Port: sv.DestinationPort, StickyEnabled: sv.StickyEnabled, StickyCookieName: sv.StickyCookieName, StickyCookieLifetimeS: sv.StickyCookieLifetimeS, + ProxyProtocol: sv.ProxyProtocol, TimeoutServerS: sv.TimeoutS, HcProtocol: sv.HcProtocol, HcPort: sv.HcPort, HcIntervalS: sv.HcIntervalS, + HcTimeoutS: sv.HcTimeoutS, HcRetries: sv.HcRetries, HcDomain: sv.HcDomain, HcPath: sv.HcPath, HcResponse: sv.HcResponse, HcTlsVerify: sv.HcTlsVerify}, + Labels: p.Labels, StatusCodes: sv.StatusCodes, Servers: p.Targets} + var tls, fwd int64 + if sv.Protocol == "https" { + tls = 1 + } + if mode == "http" { + fwd = 1 + } + fe := &Frontend{FrontendCfg: FrontendCfg{ID: sv.ID, BalancerID: p.ID, NodeID: p.NodeID, Name: p.Name, Mode: mode, Tls: tls, + DefaultBackendID: sql.NullInt64{Int64: sv.ID, Valid: true}, TimeoutClientS: sv.TimeoutS, ForwardedHeaders: fwd, Enabled: 1, + Maintenance: p.Maintenance}, Labels: p.Labels, Binds: []Bind{{Address: "*", Port: sv.ListenPort}}, CertIDs: sv.CertIDs} + return fe, be +} + +// SlotKey identifies a resolved address of a target; HostID is 0 for IP targets. type SlotKey struct { HostID int64 Address string } -// ResolveServer computes the concrete addresses of a backend server. -func ResolveServer(sv db.BackendServer, hosts []db.Host) ([]SlotKey, error) { +// ResolveTarget computes the concrete addresses of a target. +func ResolveTarget(t db.Target, hosts []db.Host) ([]SlotKey, error) { addr := func(h db.Host) string { - if sv.UsePrivateIp != 0 && h.PrivateIpv4 != "" { + if t.UsePrivateIp != 0 && h.PrivateIpv4 != "" { return h.PrivateIpv4 } if h.Ipv4 != "" { @@ -348,16 +513,16 @@ func ResolveServer(sv db.BackendServer, hosts []db.Host) ([]SlotKey, error) { } return h.Ipv6 } - switch sv.Type { + switch t.Type { case "ip": - a, err := netip.ParseAddr(sv.Ip) + a, err := netip.ParseAddr(t.Ip) if err != nil { return nil, err } return []SlotKey{{0, a.String()}}, nil case "host": for _, h := range hosts { - if h.ID == sv.HostID.Int64 { + if h.ID == t.HostID.Int64 { if a := addr(h); a != "" { return []SlotKey{{h.ID, a}}, nil } @@ -366,7 +531,7 @@ func ResolveServer(sv db.BackendServer, hosts []db.Host) ([]SlotKey, error) { } return nil, nil case "label_selector": - sel, err := model.ParseSelector(sv.LabelSelector) + sel, err := model.ParseSelector(t.LabelSelector) if err != nil { return nil, err } @@ -380,13 +545,13 @@ func ResolveServer(sv db.BackendServer, hosts []db.Host) ([]SlotKey, error) { } return out, nil } - return nil, fmt.Errorf("unknown server type %q", sv.Type) + return nil, fmt.Errorf("unknown target type %q", t.Type) } -// SyncSlots reconciles server_slots with the resolution of every backend server so that -// HAProxy server ids stay stable. Must run inside the write transaction. +// SyncSlots reconciles target_slots with the resolution of every target so that HAProxy +// server ids stay stable. Must run inside the write transaction. func SyncSlots(ctx context.Context, q *db.Queries) error { - srvs, err := q.ListBackendServers(ctx) + tgts, err := q.ListTargets(ctx) if err != nil { return err } @@ -400,25 +565,25 @@ func SyncSlots(ctx context.Context, q *db.Queries) error { } existing := map[int64]map[SlotKey]int64{} for _, sl := range slots { - m := existing[sl.ServerID] + m := existing[sl.TargetID] if m == nil { m = map[SlotKey]int64{} - existing[sl.ServerID] = m + existing[sl.TargetID] = m } m[SlotKey{sl.HostID.Int64, sl.Address}] = sl.ID } - for _, sv := range srvs { - want, err := ResolveServer(sv, hosts) + for _, t := range tgts { + want, err := ResolveTarget(t, hosts) if err != nil { return err } - have := existing[sv.ID] + have := existing[t.ID] wantSet := map[SlotKey]bool{} for _, k := range want { wantSet[k] = true if _, ok := have[k]; !ok { hid := sql.NullInt64{Int64: k.HostID, Valid: k.HostID != 0} - if _, err := q.CreateSlot(ctx, db.CreateSlotParams{BackendID: sv.BackendID, ServerID: sv.ID, HostID: hid, Address: k.Address}); err != nil { + if _, err := q.CreateSlot(ctx, db.CreateSlotParams{BalancerID: t.BalancerID, TargetID: t.ID, HostID: hid, Address: k.Address}); err != nil { return err } } @@ -454,7 +619,7 @@ func (h *Holder) Refresh(ctx context.Context, q *db.Queries, box *secrets.Box) ( return s, nil } -// TestSnapshot builds an indexed snapshot (tests only). +// TestSnapshot builds an indexed snapshot from rendering objects (tests only). func TestSnapshot(fes []*Frontend, bes []*Backend, certs ...*Cert) *Snapshot { s := newSnapshot() s.Frontends, s.Backends, s.Certs = fes, bes, certs @@ -469,3 +634,24 @@ func TestSnapshot(fes []*Frontend, bes []*Backend, certs ...*Cert) *Snapshot { } return s } + +// TestProjects builds an indexed snapshot from projects, compiling their services (tests only). +func TestProjects(ps []*Project, domains []*Domain, certs ...*Cert) *Snapshot { + s := newSnapshot() + s.Projects, s.Domains, s.Certs = ps, domains, certs + s.indexDomains() + for _, c := range certs { + s.certByID[c.ID] = c + } + for _, p := range ps { + s.projByID[p.ID] = p + for _, sv := range p.Services { + s.svcProj[sv.ID] = p + fe, be := Compile(p, sv) + s.Frontends = append(s.Frontends, fe) + s.Backends = append(s.Backends, be) + s.feByID[fe.ID], s.beByID[be.ID] = fe, be + } + } + return s +} diff --git a/internal/store/db/core.sql.go b/internal/store/db/core.sql.go index b259e47..47421b5 100644 --- a/internal/store/db/core.sql.go +++ b/internal/store/db/core.sql.go @@ -10,41 +10,18 @@ import ( "database/sql" ) -const addFrontendCertificate = `-- name: AddFrontendCertificate :exec -INSERT INTO frontend_certificates (frontend_id, certificate_id, position) VALUES (?, ?, ?) +const addServiceCertificate = `-- name: AddServiceCertificate :exec +INSERT INTO service_certificates (service_id, certificate_id, position) VALUES (?, ?, ?) ` -type AddFrontendCertificateParams struct { - FrontendID int64 +type AddServiceCertificateParams struct { + ServiceID int64 CertificateID int64 Position int64 } -func (q *Queries) AddFrontendCertificate(ctx context.Context, arg AddFrontendCertificateParams) error { - _, err := q.db.ExecContext(ctx, addFrontendCertificate, arg.FrontendID, arg.CertificateID, arg.Position) - return err -} - -const addRoute = `-- name: AddRoute :exec -INSERT INTO routes (frontend_id, position, hosts, path_prefix, backend_id) VALUES (?, ?, ?, ?, ?) -` - -type AddRouteParams struct { - FrontendID int64 - Position int64 - Hosts string - PathPrefix string - BackendID int64 -} - -func (q *Queries) AddRoute(ctx context.Context, arg AddRouteParams) error { - _, err := q.db.ExecContext(ctx, addRoute, - arg.FrontendID, - arg.Position, - arg.Hosts, - arg.PathPrefix, - arg.BackendID, - ) +func (q *Queries) AddServiceCertificate(ctx context.Context, arg AddServiceCertificateParams) error { + _, err := q.db.ExecContext(ctx, addServiceCertificate, arg.ServiceID, arg.CertificateID, arg.Position) return err } @@ -59,21 +36,12 @@ func (q *Queries) BumpDesiredVersion(ctx context.Context, id int64) (int64, erro return desired_version, err } -const clearFrontendCertificates = `-- name: ClearFrontendCertificates :exec -DELETE FROM frontend_certificates WHERE frontend_id = ? +const clearServiceCertificates = `-- name: ClearServiceCertificates :exec +DELETE FROM service_certificates WHERE service_id = ? ` -func (q *Queries) ClearFrontendCertificates(ctx context.Context, frontendID int64) error { - _, err := q.db.ExecContext(ctx, clearFrontendCertificates, frontendID) - return err -} - -const clearRoutes = `-- name: ClearRoutes :exec -DELETE FROM routes WHERE frontend_id = ? -` - -func (q *Queries) ClearRoutes(ctx context.Context, frontendID int64) error { - _, err := q.db.ExecContext(ctx, clearRoutes, frontendID) +func (q *Queries) ClearServiceCertificates(ctx context.Context, serviceID int64) error { + _, err := q.db.ExecContext(ctx, clearServiceCertificates, serviceID) return err } @@ -212,141 +180,44 @@ func (q *Queries) CreateAudit(ctx context.Context, arg CreateAuditParams) error return err } -const createBackend = `-- name: CreateBackend :one -INSERT INTO backends (project_id, name, description, mode, algorithm, port, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, - proxy_protocol, timeout_server_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, - hc_status_codes, hc_tls_verify, labels, created_at) -VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, project_id, node_id, name, description, mode, algorithm, port, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, proxy_protocol, timeout_server_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes, hc_tls_verify, labels, version, created_at +const createBalancer = `-- name: CreateBalancer :one +INSERT INTO balancers (project_id, name, description, algorithm, labels, status, created_at, updated_at) +VALUES (1, ?, ?, ?, ?, 'applying', ?, ?) RETURNING id, project_id, node_id, name, description, algorithm, labels, maintenance, status, last_error, version, created_at, updated_at ` -type CreateBackendParams struct { - Name string - Description string - Mode string - Algorithm string - Port int64 - StickyEnabled int64 - StickyCookieName string - StickyCookieLifetimeS int64 - ProxyProtocol string - TimeoutServerS int64 - HcProtocol string - HcPort int64 - HcIntervalS int64 - HcTimeoutS int64 - HcRetries int64 - HcDomain string - HcPath string - HcResponse string - HcStatusCodes string - HcTlsVerify int64 - Labels string - CreatedAt int64 +type CreateBalancerParams struct { + Name string + Description string + Algorithm string + Labels string + CreatedAt int64 + UpdatedAt int64 } -func (q *Queries) CreateBackend(ctx context.Context, arg CreateBackendParams) (Backend, error) { - row := q.db.QueryRowContext(ctx, createBackend, +func (q *Queries) CreateBalancer(ctx context.Context, arg CreateBalancerParams) (Balancer, error) { + row := q.db.QueryRowContext(ctx, createBalancer, arg.Name, arg.Description, - arg.Mode, arg.Algorithm, - arg.Port, - arg.StickyEnabled, - arg.StickyCookieName, - arg.StickyCookieLifetimeS, - arg.ProxyProtocol, - arg.TimeoutServerS, - arg.HcProtocol, - arg.HcPort, - arg.HcIntervalS, - arg.HcTimeoutS, - arg.HcRetries, - arg.HcDomain, - arg.HcPath, - arg.HcResponse, - arg.HcStatusCodes, - arg.HcTlsVerify, arg.Labels, arg.CreatedAt, + arg.UpdatedAt, ) - var i Backend + var i Balancer err := row.Scan( &i.ID, &i.ProjectID, &i.NodeID, &i.Name, &i.Description, - &i.Mode, &i.Algorithm, - &i.Port, - &i.StickyEnabled, - &i.StickyCookieName, - &i.StickyCookieLifetimeS, - &i.ProxyProtocol, - &i.TimeoutServerS, - &i.HcProtocol, - &i.HcPort, - &i.HcIntervalS, - &i.HcTimeoutS, - &i.HcRetries, - &i.HcDomain, - &i.HcPath, - &i.HcResponse, - &i.HcStatusCodes, - &i.HcTlsVerify, &i.Labels, + &i.Maintenance, + &i.Status, + &i.LastError, &i.Version, &i.CreatedAt, - ) - return i, err -} - -const createBackendServer = `-- name: CreateBackendServer :one -INSERT INTO backend_servers (backend_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, backend_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, admin_state, removing, created_at -` - -type CreateBackendServerParams struct { - BackendID int64 - Type string - HostID sql.NullInt64 - LabelSelector string - Ip string - Port sql.NullInt64 - UsePrivateIp int64 - Weight int64 - Backup int64 - CreatedAt int64 -} - -func (q *Queries) CreateBackendServer(ctx context.Context, arg CreateBackendServerParams) (BackendServer, error) { - row := q.db.QueryRowContext(ctx, createBackendServer, - arg.BackendID, - arg.Type, - arg.HostID, - arg.LabelSelector, - arg.Ip, - arg.Port, - arg.UsePrivateIp, - arg.Weight, - arg.Backup, - arg.CreatedAt, - ) - var i BackendServer - err := row.Scan( - &i.ID, - &i.BackendID, - &i.Type, - &i.HostID, - &i.LabelSelector, - &i.Ip, - &i.Port, - &i.UsePrivateIp, - &i.Weight, - &i.Backup, - &i.AdminState, - &i.Removing, - &i.CreatedAt, + &i.UpdatedAt, ) return i, err } @@ -496,12 +367,15 @@ func (q *Queries) CreateDNSProvider(ctx context.Context, arg CreateDNSProviderPa } const createDomain = `-- name: CreateDomain :one -INSERT INTO domains (project_id, fqdn, frontend_id, dns_provider_id, zone, auto_records, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?) RETURNING id, project_id, fqdn, frontend_id, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, certificate_id, managed_record_ids, created_at, updated_at +INSERT INTO domains (project_id, fqdn, balancer_id, service_id, https, dns_provider_id, zone, auto_records, created_at, updated_at) +VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, project_id, fqdn, balancer_id, service_id, https, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, certificate_id, managed_record_ids, created_at, updated_at ` type CreateDomainParams struct { Fqdn string - FrontendID sql.NullInt64 + BalancerID sql.NullInt64 + ServiceID sql.NullInt64 + Https int64 DnsProviderID sql.NullInt64 Zone string AutoRecords int64 @@ -512,7 +386,9 @@ type CreateDomainParams struct { func (q *Queries) CreateDomain(ctx context.Context, arg CreateDomainParams) (Domain, error) { row := q.db.QueryRowContext(ctx, createDomain, arg.Fqdn, - arg.FrontendID, + arg.BalancerID, + arg.ServiceID, + arg.Https, arg.DnsProviderID, arg.Zone, arg.AutoRecords, @@ -524,7 +400,9 @@ func (q *Queries) CreateDomain(ctx context.Context, arg CreateDomainParams) (Dom &i.ID, &i.ProjectID, &i.Fqdn, - &i.FrontendID, + &i.BalancerID, + &i.ServiceID, + &i.Https, &i.DnsProviderID, &i.Zone, &i.AutoRecords, @@ -542,13 +420,12 @@ func (q *Queries) CreateDomain(ctx context.Context, arg CreateDomainParams) (Dom } const createEvent = `-- name: CreateEvent :one -INSERT INTO events (ts, frontend_id, backend_id, kind, severity, message, data) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id +INSERT INTO events (ts, balancer_id, kind, severity, message, data) VALUES (?, ?, ?, ?, ?, ?) RETURNING id ` type CreateEventParams struct { Ts int64 - FrontendID sql.NullInt64 - BackendID sql.NullInt64 + BalancerID sql.NullInt64 Kind string Severity string Message string @@ -558,8 +435,7 @@ type CreateEventParams struct { func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (int64, error) { row := q.db.QueryRowContext(ctx, createEvent, arg.Ts, - arg.FrontendID, - arg.BackendID, + arg.BalancerID, arg.Kind, arg.Severity, arg.Message, @@ -570,73 +446,6 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (int64 return id, err } -const createFrontend = `-- name: CreateFrontend :one -INSERT INTO frontends (project_id, name, description, mode, tls, binds, http_redirect, default_backend_id, timeout_client_s, maxconn, - forwarded_headers, enabled, labels, status, created_at, updated_at) -VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'applying', ?, ?) RETURNING id, project_id, node_id, name, description, mode, tls, binds, http_redirect, default_backend_id, timeout_client_s, maxconn, forwarded_headers, enabled, maintenance, labels, status, last_error, version, created_at, updated_at -` - -type CreateFrontendParams struct { - Name string - Description string - Mode string - Tls int64 - Binds string - HttpRedirect int64 - DefaultBackendID sql.NullInt64 - TimeoutClientS int64 - Maxconn int64 - ForwardedHeaders int64 - Enabled int64 - Labels string - CreatedAt int64 - UpdatedAt int64 -} - -func (q *Queries) CreateFrontend(ctx context.Context, arg CreateFrontendParams) (Frontend, error) { - row := q.db.QueryRowContext(ctx, createFrontend, - arg.Name, - arg.Description, - arg.Mode, - arg.Tls, - arg.Binds, - arg.HttpRedirect, - arg.DefaultBackendID, - arg.TimeoutClientS, - arg.Maxconn, - arg.ForwardedHeaders, - arg.Enabled, - arg.Labels, - arg.CreatedAt, - arg.UpdatedAt, - ) - var i Frontend - err := row.Scan( - &i.ID, - &i.ProjectID, - &i.NodeID, - &i.Name, - &i.Description, - &i.Mode, - &i.Tls, - &i.Binds, - &i.HttpRedirect, - &i.DefaultBackendID, - &i.TimeoutClientS, - &i.Maxconn, - &i.ForwardedHeaders, - &i.Enabled, - &i.Maintenance, - &i.Labels, - &i.Status, - &i.LastError, - &i.Version, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - const createHost = `-- name: CreateHost :one INSERT INTO hosts (project_id, name, ipv4, ipv6, private_ipv4, labels, notes) VALUES (1, ?, ?, ?, ?, ?, ?) RETURNING id, project_id, name, ipv4, ipv6, private_ipv4, labels, notes ` @@ -741,6 +550,87 @@ func (q *Queries) CreateRevision(ctx context.Context, arg CreateRevisionParams) return id, err } +const createService = `-- name: CreateService :one +INSERT INTO services (balancer_id, protocol, listen_port, destination_port, proxy_protocol, timeout_s, sticky_enabled, sticky_cookie_name, + sticky_cookie_lifetime_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes, + hc_tls_verify, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, balancer_id, protocol, listen_port, destination_port, proxy_protocol, timeout_s, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes, hc_tls_verify, version, created_at +` + +type CreateServiceParams struct { + BalancerID int64 + Protocol string + ListenPort int64 + DestinationPort int64 + ProxyProtocol string + TimeoutS int64 + StickyEnabled int64 + StickyCookieName string + StickyCookieLifetimeS int64 + HcProtocol string + HcPort int64 + HcIntervalS int64 + HcTimeoutS int64 + HcRetries int64 + HcDomain string + HcPath string + HcResponse string + HcStatusCodes string + HcTlsVerify int64 + CreatedAt int64 +} + +func (q *Queries) CreateService(ctx context.Context, arg CreateServiceParams) (Service, error) { + row := q.db.QueryRowContext(ctx, createService, + arg.BalancerID, + arg.Protocol, + arg.ListenPort, + arg.DestinationPort, + arg.ProxyProtocol, + arg.TimeoutS, + arg.StickyEnabled, + arg.StickyCookieName, + arg.StickyCookieLifetimeS, + arg.HcProtocol, + arg.HcPort, + arg.HcIntervalS, + arg.HcTimeoutS, + arg.HcRetries, + arg.HcDomain, + arg.HcPath, + arg.HcResponse, + arg.HcStatusCodes, + arg.HcTlsVerify, + arg.CreatedAt, + ) + var i Service + err := row.Scan( + &i.ID, + &i.BalancerID, + &i.Protocol, + &i.ListenPort, + &i.DestinationPort, + &i.ProxyProtocol, + &i.TimeoutS, + &i.StickyEnabled, + &i.StickyCookieName, + &i.StickyCookieLifetimeS, + &i.HcProtocol, + &i.HcPort, + &i.HcIntervalS, + &i.HcTimeoutS, + &i.HcRetries, + &i.HcDomain, + &i.HcPath, + &i.HcResponse, + &i.HcStatusCodes, + &i.HcTlsVerify, + &i.Version, + &i.CreatedAt, + ) + return i, err +} + const createSession = `-- name: CreateSession :exec INSERT INTO sessions (id_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?) ` @@ -769,20 +659,20 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er } const createSlot = `-- name: CreateSlot :one -INSERT INTO server_slots (backend_id, server_id, host_id, address) VALUES (?, ?, ?, ?) RETURNING id +INSERT INTO target_slots (balancer_id, target_id, host_id, address) VALUES (?, ?, ?, ?) RETURNING id ` type CreateSlotParams struct { - BackendID int64 - ServerID int64 - HostID sql.NullInt64 - Address string + BalancerID int64 + TargetID int64 + HostID sql.NullInt64 + Address string } func (q *Queries) CreateSlot(ctx context.Context, arg CreateSlotParams) (int64, error) { row := q.db.QueryRowContext(ctx, createSlot, - arg.BackendID, - arg.ServerID, + arg.BalancerID, + arg.TargetID, arg.HostID, arg.Address, ) @@ -791,6 +681,56 @@ func (q *Queries) CreateSlot(ctx context.Context, arg CreateSlotParams) (int64, return id, err } +const createTarget = `-- name: CreateTarget :one +INSERT INTO targets (balancer_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, balancer_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, admin_state, removing, created_at +` + +type CreateTargetParams struct { + BalancerID int64 + Type string + HostID sql.NullInt64 + LabelSelector string + Ip string + Port sql.NullInt64 + UsePrivateIp int64 + Weight int64 + Backup int64 + CreatedAt int64 +} + +func (q *Queries) CreateTarget(ctx context.Context, arg CreateTargetParams) (Target, error) { + row := q.db.QueryRowContext(ctx, createTarget, + arg.BalancerID, + arg.Type, + arg.HostID, + arg.LabelSelector, + arg.Ip, + arg.Port, + arg.UsePrivateIp, + arg.Weight, + arg.Backup, + arg.CreatedAt, + ) + var i Target + err := row.Scan( + &i.ID, + &i.BalancerID, + &i.Type, + &i.HostID, + &i.LabelSelector, + &i.Ip, + &i.Port, + &i.UsePrivateIp, + &i.Weight, + &i.Backup, + &i.AdminState, + &i.Removing, + &i.CreatedAt, + ) + return i, err +} + const createUser = `-- name: CreateUser :one INSERT INTO users (email, password_hash, role, created_at) VALUES (?, ?, ?, ?) RETURNING id, email, password_hash, role, totp_secret_enc, disabled, created_at, last_login_at ` @@ -841,21 +781,12 @@ func (q *Queries) DeleteAlertRule(ctx context.Context, id int64) error { return err } -const deleteBackend = `-- name: DeleteBackend :exec -DELETE FROM backends WHERE id = ? -` - -func (q *Queries) DeleteBackend(ctx context.Context, id int64) error { - _, err := q.db.ExecContext(ctx, deleteBackend, id) - return err -} - -const deleteBackendServer = `-- name: DeleteBackendServer :exec -DELETE FROM backend_servers WHERE id = ? +const deleteBalancer = `-- name: DeleteBalancer :exec +DELETE FROM balancers WHERE id = ? ` -func (q *Queries) DeleteBackendServer(ctx context.Context, id int64) error { - _, err := q.db.ExecContext(ctx, deleteBackendServer, id) +func (q *Queries) DeleteBalancer(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteBalancer, id) return err } @@ -904,15 +835,6 @@ func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiresAt int64) er return err } -const deleteFrontend = `-- name: DeleteFrontend :exec -DELETE FROM frontends WHERE id = ? -` - -func (q *Queries) DeleteFrontend(ctx context.Context, id int64) error { - _, err := q.db.ExecContext(ctx, deleteFrontend, id) - return err -} - const deleteHost = `-- name: DeleteHost :exec DELETE FROM hosts WHERE id = ? ` @@ -936,6 +858,15 @@ func (q *Queries) DeleteNodeAddress(ctx context.Context, arg DeleteNodeAddressPa return err } +const deleteService = `-- name: DeleteService :exec +DELETE FROM services WHERE id = ? +` + +func (q *Queries) DeleteService(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteService, id) + return err +} + const deleteSession = `-- name: DeleteSession :exec DELETE FROM sessions WHERE id_hash = ? ` @@ -946,7 +877,7 @@ func (q *Queries) DeleteSession(ctx context.Context, idHash string) error { } const deleteSlot = `-- name: DeleteSlot :exec -DELETE FROM server_slots WHERE id = ? +DELETE FROM target_slots WHERE id = ? ` func (q *Queries) DeleteSlot(ctx context.Context, id int64) error { @@ -954,6 +885,15 @@ func (q *Queries) DeleteSlot(ctx context.Context, id int64) error { return err } +const deleteTarget = `-- name: DeleteTarget :exec +DELETE FROM targets WHERE id = ? +` + +func (q *Queries) DeleteTarget(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteTarget, id) + return err +} + const deleteUser = `-- name: DeleteUser :exec DELETE FROM users WHERE id = ? AND role != 'owner' ` @@ -1102,7 +1042,7 @@ func (q *Queries) GetDNSProvider(ctx context.Context, id int64) (DnsProvider, er } const getDomain = `-- name: GetDomain :one -SELECT id, project_id, fqdn, frontend_id, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, certificate_id, managed_record_ids, created_at, updated_at FROM domains WHERE id = ? +SELECT id, project_id, fqdn, balancer_id, service_id, https, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, certificate_id, managed_record_ids, created_at, updated_at FROM domains WHERE id = ? ` func (q *Queries) GetDomain(ctx context.Context, id int64) (Domain, error) { @@ -1112,7 +1052,9 @@ func (q *Queries) GetDomain(ctx context.Context, id int64) (Domain, error) { &i.ID, &i.ProjectID, &i.Fqdn, - &i.FrontendID, + &i.BalancerID, + &i.ServiceID, + &i.Https, &i.DnsProviderID, &i.Zone, &i.AutoRecords, @@ -1397,88 +1339,35 @@ func (q *Queries) ListAudit(ctx context.Context, limit int64) ([]AuditLog, error return items, nil } -const listBackendServers = `-- name: ListBackendServers :many -SELECT id, backend_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, admin_state, removing, created_at FROM backend_servers ORDER BY id +const listBalancers = `-- name: ListBalancers :many +SELECT id, project_id, node_id, name, description, algorithm, labels, maintenance, status, last_error, version, created_at, updated_at FROM balancers ORDER BY id ` -func (q *Queries) ListBackendServers(ctx context.Context) ([]BackendServer, error) { - rows, err := q.db.QueryContext(ctx, listBackendServers) +// ===== backends ===== +// ===== projects (table balancers) ===== +func (q *Queries) ListBalancers(ctx context.Context) ([]Balancer, error) { + rows, err := q.db.QueryContext(ctx, listBalancers) if err != nil { return nil, err } defer rows.Close() - items := []BackendServer{} + items := []Balancer{} for rows.Next() { - var i BackendServer - if err := rows.Scan( - &i.ID, - &i.BackendID, - &i.Type, - &i.HostID, - &i.LabelSelector, - &i.Ip, - &i.Port, - &i.UsePrivateIp, - &i.Weight, - &i.Backup, - &i.AdminState, - &i.Removing, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listBackends = `-- name: ListBackends :many -SELECT id, project_id, node_id, name, description, mode, algorithm, port, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, proxy_protocol, timeout_server_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes, hc_tls_verify, labels, version, created_at FROM backends ORDER BY id -` - -// ===== backends ===== -func (q *Queries) ListBackends(ctx context.Context) ([]Backend, error) { - rows, err := q.db.QueryContext(ctx, listBackends) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Backend{} - for rows.Next() { - var i Backend + var i Balancer if err := rows.Scan( &i.ID, &i.ProjectID, &i.NodeID, &i.Name, &i.Description, - &i.Mode, &i.Algorithm, - &i.Port, - &i.StickyEnabled, - &i.StickyCookieName, - &i.StickyCookieLifetimeS, - &i.ProxyProtocol, - &i.TimeoutServerS, - &i.HcProtocol, - &i.HcPort, - &i.HcIntervalS, - &i.HcTimeoutS, - &i.HcRetries, - &i.HcDomain, - &i.HcPath, - &i.HcResponse, - &i.HcStatusCodes, - &i.HcTlsVerify, &i.Labels, + &i.Maintenance, + &i.Status, + &i.LastError, &i.Version, &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -1616,7 +1505,7 @@ func (q *Queries) ListDNSProviders(ctx context.Context) ([]DnsProvider, error) { } const listDomains = `-- name: ListDomains :many -SELECT id, project_id, fqdn, frontend_id, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, certificate_id, managed_record_ids, created_at, updated_at FROM domains ORDER BY fqdn +SELECT id, project_id, fqdn, balancer_id, service_id, https, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, certificate_id, managed_record_ids, created_at, updated_at FROM domains ORDER BY fqdn ` func (q *Queries) ListDomains(ctx context.Context) ([]Domain, error) { @@ -1632,7 +1521,9 @@ func (q *Queries) ListDomains(ctx context.Context) ([]Domain, error) { &i.ID, &i.ProjectID, &i.Fqdn, - &i.FrontendID, + &i.BalancerID, + &i.ServiceID, + &i.Https, &i.DnsProviderID, &i.Zone, &i.AutoRecords, @@ -1660,26 +1551,19 @@ func (q *Queries) ListDomains(ctx context.Context) ([]Domain, error) { } const listEvents = `-- name: ListEvents :many -SELECT id, ts, project_id, frontend_id, backend_id, kind, severity, message, data FROM events WHERE (?1 IS NULL OR frontend_id = ?1) - AND (?2 IS NULL OR backend_id = ?2) - AND (?3 IS NULL OR kind = ?3) -ORDER BY id DESC LIMIT ?4 +SELECT id, ts, project_id, balancer_id, kind, severity, message, data FROM events WHERE (?1 IS NULL OR balancer_id = ?1) + AND (?2 IS NULL OR kind = ?2) +ORDER BY id DESC LIMIT ?3 ` type ListEventsParams struct { - FrontendID interface{} - BackendID interface{} + BalancerID interface{} Kind interface{} MaxRows int64 } func (q *Queries) ListEvents(ctx context.Context, arg ListEventsParams) ([]Event, error) { - rows, err := q.db.QueryContext(ctx, listEvents, - arg.FrontendID, - arg.BackendID, - arg.Kind, - arg.MaxRows, - ) + rows, err := q.db.QueryContext(ctx, listEvents, arg.BalancerID, arg.Kind, arg.MaxRows) if err != nil { return nil, err } @@ -1691,8 +1575,7 @@ func (q *Queries) ListEvents(ctx context.Context, arg ListEventsParams) ([]Event &i.ID, &i.Ts, &i.ProjectID, - &i.FrontendID, - &i.BackendID, + &i.BalancerID, &i.Kind, &i.Severity, &i.Message, @@ -1711,69 +1594,29 @@ func (q *Queries) ListEvents(ctx context.Context, arg ListEventsParams) ([]Event return items, nil } -const listFrontendCertificates = `-- name: ListFrontendCertificates :many -SELECT frontend_id, certificate_id, position FROM frontend_certificates ORDER BY frontend_id, position -` - -func (q *Queries) ListFrontendCertificates(ctx context.Context) ([]FrontendCertificate, error) { - rows, err := q.db.QueryContext(ctx, listFrontendCertificates) - if err != nil { - return nil, err - } - defer rows.Close() - items := []FrontendCertificate{} - for rows.Next() { - var i FrontendCertificate - if err := rows.Scan(&i.FrontendID, &i.CertificateID, &i.Position); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listFrontends = `-- name: ListFrontends :many -SELECT id, project_id, node_id, name, description, mode, tls, binds, http_redirect, default_backend_id, timeout_client_s, maxconn, forwarded_headers, enabled, maintenance, labels, status, last_error, version, created_at, updated_at FROM frontends ORDER BY id +const listHosts = `-- name: ListHosts :many +SELECT id, project_id, name, ipv4, ipv6, private_ipv4, labels, notes FROM hosts ORDER BY id ` -// ===== frontends ===== -func (q *Queries) ListFrontends(ctx context.Context) ([]Frontend, error) { - rows, err := q.db.QueryContext(ctx, listFrontends) +// ===== hosts ===== +func (q *Queries) ListHosts(ctx context.Context) ([]Host, error) { + rows, err := q.db.QueryContext(ctx, listHosts) if err != nil { return nil, err } defer rows.Close() - items := []Frontend{} + items := []Host{} for rows.Next() { - var i Frontend + var i Host if err := rows.Scan( &i.ID, &i.ProjectID, - &i.NodeID, &i.Name, - &i.Description, - &i.Mode, - &i.Tls, - &i.Binds, - &i.HttpRedirect, - &i.DefaultBackendID, - &i.TimeoutClientS, - &i.Maxconn, - &i.ForwardedHeaders, - &i.Enabled, - &i.Maintenance, + &i.Ipv4, + &i.Ipv6, + &i.PrivateIpv4, &i.Labels, - &i.Status, - &i.LastError, - &i.Version, - &i.CreatedAt, - &i.UpdatedAt, + &i.Notes, ); err != nil { return nil, err } @@ -1788,33 +1631,23 @@ func (q *Queries) ListFrontends(ctx context.Context) ([]Frontend, error) { return items, nil } -const listHosts = `-- name: ListHosts :many -SELECT id, project_id, name, ipv4, ipv6, private_ipv4, labels, notes FROM hosts ORDER BY id +const listListenPorts = `-- name: ListListenPorts :many +SELECT listen_port FROM services ORDER BY listen_port ` -// ===== hosts ===== -func (q *Queries) ListHosts(ctx context.Context) ([]Host, error) { - rows, err := q.db.QueryContext(ctx, listHosts) +func (q *Queries) ListListenPorts(ctx context.Context) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listListenPorts) if err != nil { return nil, err } defer rows.Close() - items := []Host{} + items := []int64{} for rows.Next() { - var i Host - if err := rows.Scan( - &i.ID, - &i.ProjectID, - &i.Name, - &i.Ipv4, - &i.Ipv6, - &i.PrivateIpv4, - &i.Labels, - &i.Notes, - ); err != nil { + var listen_port int64 + if err := rows.Scan(&listen_port); err != nil { return nil, err } - items = append(items, i) + items = append(items, listen_port) } if err := rows.Close(); err != nil { return nil, err @@ -1952,26 +1785,69 @@ func (q *Queries) ListRevisions(ctx context.Context, nodeID int64) ([]ListRevisi return items, nil } -const listRoutes = `-- name: ListRoutes :many -SELECT id, frontend_id, position, hosts, path_prefix, backend_id FROM routes ORDER BY frontend_id, position +const listServiceCertificates = `-- name: ListServiceCertificates :many +SELECT service_id, certificate_id, position FROM service_certificates ORDER BY service_id, position ` -func (q *Queries) ListRoutes(ctx context.Context) ([]Route, error) { - rows, err := q.db.QueryContext(ctx, listRoutes) +func (q *Queries) ListServiceCertificates(ctx context.Context) ([]ServiceCertificate, error) { + rows, err := q.db.QueryContext(ctx, listServiceCertificates) if err != nil { return nil, err } defer rows.Close() - items := []Route{} + items := []ServiceCertificate{} for rows.Next() { - var i Route + var i ServiceCertificate + if err := rows.Scan(&i.ServiceID, &i.CertificateID, &i.Position); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listServices = `-- name: ListServices :many +SELECT id, balancer_id, protocol, listen_port, destination_port, proxy_protocol, timeout_s, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes, hc_tls_verify, version, created_at FROM services ORDER BY balancer_id, id +` + +func (q *Queries) ListServices(ctx context.Context) ([]Service, error) { + rows, err := q.db.QueryContext(ctx, listServices) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Service{} + for rows.Next() { + var i Service if err := rows.Scan( &i.ID, - &i.FrontendID, - &i.Position, - &i.Hosts, - &i.PathPrefix, - &i.BackendID, + &i.BalancerID, + &i.Protocol, + &i.ListenPort, + &i.DestinationPort, + &i.ProxyProtocol, + &i.TimeoutS, + &i.StickyEnabled, + &i.StickyCookieName, + &i.StickyCookieLifetimeS, + &i.HcProtocol, + &i.HcPort, + &i.HcIntervalS, + &i.HcTimeoutS, + &i.HcRetries, + &i.HcDomain, + &i.HcPath, + &i.HcResponse, + &i.HcStatusCodes, + &i.HcTlsVerify, + &i.Version, + &i.CreatedAt, ); err != nil { return nil, err } @@ -1987,22 +1863,22 @@ func (q *Queries) ListRoutes(ctx context.Context) ([]Route, error) { } const listSlots = `-- name: ListSlots :many -SELECT id, backend_id, server_id, host_id, address FROM server_slots ORDER BY id +SELECT id, balancer_id, target_id, host_id, address FROM target_slots ORDER BY id ` -func (q *Queries) ListSlots(ctx context.Context) ([]ServerSlot, error) { +func (q *Queries) ListSlots(ctx context.Context) ([]TargetSlot, error) { rows, err := q.db.QueryContext(ctx, listSlots) if err != nil { return nil, err } defer rows.Close() - items := []ServerSlot{} + items := []TargetSlot{} for rows.Next() { - var i ServerSlot + var i TargetSlot if err := rows.Scan( &i.ID, - &i.BackendID, - &i.ServerID, + &i.BalancerID, + &i.TargetID, &i.HostID, &i.Address, ); err != nil { @@ -2019,6 +1895,47 @@ func (q *Queries) ListSlots(ctx context.Context) ([]ServerSlot, error) { return items, nil } +const listTargets = `-- name: ListTargets :many +SELECT id, balancer_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, admin_state, removing, created_at FROM targets ORDER BY id +` + +func (q *Queries) ListTargets(ctx context.Context) ([]Target, error) { + rows, err := q.db.QueryContext(ctx, listTargets) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Target{} + for rows.Next() { + var i Target + if err := rows.Scan( + &i.ID, + &i.BalancerID, + &i.Type, + &i.HostID, + &i.LabelSelector, + &i.Ip, + &i.Port, + &i.UsePrivateIp, + &i.Weight, + &i.Backup, + &i.AdminState, + &i.Removing, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsers = `-- name: ListUsers :many SELECT id, email, password_hash, role, totp_secret_enc, disabled, created_at, last_login_at FROM users ORDER BY id ` @@ -2055,12 +1972,12 @@ func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { return items, nil } -const markBackendServerRemoving = `-- name: MarkBackendServerRemoving :exec -UPDATE backend_servers SET removing = 1 WHERE id = ? +const markTargetRemoving = `-- name: MarkTargetRemoving :exec +UPDATE targets SET removing = 1 WHERE id = ? ` -func (q *Queries) MarkBackendServerRemoving(ctx context.Context, id int64) error { - _, err := q.db.ExecContext(ctx, markBackendServerRemoving, id) +func (q *Queries) MarkTargetRemoving(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, markTargetRemoving, id) return err } @@ -2182,6 +2099,21 @@ func (q *Queries) SetAppliedVersion(ctx context.Context, arg SetAppliedVersionPa return err } +const setBalancerStatus = `-- name: SetBalancerStatus :exec +UPDATE balancers SET status = ?, last_error = ? WHERE id = ? +` + +type SetBalancerStatusParams struct { + Status string + LastError sql.NullString + ID int64 +} + +func (q *Queries) SetBalancerStatus(ctx context.Context, arg SetBalancerStatusParams) error { + _, err := q.db.ExecContext(ctx, setBalancerStatus, arg.Status, arg.LastError, arg.ID) + return err +} + const setCertificateIssued = `-- name: SetCertificateIssued :exec UPDATE certificates SET cert_pem = ?, chain_pem = ?, key_pem_enc = ?, issuer = ?, serial = ?, fingerprint_sha256 = ?, not_before = ?, not_after = ?, status = 'issued', last_error = NULL, renew_after = ? WHERE id = ? @@ -2282,21 +2214,6 @@ func (q *Queries) SetDomainStatus(ctx context.Context, arg SetDomainStatusParams return err } -const setFrontendStatus = `-- name: SetFrontendStatus :exec -UPDATE frontends SET status = ?, last_error = ? WHERE id = ? -` - -type SetFrontendStatusParams struct { - Status string - LastError sql.NullString - ID int64 -} - -func (q *Queries) SetFrontendStatus(ctx context.Context, arg SetFrontendStatusParams) error { - _, err := q.db.ExecContext(ctx, setFrontendStatus, arg.Status, arg.LastError, arg.ID) - return err -} - const setUserPassword = `-- name: SetUserPassword :exec UPDATE users SET password_hash = ? WHERE id = ? ` @@ -2367,81 +2284,28 @@ func (q *Queries) TouchUserLogin(ctx context.Context, arg TouchUserLoginParams) return err } -const updateBackend = `-- name: UpdateBackend :exec -UPDATE backends SET name = ?, description = ?, mode = ?, algorithm = ?, port = ?, sticky_enabled = ?, sticky_cookie_name = ?, sticky_cookie_lifetime_s = ?, - proxy_protocol = ?, timeout_server_s = ?, hc_protocol = ?, hc_port = ?, hc_interval_s = ?, hc_timeout_s = ?, hc_retries = ?, hc_domain = ?, - hc_path = ?, hc_response = ?, hc_status_codes = ?, hc_tls_verify = ?, labels = ?, version = version + 1 WHERE id = ? +const updateBalancer = `-- name: UpdateBalancer :exec +UPDATE balancers SET name = ?, description = ?, algorithm = ?, labels = ?, maintenance = ?, version = version + 1, updated_at = ? WHERE id = ? ` -type UpdateBackendParams struct { - Name string - Description string - Mode string - Algorithm string - Port int64 - StickyEnabled int64 - StickyCookieName string - StickyCookieLifetimeS int64 - ProxyProtocol string - TimeoutServerS int64 - HcProtocol string - HcPort int64 - HcIntervalS int64 - HcTimeoutS int64 - HcRetries int64 - HcDomain string - HcPath string - HcResponse string - HcStatusCodes string - HcTlsVerify int64 - Labels string - ID int64 +type UpdateBalancerParams struct { + Name string + Description string + Algorithm string + Labels string + Maintenance int64 + UpdatedAt int64 + ID int64 } -func (q *Queries) UpdateBackend(ctx context.Context, arg UpdateBackendParams) error { - _, err := q.db.ExecContext(ctx, updateBackend, +func (q *Queries) UpdateBalancer(ctx context.Context, arg UpdateBalancerParams) error { + _, err := q.db.ExecContext(ctx, updateBalancer, arg.Name, arg.Description, - arg.Mode, arg.Algorithm, - arg.Port, - arg.StickyEnabled, - arg.StickyCookieName, - arg.StickyCookieLifetimeS, - arg.ProxyProtocol, - arg.TimeoutServerS, - arg.HcProtocol, - arg.HcPort, - arg.HcIntervalS, - arg.HcTimeoutS, - arg.HcRetries, - arg.HcDomain, - arg.HcPath, - arg.HcResponse, - arg.HcStatusCodes, - arg.HcTlsVerify, arg.Labels, - arg.ID, - ) - return err -} - -const updateBackendServer = `-- name: UpdateBackendServer :exec -UPDATE backend_servers SET weight = ?, backup = ?, admin_state = ? WHERE id = ? -` - -type UpdateBackendServerParams struct { - Weight int64 - Backup int64 - AdminState string - ID int64 -} - -func (q *Queries) UpdateBackendServer(ctx context.Context, arg UpdateBackendServerParams) error { - _, err := q.db.ExecContext(ctx, updateBackendServer, - arg.Weight, - arg.Backup, - arg.AdminState, + arg.Maintenance, + arg.UpdatedAt, arg.ID, ) return err @@ -2463,11 +2327,13 @@ func (q *Queries) UpdateCertificateMeta(ctx context.Context, arg UpdateCertifica } const updateDomain = `-- name: UpdateDomain :exec -UPDATE domains SET frontend_id = ?, dns_provider_id = ?, zone = ?, auto_records = ?, updated_at = ? WHERE id = ? +UPDATE domains SET balancer_id = ?, service_id = ?, https = ?, dns_provider_id = ?, zone = ?, auto_records = ?, updated_at = ? WHERE id = ? ` type UpdateDomainParams struct { - FrontendID sql.NullInt64 + BalancerID sql.NullInt64 + ServiceID sql.NullInt64 + Https int64 DnsProviderID sql.NullInt64 Zone string AutoRecords int64 @@ -2477,7 +2343,9 @@ type UpdateDomainParams struct { func (q *Queries) UpdateDomain(ctx context.Context, arg UpdateDomainParams) error { _, err := q.db.ExecContext(ctx, updateDomain, - arg.FrontendID, + arg.BalancerID, + arg.ServiceID, + arg.Https, arg.DnsProviderID, arg.Zone, arg.AutoRecords, @@ -2487,50 +2355,6 @@ func (q *Queries) UpdateDomain(ctx context.Context, arg UpdateDomainParams) erro return err } -const updateFrontend = `-- name: UpdateFrontend :exec -UPDATE frontends SET name = ?, description = ?, mode = ?, tls = ?, binds = ?, http_redirect = ?, default_backend_id = ?, timeout_client_s = ?, - maxconn = ?, forwarded_headers = ?, enabled = ?, maintenance = ?, labels = ?, version = version + 1, updated_at = ? WHERE id = ? -` - -type UpdateFrontendParams struct { - Name string - Description string - Mode string - Tls int64 - Binds string - HttpRedirect int64 - DefaultBackendID sql.NullInt64 - TimeoutClientS int64 - Maxconn int64 - ForwardedHeaders int64 - Enabled int64 - Maintenance int64 - Labels string - UpdatedAt int64 - ID int64 -} - -func (q *Queries) UpdateFrontend(ctx context.Context, arg UpdateFrontendParams) error { - _, err := q.db.ExecContext(ctx, updateFrontend, - arg.Name, - arg.Description, - arg.Mode, - arg.Tls, - arg.Binds, - arg.HttpRedirect, - arg.DefaultBackendID, - arg.TimeoutClientS, - arg.Maxconn, - arg.ForwardedHeaders, - arg.Enabled, - arg.Maintenance, - arg.Labels, - arg.UpdatedAt, - arg.ID, - ) - return err -} - const updateHost = `-- name: UpdateHost :exec UPDATE hosts SET name = ?, ipv4 = ?, ipv6 = ?, private_ipv4 = ?, labels = ?, notes = ? WHERE id = ? ` @@ -2582,3 +2406,77 @@ func (q *Queries) UpdateNodeInfo(ctx context.Context, arg UpdateNodeInfoParams) ) return err } + +const updateService = `-- name: UpdateService :exec +UPDATE services SET protocol = ?, listen_port = ?, destination_port = ?, proxy_protocol = ?, timeout_s = ?, sticky_enabled = ?, sticky_cookie_name = ?, + sticky_cookie_lifetime_s = ?, hc_protocol = ?, hc_port = ?, hc_interval_s = ?, hc_timeout_s = ?, hc_retries = ?, hc_domain = ?, hc_path = ?, + hc_response = ?, hc_status_codes = ?, hc_tls_verify = ?, version = version + 1 WHERE id = ? +` + +type UpdateServiceParams struct { + Protocol string + ListenPort int64 + DestinationPort int64 + ProxyProtocol string + TimeoutS int64 + StickyEnabled int64 + StickyCookieName string + StickyCookieLifetimeS int64 + HcProtocol string + HcPort int64 + HcIntervalS int64 + HcTimeoutS int64 + HcRetries int64 + HcDomain string + HcPath string + HcResponse string + HcStatusCodes string + HcTlsVerify int64 + ID int64 +} + +func (q *Queries) UpdateService(ctx context.Context, arg UpdateServiceParams) error { + _, err := q.db.ExecContext(ctx, updateService, + arg.Protocol, + arg.ListenPort, + arg.DestinationPort, + arg.ProxyProtocol, + arg.TimeoutS, + arg.StickyEnabled, + arg.StickyCookieName, + arg.StickyCookieLifetimeS, + arg.HcProtocol, + arg.HcPort, + arg.HcIntervalS, + arg.HcTimeoutS, + arg.HcRetries, + arg.HcDomain, + arg.HcPath, + arg.HcResponse, + arg.HcStatusCodes, + arg.HcTlsVerify, + arg.ID, + ) + return err +} + +const updateTarget = `-- name: UpdateTarget :exec +UPDATE targets SET weight = ?, backup = ?, admin_state = ? WHERE id = ? +` + +type UpdateTargetParams struct { + Weight int64 + Backup int64 + AdminState string + ID int64 +} + +func (q *Queries) UpdateTarget(ctx context.Context, arg UpdateTargetParams) error { + _, err := q.db.ExecContext(ctx, updateTarget, + arg.Weight, + arg.Backup, + arg.AdminState, + arg.ID, + ) + return err +} diff --git a/internal/store/db/models.go b/internal/store/db/models.go index af85274..57b2690 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -51,49 +51,20 @@ type AuditLog struct { After sql.NullString } -type Backend struct { - ID int64 - ProjectID int64 - NodeID int64 - Name string - Description string - Mode string - Algorithm string - Port int64 - StickyEnabled int64 - StickyCookieName string - StickyCookieLifetimeS int64 - ProxyProtocol string - TimeoutServerS int64 - HcProtocol string - HcPort int64 - HcIntervalS int64 - HcTimeoutS int64 - HcRetries int64 - HcDomain string - HcPath string - HcResponse string - HcStatusCodes string - HcTlsVerify int64 - Labels string - Version int64 - CreatedAt int64 -} - -type BackendServer struct { - ID int64 - BackendID int64 - Type string - HostID sql.NullInt64 - LabelSelector string - Ip string - Port sql.NullInt64 - UsePrivateIp int64 - Weight int64 - Backup int64 - AdminState string - Removing int64 - CreatedAt int64 +type Balancer struct { + ID int64 + ProjectID int64 + NodeID int64 + Name string + Description string + Algorithm string + Labels string + Maintenance int64 + Status string + LastError sql.NullString + Version int64 + CreatedAt int64 + UpdatedAt int64 } type Certificate struct { @@ -152,7 +123,9 @@ type Domain struct { ID int64 ProjectID int64 Fqdn string - FrontendID sql.NullInt64 + BalancerID sql.NullInt64 + ServiceID sql.NullInt64 + Https int64 DnsProviderID sql.NullInt64 Zone string AutoRecords int64 @@ -171,44 +144,13 @@ type Event struct { ID int64 Ts int64 ProjectID int64 - FrontendID sql.NullInt64 - BackendID sql.NullInt64 + BalancerID sql.NullInt64 Kind string Severity string Message string Data string } -type Frontend struct { - ID int64 - ProjectID int64 - NodeID int64 - Name string - Description string - Mode string - Tls int64 - Binds string - HttpRedirect int64 - DefaultBackendID sql.NullInt64 - TimeoutClientS int64 - Maxconn int64 - ForwardedHeaders int64 - Enabled int64 - Maintenance int64 - Labels string - Status string - LastError sql.NullString - Version int64 - CreatedAt int64 - UpdatedAt int64 -} - -type FrontendCertificate struct { - FrontendID int64 - CertificateID int64 - Position int64 -} - type Host struct { ID int64 ProjectID int64 @@ -275,21 +217,35 @@ type Project struct { CreatedAt int64 } -type Route struct { - ID int64 - FrontendID int64 - Position int64 - Hosts string - PathPrefix string - BackendID int64 +type Service struct { + ID int64 + BalancerID int64 + Protocol string + ListenPort int64 + DestinationPort int64 + ProxyProtocol string + TimeoutS int64 + StickyEnabled int64 + StickyCookieName string + StickyCookieLifetimeS int64 + HcProtocol string + HcPort int64 + HcIntervalS int64 + HcTimeoutS int64 + HcRetries int64 + HcDomain string + HcPath string + HcResponse string + HcStatusCodes string + HcTlsVerify int64 + Version int64 + CreatedAt int64 } -type ServerSlot struct { - ID int64 - BackendID int64 - ServerID int64 - HostID sql.NullInt64 - Address string +type ServiceCertificate struct { + ServiceID int64 + CertificateID int64 + Position int64 } type Session struct { @@ -307,6 +263,30 @@ type Setting struct { Value string } +type Target struct { + ID int64 + BalancerID int64 + Type string + HostID sql.NullInt64 + LabelSelector string + Ip string + Port sql.NullInt64 + UsePrivateIp int64 + Weight int64 + Backup int64 + AdminState string + Removing int64 + CreatedAt int64 +} + +type TargetSlot struct { + ID int64 + BalancerID int64 + TargetID int64 + HostID sql.NullInt64 + Address string +} + type User struct { ID int64 Email string diff --git a/internal/store/migrations/0002_projects.sql b/internal/store/migrations/0002_projects.sql new file mode 100644 index 0000000..da87b46 --- /dev/null +++ b/internal/store/migrations/0002_projects.sql @@ -0,0 +1,140 @@ +-- Projects (ADR 0006): a project is one load balancer with targets, services and domains. +-- The table is `balancers` because `projects` is the tenancy table. Frontend/backend +-- definitions from the previous model are dropped; domains are kept but detached. + +CREATE TABLE balancers ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL DEFAULT 1 REFERENCES projects(id) ON DELETE CASCADE, + node_id INTEGER NOT NULL DEFAULT 1 REFERENCES nodes(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + algorithm TEXT NOT NULL DEFAULT 'roundrobin' CHECK (algorithm IN ('roundrobin','leastconn','source','random')), + labels TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(labels)), + maintenance INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'applying', + last_error TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (project_id, name) +); + +CREATE TABLE services ( + id INTEGER PRIMARY KEY, + balancer_id INTEGER NOT NULL REFERENCES balancers(id) ON DELETE CASCADE, + protocol TEXT NOT NULL CHECK (protocol IN ('http','https','tcp')), + listen_port INTEGER NOT NULL UNIQUE, + destination_port INTEGER NOT NULL, + proxy_protocol TEXT NOT NULL DEFAULT 'none' CHECK (proxy_protocol IN ('none','v1','v2')), + timeout_s INTEGER NOT NULL DEFAULT 60, + sticky_enabled INTEGER NOT NULL DEFAULT 0, + sticky_cookie_name TEXT NOT NULL DEFAULT 'LOXYSTICKY', + sticky_cookie_lifetime_s INTEGER NOT NULL DEFAULT 300, + hc_protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (hc_protocol IN ('tcp','http','https')), + hc_port INTEGER NOT NULL DEFAULT 0, + hc_interval_s INTEGER NOT NULL DEFAULT 15, + hc_timeout_s INTEGER NOT NULL DEFAULT 10, + hc_retries INTEGER NOT NULL DEFAULT 3, + hc_domain TEXT NOT NULL DEFAULT '', + hc_path TEXT NOT NULL DEFAULT '/', + hc_response TEXT NOT NULL DEFAULT '', + hc_status_codes TEXT NOT NULL DEFAULT '["2??","3??"]' CHECK (json_valid(hc_status_codes)), + hc_tls_verify INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL +); +CREATE INDEX services_balancer ON services(balancer_id); + +CREATE TABLE service_certificates ( + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + certificate_id INTEGER NOT NULL REFERENCES certificates(id) ON DELETE RESTRICT, + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (service_id, certificate_id) +); +CREATE INDEX service_certificates_cert ON service_certificates(certificate_id); + +CREATE TABLE targets ( + id INTEGER PRIMARY KEY, + balancer_id INTEGER NOT NULL REFERENCES balancers(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('host','label_selector','ip')), + host_id INTEGER REFERENCES hosts(id) ON DELETE CASCADE, + label_selector TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + port INTEGER, + use_private_ip INTEGER NOT NULL DEFAULT 0, + weight INTEGER NOT NULL DEFAULT 100, + backup INTEGER NOT NULL DEFAULT 0, + admin_state TEXT NOT NULL DEFAULT 'ready' CHECK (admin_state IN ('ready','drain','maint')), + removing INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +CREATE INDEX targets_balancer ON targets(balancer_id); +CREATE INDEX targets_host ON targets(host_id); + +-- One row per resolved target address; id is the stable HAProxy server id. +CREATE TABLE target_slots ( + id INTEGER PRIMARY KEY, + balancer_id INTEGER NOT NULL REFERENCES balancers(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES targets(id) ON DELETE CASCADE, + host_id INTEGER, + address TEXT NOT NULL, + UNIQUE (target_id, host_id, address) +); +CREATE INDEX target_slots_balancer ON target_slots(balancer_id); + +-- Domains: re-created without the frontend reference. +CREATE TABLE domains_new ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + fqdn TEXT NOT NULL, + balancer_id INTEGER REFERENCES balancers(id) ON DELETE SET NULL, + service_id INTEGER REFERENCES services(id) ON DELETE SET NULL, + https INTEGER NOT NULL DEFAULT 1, + dns_provider_id INTEGER REFERENCES dns_providers(id) ON DELETE SET NULL, + zone TEXT NOT NULL DEFAULT '', + auto_records INTEGER NOT NULL DEFAULT 0, + ttl INTEGER NOT NULL DEFAULT 300, + status TEXT NOT NULL DEFAULT 'unknown', + status_detail TEXT, + observed TEXT NOT NULL DEFAULT '{"a":[],"aaaa":[]}' CHECK (json_valid(observed)), + checked_at INTEGER, + certificate_id INTEGER REFERENCES certificates(id) ON DELETE SET NULL, + managed_record_ids TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(managed_record_ids)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (project_id, fqdn) +); +INSERT INTO domains_new (id, project_id, fqdn, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, + certificate_id, managed_record_ids, created_at, updated_at) + SELECT id, project_id, fqdn, dns_provider_id, zone, auto_records, ttl, status, status_detail, observed, checked_at, + certificate_id, managed_record_ids, created_at, updated_at FROM domains; +DROP TABLE domains; +ALTER TABLE domains_new RENAME TO domains; +CREATE INDEX domains_balancer ON domains(balancer_id); + +-- Events: per-project instead of per-frontend/backend. +CREATE TABLE events_new ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + project_id INTEGER NOT NULL DEFAULT 1, + balancer_id INTEGER, + kind TEXT NOT NULL, + severity TEXT NOT NULL, + message TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(data)) +); +INSERT INTO events_new (id, ts, project_id, kind, severity, message, data) SELECT id, ts, project_id, kind, severity, message, data FROM events; +DROP TABLE events; +ALTER TABLE events_new RENAME TO events; +CREATE INDEX events_ts ON events(ts); +CREATE INDEX events_balancer ON events(balancer_id, ts); + +DROP TABLE routes; +DROP TABLE frontend_certificates; +DROP TABLE frontends; +DROP TABLE server_slots; +DROP TABLE backend_servers; +DROP TABLE backends; +DELETE FROM metrics_1m; +-- Health events are now per target of a project. +UPDATE alert_rules SET event_kinds = replace(event_kinds, 'server.health.changed', 'target.health.changed'); diff --git a/internal/store/queries/core.sql b/internal/store/queries/core.sql index 4011d98..3aa3216 100644 --- a/internal/store/queries/core.sql +++ b/internal/store/queries/core.sql @@ -77,65 +77,59 @@ UPDATE hosts SET name = ?, ipv4 = ?, ipv6 = ?, private_ipv4 = ?, labels = ?, not DELETE FROM hosts WHERE id = ?; -- ===== backends ===== --- name: ListBackends :many -SELECT * FROM backends ORDER BY id; --- name: CreateBackend :one -INSERT INTO backends (project_id, name, description, mode, algorithm, port, sticky_enabled, sticky_cookie_name, sticky_cookie_lifetime_s, - proxy_protocol, timeout_server_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, - hc_status_codes, hc_tls_verify, labels, created_at) -VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; --- name: UpdateBackend :exec -UPDATE backends SET name = ?, description = ?, mode = ?, algorithm = ?, port = ?, sticky_enabled = ?, sticky_cookie_name = ?, sticky_cookie_lifetime_s = ?, - proxy_protocol = ?, timeout_server_s = ?, hc_protocol = ?, hc_port = ?, hc_interval_s = ?, hc_timeout_s = ?, hc_retries = ?, hc_domain = ?, - hc_path = ?, hc_response = ?, hc_status_codes = ?, hc_tls_verify = ?, labels = ?, version = version + 1 WHERE id = ?; --- name: DeleteBackend :exec -DELETE FROM backends WHERE id = ?; +-- ===== projects (table balancers) ===== +-- name: ListBalancers :many +SELECT * FROM balancers ORDER BY id; +-- name: CreateBalancer :one +INSERT INTO balancers (project_id, name, description, algorithm, labels, status, created_at, updated_at) +VALUES (1, ?, ?, ?, ?, 'applying', ?, ?) RETURNING *; +-- name: UpdateBalancer :exec +UPDATE balancers SET name = ?, description = ?, algorithm = ?, labels = ?, maintenance = ?, version = version + 1, updated_at = ? WHERE id = ?; +-- name: SetBalancerStatus :exec +UPDATE balancers SET status = ?, last_error = ? WHERE id = ?; +-- name: DeleteBalancer :exec +DELETE FROM balancers WHERE id = ?; --- name: ListBackendServers :many -SELECT * FROM backend_servers ORDER BY id; --- name: CreateBackendServer :one -INSERT INTO backend_servers (backend_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, created_at) +-- name: ListServices :many +SELECT * FROM services ORDER BY balancer_id, id; +-- name: ListListenPorts :many +SELECT listen_port FROM services ORDER BY listen_port; +-- name: CreateService :one +INSERT INTO services (balancer_id, protocol, listen_port, destination_port, proxy_protocol, timeout_s, sticky_enabled, sticky_cookie_name, + sticky_cookie_lifetime_s, hc_protocol, hc_port, hc_interval_s, hc_timeout_s, hc_retries, hc_domain, hc_path, hc_response, hc_status_codes, + hc_tls_verify, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; +-- name: UpdateService :exec +UPDATE services SET protocol = ?, listen_port = ?, destination_port = ?, proxy_protocol = ?, timeout_s = ?, sticky_enabled = ?, sticky_cookie_name = ?, + sticky_cookie_lifetime_s = ?, hc_protocol = ?, hc_port = ?, hc_interval_s = ?, hc_timeout_s = ?, hc_retries = ?, hc_domain = ?, hc_path = ?, + hc_response = ?, hc_status_codes = ?, hc_tls_verify = ?, version = version + 1 WHERE id = ?; +-- name: DeleteService :exec +DELETE FROM services WHERE id = ?; +-- name: ListServiceCertificates :many +SELECT * FROM service_certificates ORDER BY service_id, position; +-- name: ClearServiceCertificates :exec +DELETE FROM service_certificates WHERE service_id = ?; +-- name: AddServiceCertificate :exec +INSERT INTO service_certificates (service_id, certificate_id, position) VALUES (?, ?, ?); + +-- name: ListTargets :many +SELECT * FROM targets ORDER BY id; +-- name: CreateTarget :one +INSERT INTO targets (balancer_id, type, host_id, label_selector, ip, port, use_private_ip, weight, backup, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; --- name: UpdateBackendServer :exec -UPDATE backend_servers SET weight = ?, backup = ?, admin_state = ? WHERE id = ?; --- name: MarkBackendServerRemoving :exec -UPDATE backend_servers SET removing = 1 WHERE id = ?; --- name: DeleteBackendServer :exec -DELETE FROM backend_servers WHERE id = ?; +-- name: UpdateTarget :exec +UPDATE targets SET weight = ?, backup = ?, admin_state = ? WHERE id = ?; +-- name: MarkTargetRemoving :exec +UPDATE targets SET removing = 1 WHERE id = ?; +-- name: DeleteTarget :exec +DELETE FROM targets WHERE id = ?; -- name: ListSlots :many -SELECT * FROM server_slots ORDER BY id; +SELECT * FROM target_slots ORDER BY id; -- name: CreateSlot :one -INSERT INTO server_slots (backend_id, server_id, host_id, address) VALUES (?, ?, ?, ?) RETURNING id; +INSERT INTO target_slots (balancer_id, target_id, host_id, address) VALUES (?, ?, ?, ?) RETURNING id; -- name: DeleteSlot :exec -DELETE FROM server_slots WHERE id = ?; - --- ===== frontends ===== --- name: ListFrontends :many -SELECT * FROM frontends ORDER BY id; --- name: CreateFrontend :one -INSERT INTO frontends (project_id, name, description, mode, tls, binds, http_redirect, default_backend_id, timeout_client_s, maxconn, - forwarded_headers, enabled, labels, status, created_at, updated_at) -VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'applying', ?, ?) RETURNING *; --- name: UpdateFrontend :exec -UPDATE frontends SET name = ?, description = ?, mode = ?, tls = ?, binds = ?, http_redirect = ?, default_backend_id = ?, timeout_client_s = ?, - maxconn = ?, forwarded_headers = ?, enabled = ?, maintenance = ?, labels = ?, version = version + 1, updated_at = ? WHERE id = ?; --- name: SetFrontendStatus :exec -UPDATE frontends SET status = ?, last_error = ? WHERE id = ?; --- name: DeleteFrontend :exec -DELETE FROM frontends WHERE id = ?; --- name: ListRoutes :many -SELECT * FROM routes ORDER BY frontend_id, position; --- name: ClearRoutes :exec -DELETE FROM routes WHERE frontend_id = ?; --- name: AddRoute :exec -INSERT INTO routes (frontend_id, position, hosts, path_prefix, backend_id) VALUES (?, ?, ?, ?, ?); --- name: ListFrontendCertificates :many -SELECT * FROM frontend_certificates ORDER BY frontend_id, position; --- name: ClearFrontendCertificates :exec -DELETE FROM frontend_certificates WHERE frontend_id = ?; --- name: AddFrontendCertificate :exec -INSERT INTO frontend_certificates (frontend_id, certificate_id, position) VALUES (?, ?, ?); +DELETE FROM target_slots WHERE id = ?; -- ===== certificates / acme / dns / domains ===== -- name: ListCertificates :many @@ -172,9 +166,10 @@ SELECT * FROM domains ORDER BY fqdn; -- name: GetDomain :one SELECT * FROM domains WHERE id = ?; -- name: CreateDomain :one -INSERT INTO domains (project_id, fqdn, frontend_id, dns_provider_id, zone, auto_records, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?) RETURNING *; +INSERT INTO domains (project_id, fqdn, balancer_id, service_id, https, dns_provider_id, zone, auto_records, created_at, updated_at) +VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: UpdateDomain :exec -UPDATE domains SET frontend_id = ?, dns_provider_id = ?, zone = ?, auto_records = ?, updated_at = ? WHERE id = ?; +UPDATE domains SET balancer_id = ?, service_id = ?, https = ?, dns_provider_id = ?, zone = ?, auto_records = ?, updated_at = ? WHERE id = ?; -- name: SetDomainStatus :exec UPDATE domains SET status = ?, status_detail = ?, observed = ?, checked_at = ? WHERE id = ?; -- name: SetDomainCertificate :exec @@ -194,10 +189,9 @@ FROM config_revisions WHERE node_id = ? ORDER BY revision DESC LIMIT 100; -- name: MaxRevision :one SELECT CAST(coalesce(max(revision), 0) AS INTEGER) FROM config_revisions WHERE node_id = ?; -- name: CreateEvent :one -INSERT INTO events (ts, frontend_id, backend_id, kind, severity, message, data) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id; +INSERT INTO events (ts, balancer_id, kind, severity, message, data) VALUES (?, ?, ?, ?, ?, ?) RETURNING id; -- name: ListEvents :many -SELECT * FROM events WHERE (sqlc.narg(frontend_id) IS NULL OR frontend_id = sqlc.narg(frontend_id)) - AND (sqlc.narg(backend_id) IS NULL OR backend_id = sqlc.narg(backend_id)) +SELECT * FROM events WHERE (sqlc.narg(balancer_id) IS NULL OR balancer_id = sqlc.narg(balancer_id)) AND (sqlc.narg(kind) IS NULL OR kind = sqlc.narg(kind)) ORDER BY id DESC LIMIT sqlc.arg(max_rows); -- name: CreateAudit :exec diff --git a/internal/store/store_test.go b/internal/store/store_test.go index bd23bec..5d2a92d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -24,7 +24,7 @@ func TestMigrateSeedAndQueries(t *testing.T) { t.Fatal(err) } for _, fe := range []int64{1, 2} { - if _, err := s.Q.CreateEvent(ctx, db.CreateEventParams{Ts: 1, FrontendID: sql.NullInt64{Int64: fe, Valid: true}, Kind: "k", Severity: "info", Message: "m", Data: "{}"}); err != nil { + if _, err := s.Q.CreateEvent(ctx, db.CreateEventParams{Ts: 1, BalancerID: sql.NullInt64{Int64: fe, Valid: true}, Kind: "k", Severity: "info", Message: "m", Data: "{}"}); err != nil { t.Fatal(err) } } @@ -32,8 +32,8 @@ func TestMigrateSeedAndQueries(t *testing.T) { if err != nil || len(all) != 2 { t.Fatalf("all: %v %d", err, len(all)) } - one, err := s.Q.ListEvents(ctx, db.ListEventsParams{FrontendID: int64(2), MaxRows: 10}) - if err != nil || len(one) != 1 || one[0].FrontendID.Int64 != 2 { + one, err := s.Q.ListEvents(ctx, db.ListEventsParams{BalancerID: int64(2), MaxRows: 10}) + if err != nil || len(one) != 1 || one[0].BalancerID.Int64 != 2 { t.Fatalf("filtered: %v %+v", err, one) } } @@ -52,3 +52,52 @@ func TestRefusesLegacySchema(t *testing.T) { t.Fatalf("expected legacy-schema error, got %v", err) } } + +// Upgrading a database from the frontend/backend schema (0001 only) keeps domains, hosts and +// certificates and creates the project tables (migration 0002). +func TestMigrateFromFrontendBackendSchema(t *testing.T) { + file := filepath.Join(t.TempDir(), "v1.db") + d, err := sql.Open("sqlite", "file:"+file+"?_pragma=foreign_keys(1)") + if err != nil { + t.Fatal(err) + } + v1, err := migrations.ReadFile("migrations/0001_init.sql") + if err != nil { + t.Fatal(err) + } + for _, stmt := range []string{string(v1), + `CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`, + `INSERT INTO schema_migrations VALUES ('0001_init.sql', 1)`, + `INSERT INTO projects (id, name, created_at) VALUES (1, 'default', 1)`, + `INSERT INTO nodes (id, name) VALUES (1, 'local')`, + `INSERT INTO hosts (project_id, name, ipv4) VALUES (1, 'web-1', '10.0.0.1')`, + `INSERT INTO backends (project_id, name, mode, port, created_at) VALUES (1, 'b', 'http', 80, 1)`, + `INSERT INTO frontends (project_id, name, mode, binds, default_backend_id, created_at, updated_at) VALUES (1, 'f', 'http', '[{"address":"*","port":80}]', 1, 1, 1)`, + `INSERT INTO domains (project_id, fqdn, frontend_id, created_at, updated_at) VALUES (1, 'app.example.com', 1, 1, 1)`, + `INSERT INTO events (ts, frontend_id, kind, severity, message) VALUES (1, 1, 'k', 'info', 'm')`, + } { + if _, err := d.Exec(stmt); err != nil { + t.Fatalf("%v: %.60s", err, stmt) + } + } + d.Close() + s, err := Open(file) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + doms, err := s.Q.ListDomains(ctx) + if err != nil || len(doms) != 1 || doms[0].Fqdn != "app.example.com" || doms[0].BalancerID.Valid || doms[0].Https != 1 { + t.Fatalf("domains after migration: %v %+v", err, doms) + } + if hosts, err := s.Q.ListHosts(ctx); err != nil || len(hosts) != 1 { + t.Fatalf("hosts: %v %d", err, len(hosts)) + } + if evs, err := s.Q.ListEvents(ctx, db.ListEventsParams{MaxRows: 10}); err != nil || len(evs) != 1 { + t.Fatalf("events: %v %d", err, len(evs)) + } + if _, err := s.Q.ListBalancers(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/test/e2e/domains.spec.ts b/test/e2e/domains.spec.ts index ec2c62f..2f632cb 100644 --- a/test/e2e/domains.spec.ts +++ b/test/e2e/domains.spec.ts @@ -3,39 +3,38 @@ import { test, expect } from '@playwright/test'; const email = process.env.LOXY_EMAIL ?? 'admin@loxy.local'; const password = process.env.LOXY_PASSWORD ?? ''; -test('domains: add a domain, see required records and verification status (§8.9)', async ({ page, request }) => { +test('domains: attach a domain to a project, see DNS status, remove it', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/dashboard/); - // A frontend to attach the domain to (created through the API with the session's CSRF token). + // A project to attach the domain to, created through the API with the session's CSRF token. const me = await (await page.request.get('/api/v1/auth/me')).json(); const h = { 'X-LOXY-CSRF': me.csrf_token }; const tag = Date.now().toString(36); - const be = await (await page.request.post('/api/v1/backends', { headers: h, data: { name: `e2e-dom-${tag}`, mode: 'http', port: 9001 } })).json(); - const fe = await (await page.request.post('/api/v1/frontends', { headers: h, - data: { name: `e2e-dom-${tag}`, mode: 'http', binds: [{ address: '*', port: 19000 + Math.floor(Math.random() * 500) }], default_backend_id: be.backend.id } })).json(); - expect(fe.frontend?.id).toBeTruthy(); + const created = await (await page.request.post('/api/v1/projects', { headers: h, + data: { name: `e2e-dom-${tag}`, service: { protocol: 'http', destination_port: 9001 } } })).json(); + const pid = created.project?.id; + expect(pid).toBeTruthy(); const fqdn = `e2e-${tag}.example.invalid`; - await page.goto('/domains'); - await page.getByRole('button', { name: 'Add domain' }).first().click(); + await page.goto(`/projects/${pid}/domains`); await page.getByLabel('Domain name').fill(fqdn); - await page.getByLabel('Frontend').selectOption({ label: `e2e-dom-${tag} (HTTP)` }); - await page.getByRole('dialog').getByRole('button', { name: 'Add domain' }).click(); - const row = page.getByRole('button', { name: fqdn }); - await expect(row).toBeVisible(); - // .invalid never resolves → "Missing" (or a resolver error), never "Resolves correctly". - await expect(page.getByRole('row', { name: new RegExp(fqdn.replace(/\./g, '\\.')) })).not.toContainText('Resolves correctly'); - await expect(page.getByText('Check now')).toBeVisible(); - await expect(page.getByRole('button', { name: /Issue certificate/ })).toBeDisabled(); - // clean up through the API UI flow - await page.getByRole('button', { name: 'Remove domain' }).click(); - await page.getByRole('dialog').last().getByRole('button', { name: /Remove/ }).last().click(); - await expect(page.getByRole('button', { name: fqdn })).toHaveCount(0); - await expect.poll(async () => (await page.request.delete(`/api/v1/frontends/${fe.frontend.id}`, { headers: h })).status(), { timeout: 10_000 }).toBe(204); - expect((await page.request.delete(`/api/v1/backends/${be.backend.id}`, { headers: h })).status()).toBe(204); - void request; + await page.getByRole('button', { name: 'Add domain' }).click(); + + const card = page.locator('details', { hasText: fqdn }); + await expect(card).toBeVisible(); + // .invalid never resolves → "No record yet" (or a resolver error), never "Resolves correctly". + await expect(card).not.toContainText('Resolves correctly'); + await expect(card.getByRole('button', { name: 'Check now' })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Issue certificate' })).toBeDisabled(); + await expect(card.getByText('DNS records to create')).toBeVisible(); + + await card.getByRole('button', { name: 'Remove domain' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Remove domain' }).click(); + await expect(page.locator('details', { hasText: fqdn })).toHaveCount(0); + + expect((await page.request.delete(`/api/v1/projects/${pid}`, { headers: h })).status()).toBe(204); }); diff --git a/test/e2e/panel.spec.ts b/test/e2e/panel.spec.ts index 3332eb8..8f128ec 100644 --- a/test/e2e/panel.spec.ts +++ b/test/e2e/panel.spec.ts @@ -6,6 +6,7 @@ const serverIP = process.env.LOXY_SERVER_IP ?? '10.0.0.10'; const haHost = process.env.LOXY_HAPROXY_HOST ?? '127.0.0.1'; const name = `e2e-${Date.now().toString(36)}`; const port = 18000 + Math.floor(Math.random() * 1000); +const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); async function login(page: Page) { await page.goto('/login'); @@ -15,47 +16,45 @@ async function login(page: Page) { await expect(page).toHaveURL(/dashboard/); } -test('backend + frontend: create, serve traffic, maintenance, delete', async ({ page, request }) => { +async function actions(page: Page, item: string) { + await page.getByRole('button', { name: 'Actions', exact: true }).click(); + await page.getByRole('menuitem', { name: item }).click(); +} + +test('project: create via wizard, serve traffic, maintenance, delete', async ({ page, request }) => { await login(page); const status = async () => (await request.get(`http://${haHost}:${port}/`).catch(() => null))?.status(); - // Backend (§8.4) with one IP server (§8.6). - await page.goto('/backends/new'); - await page.getByLabel('Name').fill(name); - await page.getByLabel('Default server port').fill('9001'); - await page.getByRole('button', { name: 'Create backend' }).click(); - await expect(page).toHaveURL(/backends\/\d+/); - await page.getByRole('button', { name: 'Add servers' }).first().click(); + // Wizard: name, one IP target answering on :9001, HTTP service on an explicit port. + await page.goto('/projects/new'); + await page.getByLabel('Name', { exact: true }).fill(name); await page.getByLabel('Addresses, one per line').fill(serverIP); - await page.getByRole('dialog').getByRole('button', { name: 'Add server' }).click(); - await expect(page.getByText(serverIP).first()).toBeVisible(); - await expect(page.getByText('Healthy').first()).toBeVisible({ timeout: 30_000 }); - - // Frontend (§8.2) on a free port, default backend = the new backend. - await page.goto('/frontends/new'); - await page.getByLabel('Name').fill(name); - await page.getByLabel('Port').first().fill(String(port)); - await page.getByLabel('Default backend').selectOption({ label: `${name} · :9001` }); - await page.getByRole('button', { name: 'Create frontend' }).click(); - await expect(page).toHaveURL(/frontends\/\d+/); + await page.getByLabel('Public port', { exact: true }).fill(String(port)); + await page.getByLabel('Server port', { exact: true }).fill('9001'); + await page.getByRole('button', { name: 'Create project' }).click(); + await expect(page).toHaveURL(/\/projects\/\d+\/overview$/); + const base = page.url().replace(/\/overview$/, ''); + + // Targets tab: the IP target turns healthy. + await page.goto(`${base}/targets`); + const row = page.getByRole('row', { name: new RegExp(`${esc(serverIP)}(?!\\d)`) }).first(); + await expect(row).toBeVisible(); + await expect(row).toContainText('Healthy', { timeout: 30_000 }); await expect.poll(status, { timeout: 10_000 }).toBe(200); // Maintenance → 503, then off again. - await page.getByRole('button', { name: 'Actions', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Turn maintenance on' }).click(); + await actions(page, 'Turn maintenance on'); await page.getByRole('dialog').getByRole('button', { name: 'Turn maintenance on' }).click(); await expect.poll(status, { timeout: 5_000 }).toBe(503); - await page.getByRole('button', { name: 'Actions', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Turn maintenance off' }).click(); + await actions(page, 'Turn maintenance off'); await page.getByRole('dialog').getByRole('button', { name: 'Turn maintenance off' }).click(); await expect.poll(status, { timeout: 5_000 }).toBe(200); - // Delete with typed confirmation; the backend is freed and can be deleted too. - await page.getByRole('button', { name: 'Actions', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Delete…' }).click(); + // Delete with typed confirmation; the port stops answering. + await actions(page, 'Delete…'); await page.getByLabel(/Type .* to confirm/).fill(name); - await page.getByRole('dialog').getByRole('button', { name: 'Delete frontend' }).click(); - await expect(page).toHaveURL(/\/frontends$/); - await expect(page.getByText(name)).toHaveCount(0); + await page.getByRole('dialog').getByRole('button', { name: 'Delete project' }).click(); + await expect(page).toHaveURL(/\/projects$/); + await expect(page.getByRole('link', { name, exact: true })).toHaveCount(0); await expect.poll(async () => (await request.get(`http://${haHost}:${port}/`).catch(() => null)) === null, { timeout: 10_000 }).toBe(true); }); diff --git a/test/integration/acme_test.go b/test/integration/acme_test.go index 5867d16..756275a 100644 --- a/test/integration/acme_test.go +++ b/test/integration/acme_test.go @@ -17,8 +17,8 @@ import ( "github.com/2shiraz/loxy/internal/config" ) -// TestACMEHTTP01Pebble: TLS frontend with a managed certificate → lego HTTP-01 via the -// virt@acme_http01 map → Pebble-issued certificate served on :443 and 301 on :80 (§8.7, §11.2). +// TestACMEHTTP01Pebble: https service with a managed certificate → lego HTTP-01 via the +// virt@acme_http01 map → Pebble-issued certificate served on the service port, sys_http 404 for unknown hosts (§8.7, §11.2). // Needs `pebble` and `pebble-challtestsrv` on PATH (go install github.com/letsencrypt/pebble/v2/cmd/...). func TestACMEHTTP01Pebble(t *testing.T) { dockerOK(t) @@ -65,15 +65,11 @@ func TestACMEHTTP01Pebble(t *testing.T) { t.Fatalf("cert %d %v", st, body) } certID := body["certificate"].(map[string]any)["id"].(float64) - st, body = s.c.do("POST", "/backends", map[string]any{"name": "app", "mode": "http", "port": 9001}) + // An https service with the managed certificate; HTTP-01 is answered by the shared sys_http on :80. + st, body = s.c.do("POST", "/projects", map[string]any{"name": "tls", + "service": map[string]any{"protocol": "https", "listen_port": 8443, "destination_port": 9001, "certificates": []float64{certID}}}) if st != 201 { - t.Fatalf("backend %d %v", st, body) - } - beID := body["backend"].(map[string]any)["id"].(float64) - st, body = s.c.do("POST", "/frontends", map[string]any{"name": "tls", "mode": "http", "tls": true, "http_redirect": true, - "certificates": []float64{certID}, "binds": []any{map[string]any{"address": "*", "port": 443}}, "default_backend_id": beID}) - if st != 201 { - t.Fatalf("frontend %d %v", st, body) + t.Fatalf("project %d %v", st, body) } start := time.Now() var status string @@ -89,7 +85,7 @@ func TestACMEHTTP01Pebble(t *testing.T) { t.Logf("issued after %v", time.Since(start)) var issuer string waitFor(t, 15*time.Second, "issued cert served on :443", func() bool { - conn, err := tls.Dial("tcp", s.haIP+":443", &tls.Config{ServerName: "app.loxy.test", InsecureSkipVerify: true}) //nolint:gosec + conn, err := tls.Dial("tcp", s.haIP+":8443", &tls.Config{ServerName: "app.loxy.test", InsecureSkipVerify: true}) //nolint:gosec if err != nil { return false } @@ -97,14 +93,14 @@ func TestACMEHTTP01Pebble(t *testing.T) { issuer = conn.ConnectionState().PeerCertificates[0].Issuer.CommonName return strings.Contains(strings.ToLower(issuer), "pebble") }) - hc := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} - r, err := hc.Get("http://" + s.haIP + "/") + // sys_http answers unknown hosts with 404 (no domains attached). + r, err := http.Get("http://" + s.haIP + "/") if err != nil { t.Fatal(err) } r.Body.Close() - if r.StatusCode != 301 { - t.Fatalf("redirect: %d", r.StatusCode) + if r.StatusCode != 404 { + t.Fatalf("sys_http unknown host: %d", r.StatusCode) } revs, _ := s.a.Store.Q.ListRevisions(ctx, 1) t.Logf("served cert issuer %q; revisions: %d", issuer, len(revs)) diff --git a/test/integration/apply_test.go b/test/integration/apply_test.go index 0da9740..4af1437 100644 --- a/test/integration/apply_test.go +++ b/test/integration/apply_test.go @@ -130,8 +130,7 @@ func startStack(t *testing.T, tweak func(*config.Config)) *stack { } // TestApplyPathZeroDowntime drives the real API + reconciler against HAProxy 3.4: -// create a backend + frontend (reload), add/remove servers (runtime, no reload), repeated -// reloads under load. +// create a project (reload), add/remove targets (runtime, no reload), repeated reloads under load. func TestApplyPathZeroDowntime(t *testing.T) { s := startStack(t, nil) a, c, dir, haIP := s.a, s.c, s.dir, s.haIP @@ -139,23 +138,14 @@ func TestApplyPathZeroDowntime(t *testing.T) { b1, b2 := echoServer(t, dir, "one"), echoServer(t, dir, "two") var st int var body map[string]any - beSpec := func(alg string) map[string]any { - return map[string]any{"name": "it", "mode": "http", "port": 9001, "algorithm": alg, - "health_check": map[string]any{"protocol": "http", "interval_s": 3, "timeout_s": 1, "retries": 1, "http": map[string]any{"path": "/"}}} - } - st, body = c.do("POST", "/backends", beSpec("roundrobin")) - if st != 201 { - t.Fatalf("create backend %d %v", st, body) - } - beID := int64(body["backend"].(map[string]any)["id"].(float64)) - if st, body = c.do("POST", fmt.Sprintf("/backends/%d/servers", beID), map[string]any{"servers": []any{map[string]any{"type": "ip", "ip": b1}}}); st != 201 { - t.Fatalf("add server %d %v", st, body) - } - st, body = c.do("POST", "/frontends", map[string]any{"name": "it", "mode": "http", "binds": []any{map[string]any{"address": "*", "port": 8080}}, - "default_backend_id": beID}) + st, body = c.do("POST", "/projects", map[string]any{"name": "it", + "service": map[string]any{"protocol": "http", "listen_port": 8080, "destination_port": 9001, + "health_check": map[string]any{"protocol": "http", "interval_s": 3, "timeout_s": 1, "retries": 1, "http": map[string]any{"path": "/"}}}, + "targets": []any{map[string]any{"type": "ip", "ip": b1}}}) if st != 201 { - t.Fatalf("create frontend %d %v", st, body) + t.Fatalf("create project %d %v", st, body) } + pid := int64(body["project"].(map[string]any)["id"].(float64)) apply := func(wantKind string) { t.Helper() if err := a.Rec.Apply(ctx, "test"); err != nil { @@ -213,20 +203,20 @@ func TestApplyPathZeroDowntime(t *testing.T) { } }() } - // Runtime: add and remove a server under load — no reload allowed. - st, body = c.do("POST", fmt.Sprintf("/backends/%d/servers", beID), map[string]any{"servers": []any{map[string]any{"type": "ip", "ip": b2}}}) + // Runtime: add and remove a target under load — no reload allowed. + st, body = c.do("POST", fmt.Sprintf("/projects/%d/targets", pid), map[string]any{"targets": []any{map[string]any{"type": "ip", "ip": b2}}}) if st != 201 { - t.Fatalf("add server %d %v", st, body) + t.Fatalf("add target %d %v", st, body) } apply("runtime") time.Sleep(4 * time.Second) // let the new server pass its first check - sid := int64(body["servers"].([]any)[0].(map[string]any)["id"].(float64)) - st, _ = c.do("DELETE", fmt.Sprintf("/backends/%d/servers/%d", beID, sid), nil) - if st != 204 && st != 202 { - t.Fatalf("delete server %d", st) + tid := int64(body["targets"].([]any)[0].(map[string]any)["id"].(float64)) + st, _ = c.do("DELETE", fmt.Sprintf("/projects/%d/targets/%d", pid, tid), nil) + if st != 202 { + t.Fatalf("delete target %d", st) } apply("runtime") // drain - waitFor(t, 10*time.Second, "server removed", func() bool { return a.Snap.Get().Backend(beID).Server(sid) == nil }) + waitFor(t, 10*time.Second, "target removed", func() bool { return a.Snap.Get().Project(pid).Target(tid) == nil }) apply("runtime") // del server // Repeated reloads under load (algorithm flip forces a reload). reloads := 20 @@ -235,7 +225,7 @@ func TestApplyPathZeroDowntime(t *testing.T) { } algs := []string{"leastconn", "roundrobin"} for i := 0; i < reloads; i++ { - if st, b := c.do("PUT", fmt.Sprintf("/backends/%d", beID), beSpec(algs[i%2])); st != 200 { + if st, b := c.do("PATCH", fmt.Sprintf("/projects/%d", pid), map[string]any{"algorithm": algs[i%2]}); st != 200 { t.Fatalf("patch %d %v", st, b) } apply("reload") diff --git a/test/integration/verify_test.go b/test/integration/verify_test.go index bff6d5f..b59c0ea 100644 --- a/test/integration/verify_test.go +++ b/test/integration/verify_test.go @@ -38,17 +38,17 @@ frontend fake_200_str from loxy_http ` func ipServer(id int64, ip string) *state.Server { - return &state.Server{BackendServer: db.BackendServer{ID: id, Type: "ip", Ip: ip, Weight: 100, AdminState: "ready"}, + return &state.Server{Target: db.Target{ID: id, Type: "ip", Ip: ip, Weight: 100, AdminState: "ready"}, Slots: []*state.Slot{{ID: id, ServerID: id, Address: ip}}} } // backend returns a backend on `port` (tcp health checks, 1 s interval) with the given servers. func backend(id int64, mode string, port int64, servers ...*state.Server) *state.Backend { - b := &state.Backend{Backend: db.Backend{ID: id, Name: fmt.Sprintf("b%d", id), Mode: mode, Algorithm: "roundrobin", Port: port, ProxyProtocol: "none", + b := &state.Backend{BackendCfg: state.BackendCfg{ID: id, Name: fmt.Sprintf("b%d", id), Mode: mode, Algorithm: "roundrobin", Port: port, ProxyProtocol: "none", TimeoutServerS: 60, HcProtocol: "tcp", HcPort: port, HcIntervalS: 1, HcTimeoutS: 1, HcRetries: 1, HcPath: "/", StickyCookieName: "LOXYSTICKY", StickyCookieLifetimeS: 300}, StatusCodes: []string{"2??", "3??"}} for _, sv := range servers { - sv.BackendID = id + sv.BalancerID = id b.Servers = append(b.Servers, sv) } return b @@ -56,7 +56,7 @@ func backend(id int64, mode string, port int64, servers ...*state.Server) *state // frontend returns an enabled frontend on *:port whose default backend is beID. func frontend(id int64, mode string, port int64, beID int64) *state.Frontend { - return &state.Frontend{Frontend: db.Frontend{ID: id, Name: fmt.Sprintf("f%d", id), Mode: mode, TimeoutClientS: 60, Maxconn: 1000, Enabled: 1, + return &state.Frontend{FrontendCfg: state.FrontendCfg{ID: id, Name: fmt.Sprintf("f%d", id), Mode: mode, TimeoutClientS: 60, Maxconn: 1000, Enabled: 1, DefaultBackendID: sql.NullInt64{Int64: beID, Valid: beID != 0}}, Binds: []state.Bind{{Address: "*", Port: port}}} } @@ -149,7 +149,7 @@ func TestDynamicServers(t *testing.T) { e := startHAProxy(t, input([]*state.Frontend{frontend(1, "http", 8080, 1)}, b), fakes) ctx := context.Background() sv := ipServer(7, "127.0.0.3") - sv.BackendID = 1 + sv.BalancerID = 1 be := render.BackendName(1) name := render.ServerName(sv, sv.Slots[0]) for _, cmd := range []string{ @@ -191,8 +191,8 @@ func TestVerifySSLRuntimeAndMaps(t *testing.T) { plainBE := backend(2, "http", 19000, ipServer(2, "127.0.0.3")) tlsFE := frontend(1, "http", 8443+1, 1) tlsFE.Tls, tlsFE.CertIDs = 1, []int64{5} - in := input([]*state.Frontend{tlsFE, frontend(2, "http", 80, 2)}, tlsBE, plainBE) - in.ACMEHTTP01 = map[int64]bool{2: true} + in := input([]*state.Frontend{tlsFE, frontend(2, "http", 8080, 2)}, tlsBE, plainBE) + in.Domains = []render.DomainRoute{{Host: "shop.test", BackendID: 2}} in.Cert = func(id int64) *state.Cert { if id == 5 { return cert @@ -226,17 +226,29 @@ func TestVerifySSLRuntimeAndMaps(t *testing.T) { t.Fatalf("%s: %v", st.cmd, err) } } - // Maintenance map: opt@ prefix is not part of the name. - if err := e.rt.Run(ctx, "add map "+render.MaintMapPath(live)+" 2 1"); err != nil { - t.Fatal(err) + get := func(port int, host string) int { + t.Helper() + req, _ := http.NewRequest("GET", fmt.Sprintf("http://%s:%d/", e.ip, port), nil) + req.Host = host + r, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + return r.StatusCode } - r, err := http.Get(fmt.Sprintf("http://%s:80/", e.ip)) - if err != nil { + // Domains on the shared sys_http: known host is routed, unknown host gets 404. + waitFor(t, 10*time.Second, "domain routed", func() bool { return get(80, "shop.test") == 200 }) + if c := get(80, "other.test"); c != 404 { + t.Fatalf("unknown host: %d", c) + } + // Maintenance map (opt@ prefix is not part of the name) applies to the service port + // (frontend rule) and to domain traffic (backend rule). + if err := e.rt.Run(ctx, "add map "+render.MaintMapPath(live)+" 2 1"); err != nil { t.Fatal(err) } - r.Body.Close() - if r.StatusCode != 503 { - t.Fatalf("maintenance not active: %d", r.StatusCode) + if a, b := get(8080, "x"), get(80, "shop.test"); a != 503 || b != 503 { + t.Fatalf("maintenance not active: port %d, domain %d", a, b) } if err := e.rt.Run(ctx, "del map "+render.MaintMapPath(live)+" 2"); err != nil { t.Fatal(err) @@ -245,7 +257,7 @@ func TestVerifySSLRuntimeAndMaps(t *testing.T) { if err := e.rt.Run(ctx, "add map virt@acme_http01 tok123 tok123.keyauth"); err != nil { t.Fatal(err) } - r, err = http.Get(fmt.Sprintf("http://%s:80/.well-known/acme-challenge/tok123", e.ip)) + r, err := http.Get(fmt.Sprintf("http://%s:80/.well-known/acme-challenge/tok123", e.ip)) if err != nil { t.Fatal(err) } diff --git a/web/DESIGN.md b/web/DESIGN.md new file mode 100644 index 0000000..ddd227d --- /dev/null +++ b/web/DESIGN.md @@ -0,0 +1,69 @@ +# Loxy UI — design brief + +**Surface mode:** Operate. People come here to put a service online, see whether traffic flows, and fix whatever is red. Everything serves that loop. + +## Direction: "Patch panel" + +A calm, precise traffic console. The vocabulary comes from the rack: cool porcelain and graphite surfaces, one **fibre-aqua** signal colour (the jacket colour of OM3 fibre patch cords) that means *traffic and action*, and status colours (green, amber, red) reserved strictly for health. Type is a plain technical grotesk for UI and a monospace only for things a person would type into a terminal: addresses, ports, hostnames, config. + +The one memorable element is the **route strip** on a project's overview: entry points → Loxy → targets, drawn as an actual route whose flow moves only while requests are flowing, labelled with live req/s. It answers "how do I reach it, and is it working?" in a single glance. Everything around it is quiet. + +Rules we hold ourselves to: +- Plain words. "Targets: the servers that receive traffic." No internal jargon on primary screens; HAProxy terms only in advanced places. +- Every empty state names the next step and offers the button for it. +- Every URL, hostname and DNS value has a copy button. +- Live, not twitchy: values update in place, tween briefly, never shift layout (tabular numerals, reserved widths). +- No eyebrows, no gradient text, no decorative glass, no hero-metric template, no card-in-card. + +## Colour tokens + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--bg` | `#F3F5F6` | `#0A1013` | page | +| `--surface` | `#FFFFFF` | `#10181C` | panels | +| `--surface-2` | `#EDF0F2` | `#162126` | hover, wells, segmented controls | +| `--surface-3` | `#E2E7EA` | `#1D2A30` | pressed, skeleton | +| `--border` | `#E0E5E8` | `#1E2B31` | hairlines | +| `--border-strong` | `#CBD3D8` | `#2C3B42` | inputs | +| `--text` | `#0E1A1F` | `#E3EBEE` | primary ink | +| `--text-2` | `#3B4A51` | `#B3C2C8` | secondary | +| `--muted` | `#5D6C73` | `#8394A0` | tertiary (AA on bg and surface) | +| `--accent` | `#0B6F80` | `#47C6D8` | fibre aqua: primary action, live flow, focus | +| `--ok` | `#267436` | `#4CC273` | healthy | +| `--warn` | `#9A5B00` | `#E8A63A` | degraded, applying | +| `--err` | `#C2333A` | `#F06A6E` | unhealthy, failed | + +All text tokens are ≥ 4.5:1 on `--bg`, `--surface` and `--surface-2` in both themes. Status is never colour-only: every status also has a shape or an icon and a word. + +## Type + +- **Hanken Grotesk** (variable, self-hosted, OFL) for all UI text: a plain, slightly technical grotesk with open apertures that stays legible at 12–13 px. +- **JetBrains Mono** (variable, self-hosted, OFL) for addresses, ports, hostnames, selectors, config and IDs only. +- Scale (ratio ≈ 1.2, base 14 px): `--fs-xs 12` · `--fs-sm 13` · `--fs-md 14` · `--fs-lg 16` · `--fs-xl 19` · `--fs-2xl 23` · `--fs-3xl 28`. +- Weights: 400 body, 500 labels/controls, 600 headings, 650 page titles. Tracking −0.02em on ≥ 23 px, 0 otherwise. +- Numbers: `font-variant-numeric: tabular-nums` everywhere a value can change. + +## Space, shape, depth + +- 4 px grid: `--sp-1 4` · `--sp-2 8` · `--sp-3 12` · `--sp-4 16` · `--sp-5 20` · `--sp-6 24` · `--sp-8 32` · `--sp-10 40`. +- Radii: controls 8 px, panels 12 px, pills only for small badges. +- Depth: panels use a 1 px border and no shadow. Only floating layers (menus, dialogs, toasts, the mobile sheet) cast a shadow (`--shadow-pop`, offset + soft blur). + +## Motion + +- Curves: `--ease-out cubic-bezier(.23,1,.32,1)` for anything entering or responding; `--ease-drawer cubic-bezier(.32,.72,0,1)` for sheets. +- Durations: press 120 ms (`scale(.97)`), hover/colour 120 ms, popovers 160 ms, dialogs/sheets 220 ms. Nothing over 300 ms. +- Live values tween for 450 ms with ease-out; the route strip's flow is the only ambient motion and stops at 0 req/s. +- `prefers-reduced-motion: reduce`: no tweening, no flow, no slide; opacity and colour only. +- Keyboard-initiated actions are never animated. + +## Layout & breakpoints + +- **≥ 1100 px** desktop: 232 px sidebar (collapsible to a 64 px rail, remembered per browser) + content up to 1240 px. +- **700–1099 px** tablet: icon rail by default; tables stay tables. +- **< 700 px** phone: no sidebar; a bottom tab bar (Home, Projects, Domains, Activity, More) with a sheet for the rest. Tables marked `.tbl.cards` collapse into stacked cards; other wide tables scroll inside their own panel, never the page. Touch targets ≥ 40 px. +- Side gutter 16 px on phones, 28 px from tablet up. + +## Live + +One SSE connection (`/api/v1/events`). Every 2 s `live.tick` carries status, health and traffic for every project plus node apply versions. Pages read it through `$lib/stores/live.svelte` and overlay it onto fetched data; a changed `health_sig`, `version`, `status` or `maintenance` triggers a targeted refetch of that project only. The top bar shows **Live** / **Reconnecting** / **Offline**, plus **Applying…** while the node's desired version is ahead of the applied one. diff --git a/web/src/app.html b/web/src/app.html index 4ba439d..4cb4863 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -2,8 +2,10 @@ - - + + + + Loxy - -{#snippet num(id: string, label: string, key: string, hint: string, get: () => number | '', set: (v: number | '') => void, min = 1, max = 3600, placeholder = '')} -
- - { const v = (e.currentTarget as HTMLInputElement).value; set(v === '' ? '' : Number(v)); }} - aria-invalid={fe(key) ? 'true' : undefined} aria-describedby="{id}-h" /> - {#if fe(key)}{fe(key)}{:else if hint}{hint}{/if} -
-{/snippet} - -
-
-

General

-
-
- - - {#if fe('name')}{fe('name')}{/if} -
-
- Mode -
- - -
- {#if initial?.used_by.length}Used by {initial.used_by.length} frontend{initial.used_by.length === 1 ? '' : 's'}; they must use the same mode.{/if} - {#if fe('mode')}{fe('mode')}{/if} -
-
-
- - -
-
-
- - -
- {@render num('b-port', 'Default server port', 'port', 'Used for servers added without a port.', () => port, (v) => (port = v), 1, 65535)} - {@render num('b-ts', 'Server timeout (s)', 'timeout_server_s', 'Max wait for a server response.', () => timeoutServer, (v) => (timeoutServer = Number(v)))} -
-
- -
-

Health check

-
-
- - -
- {@render num('b-hcport', 'Port', 'health_check.port', 'Defaults to the server port.', () => hcPort, (v) => (hcPort = v), 1, 65535, 'server port')} - {@render num('b-hci', 'Interval (s)', 'health_check.interval_s', '', () => hcInterval, (v) => (hcInterval = Number(v)), 3)} - {@render num('b-hct', 'Timeout (s)', 'health_check.timeout_s', '', () => hcTimeout, (v) => (hcTimeout = Number(v)))} - {@render num('b-hcr', 'Retries', 'health_check.retries', 'Failures before marking down.', () => hcRetries, (v) => (hcRetries = Number(v)), 1, 10)} -
- {#if isHttpCheck} -
-
- - -
-
- - - {#if fe('health_check.http.path')}{fe('health_check.http.path')}{/if} -
-
- - - {#if fe('health_check.http.status_codes')}{fe('health_check.http.status_codes')}{:else}Comma separated; ? matches any digit.{/if} -
-
- - -
-
- {#if hcProtocol === 'https'} - - {/if} - {/if} -
- -
-

Connection

- {#if mode === 'http'} - - {#if sticky} -
-
- - - {#if fe('cookie_name')}{fe('cookie_name')}{/if} -
- {@render num('b-ckl', 'Cookie lifetime (s)', 'cookie_lifetime_s', '', () => cookieLifetime, (v) => (cookieLifetime = Number(v)), 1, 31536000)} -
- {/if} - {/if} -
- - -
- {#if proxyProtocol !== 'none'} - - {/if} -
- -
-

Labels

- -
- -
- {#if touched && errCount}Fix {errCount} field{errCount === 1 ? '' : 's'} above.{/if} - - -
-
- - diff --git a/web/src/lib/components/Chart.svelte b/web/src/lib/components/Chart.svelte index 64a5e64..31cee88 100644 --- a/web/src/lib/components/Chart.svelte +++ b/web/src/lib/components/Chart.svelte @@ -11,8 +11,9 @@ fmt = (v: number) => String(v), syncKey = 'loxy', height = 180, - onhover - }: { title: string; ts: number[]; series: S[]; fmt?: (v: number) => string; syncKey?: string; height?: number; onhover?: (h: boolean) => void } = $props(); + onhover, + xWindow + }: { title: string; ts: number[]; series: S[]; fmt?: (v: number) => string; syncKey?: string; height?: number; onhover?: (h: boolean) => void; xWindow?: number } = $props(); let el: HTMLDivElement | undefined = $state(); let u: uPlotT | null = null; @@ -31,12 +32,12 @@ height, legend: { show: false }, cursor: { sync: { key: syncKey }, points: { size: 6 }, drag: { x: false, y: false } }, - scales: { x: { time: true }, y: { range: (_u, _min, max) => [0, max > 0 ? max * 1.1 : 1] } }, + scales: { x: { time: true, ...(xWindow ? { range: (_u: uPlotT, _min: number, max: number): uPlotT.Range.MinMax => [max - xWindow, max] } : {}) }, y: { range: (_u, _min, max) => [0, max > 0 ? max * 1.1 : 1] } }, axes: [ - { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font: '11px ui-sans-serif, system-ui', size: 28 }, - { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font: '11px ui-sans-serif, system-ui', size: 56, values: (_u, vals) => vals.map((v) => fmt(v)) } + { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font: '11px "Hanken Grotesk", ui-sans-serif, system-ui', size: 40 }, + { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font: '11px "Hanken Grotesk", ui-sans-serif, system-ui', size: 60, values: (_u, vals) => vals.map((v) => fmt(v)) } ], - series: [{}, ...series.map((s) => ({ label: s.label, stroke: css(s.color), width: 1.5, fill: css(s.color) + '1f', points: { show: false } }))], + series: [{}, ...series.map((s) => ({ label: s.label, stroke: css(s.color), width: 1.75, fill: css(s.color) + '1a', points: { show: false } }))], hooks: { setCursor: [(uu) => { const i = uu.cursor.idx; @@ -90,12 +91,14 @@ diff --git a/web/src/lib/components/DomainAddForm.svelte b/web/src/lib/components/DomainAddForm.svelte new file mode 100644 index 0000000..9635b12 --- /dev/null +++ b/web/src/lib/components/DomainAddForm.svelte @@ -0,0 +1,139 @@ + + +
+
+ {#if projectId == null} +
+ + + {#if errs.project_id}{errs.project_id}{/if} +
+ {/if} +
+ + + {#if errs.fqdn}{errs.fqdn} + {:else}Traffic for this name on ports 80 and 443 goes to the project.{/if} +
+ {#if webServices.length > 1} +
+ + + {#if errs.service_id}{errs.service_id}{/if} +
+ {/if} + {#if providers.length} +
+ + + {#if errs.dns_provider_id}{errs.dns_provider_id}{/if} +
+ {/if} +
+
+ + {#if providers.length} + + {/if} +
+ {#if services.length && !webServices.length} +

This project has only TCP services. Domains route HTTP traffic, so add an HTTP service first.

+ {/if} + {#each other as m (m)}

{m}

{/each} +
+ + {#if oncancel}{/if} + +
+
+ + diff --git a/web/src/lib/components/DomainDetails.svelte b/web/src/lib/components/DomainDetails.svelte new file mode 100644 index 0000000..9a45cba --- /dev/null +++ b/web/src/lib/components/DomainDetails.svelte @@ -0,0 +1,168 @@ + + +
+
+ + Checked {relTime(d.checked_at)} + + + +
+ {#if d.status_detail}

{d.status_detail}

{/if} + +
+ DNS records to create + {#if d.required_records.length} +

Create these at your DNS provider{d.auto_records ? ' — Loxy manages them for you' : ''}. Changes can take a few minutes to show up.

+
+ + + + {#each d.required_records as r (r.type + r.name + r.value)} + + {/each} + +
TypeNameValueTTL
{r.type}{r.ttl}
+
+ {:else} +

No server addresses are configured yet, so Loxy can't tell which records to create. Add them under System.

+ {/if} +
+ +
+ What DNS returns + {#each ['A', 'AAAA'] as const as t (t)} + {@const f = family(t)} + {#if f.state !== 'na'} +
+ {t} + {#if f.state === 'ok'} Resolves correctly + {:else if f.state === 'missing'} No record yet + {:else} Points elsewhere{/if} + {f.got.join(', ') || 'no answer'} +
+ {/if} + {/each} +
+ +
+ HTTPS + +
+ Certificate + {certInfo.text} + {#if certInfo.detail}{certInfo.detail}{/if} + {#if cert}Details{/if} +
+ {#if d.url}
Reachable at {d.url}
{/if} +
+ +
+
+ + diff --git a/web/src/lib/components/Drawer.svelte b/web/src/lib/components/Drawer.svelte index 9015b0e..113fbf4 100644 --- a/web/src/lib/components/Drawer.svelte +++ b/web/src/lib/components/Drawer.svelte @@ -36,6 +36,6 @@ .inner { display: flex; flex-direction: column; height: 100%; } header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 14px 20px; border-bottom: 1px solid var(--border); } header h2 { flex: 1; font-size: var(--fs-lg); overflow-wrap: anywhere; } - .body { flex: 1; overflow: auto; padding: 18px 20px 28px; } + .body { flex: 1; overflow: auto; overscroll-behavior: contain; padding: 18px 20px 28px; } @keyframes slide { from { transform: translateX(24px); opacity: 0; } } diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte index de25522..0dc94ae 100644 --- a/web/src/lib/components/EmptyState.svelte +++ b/web/src/lib/components/EmptyState.svelte @@ -5,16 +5,16 @@
- +

{title}

{#if text}

{text}

{/if} {#if children}
{@render children()}
{/if}
diff --git a/web/src/lib/components/Endpoints.svelte b/web/src/lib/components/Endpoints.svelte new file mode 100644 index 0000000..3eb0564 --- /dev/null +++ b/web/src/lib/components/Endpoints.svelte @@ -0,0 +1,54 @@ + + +{#if !endpoints.length} + — +{:else} + + {#each shown as e (e.url)} + + {#if isWebUrl(e.url)} + {full ? e.url : endpointLabel(e)} + {:else} + {full ? e.url : endpointLabel(e)} + {/if} + {#if full} + + {/if} + + {/each} + {#if !full && sorted.length > shown.length}+{sorted.length - shown.length}{/if} + {copied ? 'Copied' : ''} + +{/if} + + diff --git a/web/src/lib/components/FrontendForm.svelte b/web/src/lib/components/FrontendForm.svelte deleted file mode 100644 index c5239fe..0000000 --- a/web/src/lib/components/FrontendForm.svelte +++ /dev/null @@ -1,341 +0,0 @@ - - -
-
-

General

-
-
- - - {#if fe('name')}{fe('name')}{/if} -
-
- Mode -
- - -
-
-
-
- - -
-
- -
-

Binds

Addresses and ports HAProxy listens on
- - - - {#each addrs as a (a.id)}{/each} - -
- {#each binds as b, i (i)} -
-
- - - {#if fe(`binds[${i}].address`)}{fe(`binds[${i}].address`)}{/if} -
-
- - - {#if fe(`binds[${i}].port`)}{fe(`binds[${i}].port`)}{/if} -
- -
- {/each} -
-
- - {#if fe('binds')}{fe('binds')}{/if} - “*” listens on every address. Pick a node address to listen on one interface only. -
-
- - {#if mode === 'http'} -
-

TLS

- - {#if tls} -
- - -
- {/if} -
- {/if} - -
-

Routing

-
- -
- - Create backend -
- {#if fe('default_backend_id')}{fe('default_backend_id')} - {:else if backends.data && !sameMode.length}No {mode.toUpperCase()} backends yet. Create one first. - {:else}Only {mode.toUpperCase()} backends are listed.{/if} -
- - {#if mode === 'http'} -

Rules — evaluated top to bottom; the first match wins

- {#if routes.length} -
- - - - {#each routes as r, i (i)} - - - - - - - {/each} - -
HostsPath prefixBackendOrder and remove
- - {#if fe(`routes[${i}].hosts`)}{fe(`routes[${i}].hosts`)}{/if} - - - {#if fe(`routes[${i}].path_prefix`)}{fe(`routes[${i}].path_prefix`)}{/if} - - - {#if fe(`routes[${i}].backend_id`)}{fe(`routes[${i}].backend_id`)}{/if} - - - - - - -
-
- {:else} -

No rules. Every request goes to the default backend.

- {/if} -
- {/if} -
- -
-

Options

-
-
- - - {#if fe('timeout_client_s')}{fe('timeout_client_s')}{:else}Idle time before HAProxy closes a client connection.{/if} -
-
- - - {#if fe('maxconn')}{fe('maxconn')}{:else}0 = unlimited (process limit applies).{/if} -
-
-
- {#if mode === 'http'} - - {/if} - -
-
- Labels - -
-
- -
- {#if touched && errCount}Fix {errCount} field{errCount === 1 ? '' : 's'} above.{/if} - - {#if oncancel}{/if} - -
-
- - diff --git a/web/src/lib/components/Icon.svelte b/web/src/lib/components/Icon.svelte index 6528148..96be50b 100644 --- a/web/src/lib/components/Icon.svelte +++ b/web/src/lib/components/Icon.svelte @@ -2,8 +2,6 @@ const P: Record = { lb: 'M4 6h16M4 12h16M4 18h16M8 6v12', dashboard: 'M4 4h7v7H4zM13 4h7v4h-7zM13 10h7v10h-7zM4 13h7v7H4z', - frontend: 'M3 12h7M7 8l4 4-4 4M14 4h6v16h-6', - backend: 'M4 5h16v4H4zM4 15h16v4H4zM12 9v6M8 12h8', host: 'M4 5h16v10H4zM9 19h6M12 15v4', arrowUp: 'M12 19V5M6 11l6-6 6 6', arrowDown: 'M12 5v14M6 13l6 6 6-6', @@ -43,7 +41,11 @@ pause: 'M8 5v14M16 5v14', triangleUp: 'M12 5l8 14H4l8-14Z', circle: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z', - minus: 'M5 12h14' + minus: 'M5 12h14', + panel: 'M4 5h16v14H4zM9 5v14', + route: 'M3 12h6m0 0 8-6m-8 6h8m-8 0 8 6M17 6h4M17 12h4M17 18h4', + arrowRight: 'M5 12h14M13 6l6 6-6 6', + zap: 'M13 3 5 14h6l-1 7 8-11h-6l1-7Z' }; export type IconName = keyof typeof P; @@ -59,7 +61,7 @@ viewBox="0 0 24 24" fill="none" stroke="currentColor" - stroke-width="1.75" + stroke-width={name === "more" ? 3 : 1.75} stroke-linecap="round" stroke-linejoin="round" role={label ? 'img' : undefined} diff --git a/web/src/lib/components/LabelChips.svelte b/web/src/lib/components/LabelChips.svelte index eb4ae2c..f4566b0 100644 --- a/web/src/lib/components/LabelChips.svelte +++ b/web/src/lib/components/LabelChips.svelte @@ -7,7 +7,7 @@ {#if entries.length} {#each entries.slice(0, max) as [k, v] (k)} - {k}{#if v}={v}{/if} + {k}{v ? `=${v}` : ""} {/each} {#if entries.length > max}+{entries.length - max}{/if} diff --git a/web/src/lib/components/LiveLogs.svelte b/web/src/lib/components/LiveLogs.svelte index acf4488..0896524 100644 --- a/web/src/lib/components/LiveLogs.svelte +++ b/web/src/lib/components/LiveLogs.svelte @@ -42,12 +42,12 @@ es = undefined; } - async function connect(feId: number) { + async function connect(pid: number) { close(); const my = ++gen; status = 'connecting'; errMsg = ''; - const url = `/api/v1/frontends/${feId}/logs/stream`; + const url = `/api/v1/projects/${pid}/logs/stream`; // Probe first: EventSource hides the HTTP status, so a 404 (live logs off) // would otherwise look like a generic connection error. @@ -144,10 +144,10 @@ }); $effect(() => { - const feId = id; + const pid = id; lines = []; held = []; - void connect(feId); + void connect(pid); return close; }); diff --git a/web/src/lib/components/LiveNumber.svelte b/web/src/lib/components/LiveNumber.svelte new file mode 100644 index 0000000..723f4b0 --- /dev/null +++ b/web/src/lib/components/LiveNumber.svelte @@ -0,0 +1,28 @@ + + +{value == null ? '—' : format(t.current)} + + diff --git a/web/src/lib/components/LiveStatus.svelte b/web/src/lib/components/LiveStatus.svelte new file mode 100644 index 0000000..88bca52 --- /dev/null +++ b/web/src/lib/components/LiveStatus.svelte @@ -0,0 +1,42 @@ + + + + {#if applying()} + + Applying… + + {/if} + + {#key live.tick?.ts}{/key} + {label} + + + + diff --git a/web/src/lib/components/Logo.svelte b/web/src/lib/components/Logo.svelte new file mode 100644 index 0000000..32267a1 --- /dev/null +++ b/web/src/lib/components/Logo.svelte @@ -0,0 +1,10 @@ + + + diff --git a/web/src/lib/components/Menu.svelte b/web/src/lib/components/Menu.svelte index 3d305fe..5291b17 100644 --- a/web/src/lib/components/Menu.svelte +++ b/web/src/lib/components/Menu.svelte @@ -82,19 +82,21 @@ diff --git a/web/src/lib/components/MetricsGraphs.svelte b/web/src/lib/components/MetricsGraphs.svelte index a0c363e..5c538e6 100644 --- a/web/src/lib/components/MetricsGraphs.svelte +++ b/web/src/lib/components/MetricsGraphs.svelte @@ -10,8 +10,9 @@ - -
- - - {#if error}{error}{/if} -
diff --git a/web/src/lib/components/RouteStrip.svelte b/web/src/lib/components/RouteStrip.svelte new file mode 100644 index 0000000..cc5e642 --- /dev/null +++ b/web/src/lib/components/RouteStrip.svelte @@ -0,0 +1,174 @@ + + +
+

How to reach it

+
+
+

People connect to

+ {#if sorted.length} +
    + {#each sorted as e (e.url)} +
  • + + {#if isWebUrl(e.url)} + {e.url} + {:else} + {e.url} + {/if} + +
  • + {/each} +
+ {copied ? 'Copied to clipboard' : ''} + {#if !hasDomain} +

Attach a domain to serve it on ports 80 and 443 with automatic HTTPS.

+ {/if} + {:else} +

Nothing listens yet.

+ Add a service + {/if} +
+ +
+ +
+ {#if maintenance}Maintenance{:else} {rateUnit}{/if} + {maintenance ? 'answers 503' : algorithmLabel(algorithm)} +
+ +
+ +
+

Traffic goes to

+ {#if !targets} +

Loading targets…

+ {:else if !targets.length} +

No servers yet. Targets are the servers that receive the traffic.

+ Add targets + {:else} +
    + {#each shownTargets as t (t.id)} + {@const ok = t.resolved.filter((r) => r.status === 'healthy').length} +
  • + + {tName(t)} + {tNote(t)} +
  • + {/each} +
+ {(targets.length > 5) ? `All ${targets.length} targets` : 'Manage targets'} + {/if} +
+
+
+ + diff --git a/web/src/lib/components/ServiceEditor.svelte b/web/src/lib/components/ServiceEditor.svelte new file mode 100644 index 0000000..1aad602 --- /dev/null +++ b/web/src/lib/components/ServiceEditor.svelte @@ -0,0 +1,240 @@ + + +{#snippet ferr(k: string)} + {#if errs[k]}{errs[k]}{/if} +{/snippet} + +
+
+
+ + +
+
+ + + {#if errs.listen_port}{errs.listen_port} + {:else}{allowAuto ? 'Empty = next free port from 20000.' : 'Clients connect here.'}{/if} +
+
+ + + {#if errs.destination_port}{errs.destination_port} + {:else}Port on your targets.{/if} +
+
+ {#if errs.protocol}{errs.protocol}{/if} + + {#if s.protocol === 'https'} + oncertcreated?.()} /> +

For a domain on 443 you don't need this: attach the domain on the Domains tab and HTTPS is set up automatically.

+ {/if} + +
+ + Advanced settings + {#if advErr}Needs attention{/if} + {hcSummary(s)} + + {#if s.health_check} + {@const hc = s.health_check} +
+
+ Health check +
+
+ + +
+
+ + + {@render ferr('hc_port')} +
+
+ + + {@render ferr('hc_interval')} +
+
+ + + {@render ferr('hc_timeout')} +
+
+ + + {@render ferr('hc_retries')} +
+
+ {#if hc.protocol !== 'tcp' && hc.http} +
+
+ + + {@render ferr('hc_path')} +
+
+ + (hc.http!.domain = (e.currentTarget as HTMLInputElement).value || null)} /> +
+
+ + + {@render ferr('hc_codes')} +
+
+ + (hc.http!.response = (e.currentTarget as HTMLInputElement).value || null)} /> +
+
+ {#if hc.protocol === 'https'}{/if} + {/if} + {#if errs.hc_protocol || errs.hc_other}{errs.hc_protocol || errs.hc_other}{/if} +
+ +
+ Connection +
+
+ + + {#if errs.timeout_s}{@render ferr('timeout_s')}{:else}Idle client and server timeout.{/if} +
+
+ + +
+
+ {#if s.proxy_protocol && s.proxy_protocol !== 'none'} + + {/if} + {#if s.protocol !== 'tcp'} + + {#if s.sticky_sessions} +
+
+ + + {@render ferr('cookie_name')} +
+
+ + + {@render ferr('cookie_lifetime_s')} +
+
+ {/if} + {/if} +
+
+ {/if} +
+ + {#each otherErrs as m (m)}

{m}

{/each} + +
+ + + +
+ + + diff --git a/web/src/lib/components/Sparkline.svelte b/web/src/lib/components/Sparkline.svelte new file mode 100644 index 0000000..96a38de --- /dev/null +++ b/web/src/lib/components/Sparkline.svelte @@ -0,0 +1,44 @@ + + + + {#if geo} + + + + + + {:else} + + {/if} + + + diff --git a/web/src/lib/components/Table.svelte b/web/src/lib/components/Table.svelte deleted file mode 100644 index 5a40bc1..0000000 --- a/web/src/lib/components/Table.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - - -
- {@render children()}
-
diff --git a/web/src/lib/components/Tabs.svelte b/web/src/lib/components/Tabs.svelte index 507e8dd..c71970b 100644 --- a/web/src/lib/components/Tabs.svelte +++ b/web/src/lib/components/Tabs.svelte @@ -1,7 +1,8 @@ -
+
{#each tabs as t, i (t.id)} {#if t.href} - onkey(e, i)}> + onkey(e, i)} data-sveltekit-noscroll> {t.label}{#if t.count != null}{t.count}{/if} {:else} @@ -28,14 +35,18 @@
diff --git a/web/src/lib/components/TargetPicker.svelte b/web/src/lib/components/TargetPicker.svelte index 3921c2a..0c03c8e 100644 --- a/web/src/lib/components/TargetPicker.svelte +++ b/web/src/lib/components/TargetPicker.svelte @@ -1,10 +1,10 @@ - -Backends · Loxy - -
-

Backends

- New backend -
- -{#if bes.data?.length} - -{/if} - -{#if bes.error && !bes.data} - -{:else if !bes.data} - -{:else if bes.data.length === 0} -
- - New backend - -
-{:else} -
- - - - - - - - - - - - - - - - {#each rows as b (b.id)} - open(b)}> - - - - - - - - - - - {:else} - - {/each} - -
StatusNameModeAlgorithmPortServersSessionsUsed byLabels
e.stopPropagation()}>{b.name}{b.mode.toUpperCase()}{algorithmLabel(b.algorithm)}{b.port}{num(b.stats.sessions)}{#if b.used_by.length}{b.used_by.length} frontend{b.used_by.length === 1 ? '' : 's'}{:else}Unused{/if}
No backends match “{q}”.
-
-{/if} - - diff --git a/web/src/routes/backends/[id]/+layout.svelte b/web/src/routes/backends/[id]/+layout.svelte deleted file mode 100644 index eb3000d..0000000 --- a/web/src/routes/backends/[id]/+layout.svelte +++ /dev/null @@ -1,113 +0,0 @@ - - -{be?.name ?? 'Backend'} · Loxy - - - -{#if res.error && !be} - -{:else if !be} - -{:else} -
-
-

{be.name}

- {be.mode.toUpperCase()} - - #{be.id} - - goto(`${base}/settings`) }, - { label: 'Copy ID', onclick: () => { void navigator.clipboard?.writeText(String(be!.id)); toast.info('ID copied'); } }, - { label: be.used_by.length ? 'Delete (in use)…' : 'Delete…', onclick: remove, danger: true, separator: true } - ]} - > - {#snippet trigger()}Actions{/snippet} - -
-
- Algorithm {algorithmLabel(be.algorithm)} - Port {be.port} - Health check {be.health_check.protocol ?? 'tcp'}{be.health_check.http?.path && be.health_check.protocol !== 'tcp' ? ` ${be.health_check.http.path}` : ''} · {be.health_check.interval_s ?? 15}s - Used by - {#if users.length}{#each users as f, i (f.id)}{i ? ', ' : ' '}{f.name}{/each} - {:else if be.used_by.length}{be.used_by.length} frontend{be.used_by.length === 1 ? '' : 's'} - {:else}no frontend{/if} - -
-
- {#if be.health.messages.length} - - {/if} - - -
{@render children()}
-{/if} - - diff --git a/web/src/routes/backends/[id]/+page.svelte b/web/src/routes/backends/[id]/+page.svelte deleted file mode 100644 index eefb9e5..0000000 --- a/web/src/routes/backends/[id]/+page.svelte +++ /dev/null @@ -1,207 +0,0 @@ - - -Servers · {be?.name ?? 'Backend'} · Loxy - -
- - - {#if status.data}{totals.healthy} of {totals.total} healthy · default port {be.port}{/if} -
- -{#snippet admin(s: BackendServer)} - {#if s.removing}Removing - {:else if s.admin_state === 'drain'}Draining - {:else if s.admin_state === 'maint'}Maintenance{/if} - {#if s.backup}Backup{/if} -{/snippet} - -{#if servers.error && !servers.data} - -{:else if !servers.data} - -{:else if !servers.data.length} -
- - - -
-{:else} -
- - - - {#each servers.data as s (s.id)} - {#if s.type === 'label_selector'} - - - - - - - - - {#each s.resolved as r0 (r0.slot_id)} - {@const r = st(r0)} - - - - - - - - - - {:else} - - {/each} - {:else} - {@const r = s.resolved[0] ? st(s.resolved[0]) : null} - - - - - - - - - - {/if} - {/each} - -
ServerAddressSourceStatusSessionsWeightActions
{s.label_selector} {s.resolved.length} match{s.resolved.length === 1 ? '' : 'es'}Label selector{s.use_private_ip ? ' · private IP' : ''}{@render admin(s)}{s.weight}
{r.name ?? '—'}{r.address}:{r.port}{num(r.sessions)}{s.weight}
No hosts match this selector yet.
{r?.name ?? (s.type === 'ip' ? '—' : `host #${s.host_id}`)}{r ? `${r.address}:${r.port}` : `${s.ip ?? '—'}:${s.port ?? be.port}`}{s.type === 'ip' ? 'IP' : `Host${s.use_private_ip ? ' · private IP' : ''}`} - - {#if r}{:else}{/if} - {@render admin(s)} - - {num(r?.sessions)}{s.weight}
-
-

Hover a status for the last health-check result. Removing a server drains it first.

-{/if} - - - {#key pickerKey} - - {/key} - {#snippet footer()} - - - {/snippet} - - - -
{ e.preventDefault(); void saveWeight(); }}> -
- - - Relative share of new sessions. 0 stops new traffic without draining. -
-
- {#snippet footer()} - - - {/snippet} -
- - diff --git a/web/src/routes/backends/[id]/graphs/+page.svelte b/web/src/routes/backends/[id]/graphs/+page.svelte deleted file mode 100644 index eff2449..0000000 --- a/web/src/routes/backends/[id]/graphs/+page.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - -Graphs · {ctx.item?.name ?? 'Backend'} · Loxy - -{#key ctx.id} - -{/key} diff --git a/web/src/routes/backends/[id]/settings/+page.svelte b/web/src/routes/backends/[id]/settings/+page.svelte deleted file mode 100644 index b726d9a..0000000 --- a/web/src/routes/backends/[id]/settings/+page.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - -Settings · {ctx.item?.name ?? 'Backend'} · Loxy - -{#if ctx.item} - {#key ctx.item.id} - ctx.set(b)} /> - {/key} -{/if} diff --git a/web/src/routes/backends/new/+page.svelte b/web/src/routes/backends/new/+page.svelte deleted file mode 100644 index 7215fbd..0000000 --- a/web/src/routes/backends/new/+page.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -New backend · Loxy - - -

New backend

-

A backend is a pool of servers with a balancing algorithm and a health check. Add servers after creating it.

- diff --git a/web/src/routes/certificates/+page.svelte b/web/src/routes/certificates/+page.svelte index 0b95859..490beea 100644 --- a/web/src/routes/certificates/+page.svelte +++ b/web/src/routes/certificates/+page.svelte @@ -92,23 +92,23 @@ {#if certs.error && !certs.data} {:else if !certs.data} {:else if !certs.data.length} -
+
{:else}
- +
{#each certs.data as c (c.id)} {@const ex = expiry(c)} (sel = c)}> - - - - - - - + + + + + + +
NameTypeDomainsStatusExpires inUsed byLabelsActions
{c.type === 'managed' ? 'Managed' : 'Uploaded'}{c.domains.slice(0, 2).join(', ')}{c.domains.length > 2 ? ` +${c.domains.length - 2}` : ''}{ex.text}{c.used_by.length} frontend{c.used_by.length === 1 ? '' : 's'}{c.type === 'managed' ? 'Managed' : 'Uploaded'}{c.domains.slice(0, 2).join(', ')}{c.domains.length > 2 ? ` +${c.domains.length - 2}` : ''}{ex.text}{c.used_by.length} service{c.used_by.length === 1 ? '' : 's'} e.stopPropagation()}> (sel = c) }, @@ -189,7 +189,7 @@
{c.type === 'managed' ? 'Managed' : 'Uploaded'} {#if c.type === 'managed'}{/if} - +
Domains
{c.domains.join(', ')}
@@ -200,7 +200,7 @@
SHA-256
{#if c.fingerprint_sha256}{:else}—{/if}
Key type
{c.key_type ?? '—'}
{#if c.type === 'managed'}
Challenge
{c.challenge === 'dns01' ? 'DNS-01' : 'HTTP-01'}
Renewal
Automatic, 30 days before expiry
{/if} -
Used by
{c.used_by.length} frontend{c.used_by.length === 1 ? '' : 's'}
+
Used by
{c.used_by.length} service{c.used_by.length === 1 ? '' : 's'}
{#if c.last_error}

Last issuance error

{/if}
@@ -213,4 +213,10 @@ .doms { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } .file { cursor: pointer; } legend { margin-bottom: 6px; padding: 0; } + @media (max-width: 699px) { + .certs :global(tr) { position: relative; } + .certs :global(td.actions) { position: absolute; top: 4px; right: 4px; padding: 0; } + .certs :global(td.lead) { padding-right: 52px; } + .doms { max-width: 60%; } + } diff --git a/web/src/routes/dashboard/+page.svelte b/web/src/routes/dashboard/+page.svelte index fbfe4c9..21cf196 100644 --- a/web/src/routes/dashboard/+page.svelte +++ b/web/src/routes/dashboard/+page.svelte @@ -1,162 +1,269 @@ Dashboard · Loxy -

Dashboard

+
+

Dashboard

+ Create project +
{#if ov.error && !ov.data} {:else if !ov.data} -
+
{:else} {@const o = ov.data} {@const n = o.node} -
- {#if o.frontends.apply_errors > 0 || failing.length} +
+ {#if (counts?.errors ?? 0) > 0 || failing.length} {/if} - - -
-

Current traffic

- {#if o.traffic} - - - - - - - {:else} -

No traffic data yet.

- {/if} +
+
+

Requests per second, all projects

+
+
+

Last 3 minutes, updated every 2 seconds.

+
+
+
Open connections
+
Inbound
bytes(v, '/s')} />
+
Outbound
bytes(v, '/s')} />
+
-
-
-
- -

HAProxy · {n.name}

- - - System -
-
-
Version
{n.haproxy_running_version ?? n.haproxy_version ?? '—'}
-
Uptime
{duration(n.uptime_s)}
-
Reloads
{num(n.reloads)}
-
Config
-
- applied v{n.applied_version} · desired v{n.desired_version} - {#if n.applied_version === n.desired_version} in sync{:else} applying{/if} -
-
-
- -
-

Recent events

All activity
- {#if events.error && !events.data}
- {:else if !events.data}
- {:else if !events.data.length} +
+
+

Projects

All projects
+ {#if !projects.data}
+ {:else if !plist.length} + + Create project + {:else} -
    - {#each events.data as e (e.id)} +
+ {/if}
+ +
+
+

Health

+
+
+
Projects
+
+ {#if !counts?.total}none yet + {:else} + {counts.healthy} healthy + {#if counts.degraded}{counts.degraded} degraded{/if} + {#if counts.unhealthy}{counts.unhealthy} unhealthy{/if} + {/if} +
+
+
+
Targets
+
+ {#if !o.targets.total}none yet + {:else}{num(o.targets.healthy)} of {num(o.targets.total)} healthy{/if} +
+
+
+
Certificates
+
+ {#if !o.certificates.total}none yet + {:else if !o.certificates.expiring_soon && !o.certificates.failed}{o.certificates.total} valid{/if} + {#if o.certificates.expiring_soon}{o.certificates.expiring_soon} expiring soon{/if} + {#if o.certificates.failed}{o.certificates.failed} failed{/if} +
+
+
+ {#if attention.length} +
    + {#each attention.slice(0, 4) as p (p.id)} +
  • {p.name}
  • + {/each} +
+ {/if} +
+ +
+
+

HAProxy

+ + + System +
+
+
Version
{n.haproxy_running_version ?? n.haproxy_version ?? '—'}
+
Uptime
{duration(n.uptime_s)}
+
Config
+
+ {#if applying()}Applying v{nodeV?.desired_version}… + {:else}Up to date v{nodeV?.applied_version ?? n.applied_version}{/if} +
+
+
+
+ +
+

Recent events

All activity
+ {#if events.error && !events.data}
+ {:else if !events.data}
+ {:else if !events.data.length} + {:else} +
    + {#each events.data as e (e.id)} +
  1. + + + {#if e.project_id}{eventText(e.message)} + {:else}{eventText(e.message)}{/if} + + +
  2. + {/each} +
+ {/if} +
{/if} diff --git a/web/src/routes/domains/+page.svelte b/web/src/routes/domains/+page.svelte index db8144e..d1c385a 100644 --- a/web/src/routes/domains/+page.svelte +++ b/web/src/routes/domains/+page.svelte @@ -1,120 +1,80 @@ -
-

Point names at your frontends. Loxy checks that DNS resolves to the right addresses and can issue certificates once it does.

+
+

Domains route a hostname on the shared ports 80 and 443 to a project, with an automatic certificate. Loxy checks that DNS points at this server.

- +
+{#if projectId} +

Project: {projectName(projectId)} ×

+{/if} + {#if domains.error && !domains.data} {:else if !domains.data} {:else if !domains.data.length} -
+
+ + {#if projects.data?.length}{:else}Create project{/if} + +
{:else}
- - +
DomainFrontendDNSStatusLast checkedCertificateAuto records
+ {#each domains.data as d (d.id)} - (selected = d)}> - - - - - - - + open(d)}> + + + + + {/each} @@ -122,100 +82,29 @@ {/if} - -
{ e.preventDefault(); void add(); }} novalidate> -
- - - {#if errs.fqdn}{errs.fqdn}{/if} -
-
- - - {#if errs.frontend_id}{errs.frontend_id}{:else}A TLS frontend gets the issued certificate attached automatically.{/if} -
-
- - -
- - - {#snippet footer()} - - - {/snippet} + + {#key addKey} + (addOpen = false)} /> + {/key} {#if selected} - {@const d = selected}
-
- - Checked {relTime(d.checked_at)} - - - -
- {#if d.status_detail}

{d.status_detail}

{/if} - -
-

Required records

- {#if d.required_records.length} -
-
DomainProjectDNSCertificateLast checked
{#if d.frontend_id} e.stopPropagation()}>{feName(d.frontend_id)}{:else}—{/if}{provName(d.dns_provider_id)}{relTime(d.checked_at)}{certState(d)}{d.auto_records ? 'On' : 'Off'}
{#if d.project_id} e.stopPropagation()}>{projectName(d.project_id)}{:else}—{/if}{certState(d)}{relTime(d.checked_at)}
- - - {#each d.required_records as r (r.type + r.value)} - - {/each} - -
TypeNameValueTTL
{r.type}{r.ttl}
-
- {:else} -

The frontend listens on no public addresses, so no records are required.

- {/if} -
- -
-

What DNS returns

- {#each ['A', 'AAAA'] as const as t (t)} - {@const f = family(d, t)} - {#if f.state !== 'na'} -
- {t} - {#if f.state === 'ok'} Resolves correctly - {:else if f.state === 'missing'} Missing - {:else} Wrong IP{/if} - {f.got.join(', ') || 'no answer'} -
- {/if} - {/each} -
- -
-
Frontend
{#if d.frontend_id}{feName(d.frontend_id)}{:else}—{/if}
-
DNS provider
{provName(d.dns_provider_id)}{d.zone ? ` · zone ${d.zone}` : ''}
-
Auto records
{d.auto_records ? 'On' : 'Off'}
-
Certificate
{#if d.certificate_id}{certState(d)}{:else}None{/if}
-
-
+ {#if selected.project_id} +

Project {projectName(selected.project_id)}

+ {/if} + (fresh = d)} onrefresh={() => { void domains.reload(); void certs.reload(); }} onremoved={() => { selectedId = null; void domains.reload(); }} />
{/if} diff --git a/web/src/routes/frontends/+page.svelte b/web/src/routes/frontends/+page.svelte deleted file mode 100644 index 708f713..0000000 --- a/web/src/routes/frontends/+page.svelte +++ /dev/null @@ -1,111 +0,0 @@ - - -Frontends · Loxy - -
-

Frontends

- New frontend -
- -{#if fes.data?.length} - -{/if} - -{#if fes.error && !fes.data} - -{:else if !fes.data} - -{:else if fes.data.length === 0} -
- - New frontend - {#if bes.data && !bes.data.length}Create a backend first{/if} - -
-{:else} -
- - - - - - - - - - - - - - - {#each rows as f (f.id)} - open(f)}> - - - - - - - - - - {:else} - - {/each} - -
StatusNameModeBindsDefault backendRoutesConnectionsLabels
- e.stopPropagation()}>{f.name} - {#if f.maintenance}Maintenance{/if} - {#if !f.enabled}Disabled{/if} - {#if f.status === 'apply_error'}Apply error{/if} - {modeLabel(f.mode, f.tls)}{bindsText(f.binds)} - {#if f.default_backend_id != null} - e.stopPropagation()}>{beName(f.default_backend_id)} - {:else}—{/if} - {f.routes.length || '—'}{num(f.stats.open_connections)}
No frontends match “{q}”.
-
-{/if} - - diff --git a/web/src/routes/frontends/[id]/+layout.svelte b/web/src/routes/frontends/[id]/+layout.svelte deleted file mode 100644 index 601dcfe..0000000 --- a/web/src/routes/frontends/[id]/+layout.svelte +++ /dev/null @@ -1,212 +0,0 @@ - - -{fe?.name ?? 'Frontend'} · Loxy - - - -{#if res.error && !fe} - -{:else if !fe} - -{:else} -
-
- {#if editing} - { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') editing = false; }} /> - {:else} - - {/if} - {modeLabel(fe.mode, fe.tls)} - - #{fe.id} - {#if fe.maintenance}Maintenance{/if} - {#if !fe.enabled}Disabled{/if} - {#if fe.status === 'applying'}Applying{/if} - - {#if sub !== 'edit'}Edit{/if} - goto(`${base}/edit`) }, - { label: 'Rename', onclick: startRename }, - { label: fe.maintenance ? 'Turn maintenance off' : 'Turn maintenance on', onclick: toggleMaintenance, separator: true }, - { label: fe.enabled ? 'Disable' : 'Enable', onclick: toggleEnabled }, - { label: 'View config', onclick: showConfig, separator: true }, - { label: 'Copy ID', onclick: () => { void navigator.clipboard?.writeText(String(fe!.id)); toast.info('ID copied'); } }, - { label: 'Delete…', onclick: remove, danger: true, separator: true } - ]} - > - {#snippet trigger()}Actions{/snippet} - -
-
- Binds {bindsText(fe.binds)} - {#if fe.description}{fe.description}{/if} -
-
- - {#if fe.status === 'apply_error'} - - {/if} - - {#if sub !== 'edit'}{/if} -
{@render children()}
-{/if} - - - {#if cfgText}{:else}{/if} -

Read-only. The sections rendered for this frontend.

- {#snippet footer()} - - {/snippet} -
- - diff --git a/web/src/routes/frontends/[id]/+page.svelte b/web/src/routes/frontends/[id]/+page.svelte deleted file mode 100644 index 8684764..0000000 --- a/web/src/routes/frontends/[id]/+page.svelte +++ /dev/null @@ -1,144 +0,0 @@ - - -
- - - {#if fe.mode === 'http'}{/if} - - - - - {#if fe.health.messages.length} - - {/if} - -
-
-

Listening

-
-
Binds
-
{#each fe.binds as b, i (i)}{b.address.includes(':') ? `[${b.address}]` : b.address}:{b.port}{/each}
-
Mode
{fe.mode === 'tcp' ? 'TCP (layer 4)' : 'HTTP (layer 7)'}
- {#if fe.mode === 'http'} -
TLS
-
{#if fe.tls}On{fe.http_redirect ? ' · HTTP redirects to HTTPS' : ''}{:else}Off{/if}
-
Forwarded headers
{fe.forwarded_headers ? 'On' : 'Off'}
- {/if} -
Client timeout
{fe.timeout_client_s} s
-
Max connections
{fe.maxconn ? num(fe.maxconn) : 'Unlimited'}
-
Created
{dateTime(fe.created_at)}
- {#if Object.keys(fe.labels).length}
Labels
{/if} -
-
- - {#if fe.tls} -
-

Certificates

Manage
- {#if !certs.data} -

Loading…

- {:else if !myCerts.length} -

No certificate attached. HTTPS clients will fail the handshake.

- {:else} -
    - {#each myCerts as c (c.id)} - {@const d = daysUntil(c.not_after)} -
  • - - {c.name} {c.domains.slice(0, 3).join(', ')}{c.domains.length > 3 ? '…' : ''} - {#if d != null && d < 14 && c.status === 'issued'}expires in {d} d{/if} - -
  • - {/each} -
- {/if} -
- {/if} -
- -
- -
- - - - {#each fe.routes as r, i (r.id ?? i)} - {@const b = byId.get(r.backend_id)} - - - - - - - {/each} - - - - - - - -
#MatchBackendServers
{i + 1} - {#if r.hosts?.length}host {r.hosts.join(', ')}{/if} - {#if r.hosts?.length && r.path_prefix} and {/if} - {#if r.path_prefix}path {r.path_prefix}*{/if} - {b?.name ?? `#${r.backend_id}`}{#if b}{/if}
{fe.routes.length ? '—' : ''}{fe.routes.length ? 'Everything else' : 'All traffic'} - {#if fe.default_backend_id != null} - {byId.get(fe.default_backend_id)?.name ?? `#${fe.default_backend_id}`} default - {:else}No default — 503{/if} - {#if fe.default_backend_id != null && byId.get(fe.default_backend_id)}{@const b = byId.get(fe.default_backend_id)!}{/if}
-
-
- - {#if usedIds.length} -
-

Backends in use

- -
- {/if} -
- - diff --git a/web/src/routes/frontends/[id]/config/+page.svelte b/web/src/routes/frontends/[id]/config/+page.svelte deleted file mode 100644 index 8fc434e..0000000 --- a/web/src/routes/frontends/[id]/config/+page.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -Config · {ctx.item?.name ?? 'Frontend'} · Loxy - -
-

Rendered HAProxy sections for this frontend and the backends it uses. Read-only; edit the frontend to change it.

- - - -
-{#if cfg.error && cfg.data == null} -{:else if cfg.data == null} -{:else}{/if} - - diff --git a/web/src/routes/frontends/[id]/edit/+page.svelte b/web/src/routes/frontends/[id]/edit/+page.svelte deleted file mode 100644 index 88be36a..0000000 --- a/web/src/routes/frontends/[id]/edit/+page.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -Edit · {ctx.item?.name ?? 'Frontend'} · Loxy - -{#if ctx.item} - {#key ctx.item.id} - ctx.set(f)} oncancel={() => goto(`/frontends/${ctx.id}`)} /> - {/key} -{/if} diff --git a/web/src/routes/frontends/[id]/graphs/+page.svelte b/web/src/routes/frontends/[id]/graphs/+page.svelte deleted file mode 100644 index 388c59a..0000000 --- a/web/src/routes/frontends/[id]/graphs/+page.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -Graphs · {ctx.item?.name ?? 'Frontend'} · Loxy - -{#key ctx.id} - -{/key} diff --git a/web/src/routes/frontends/[id]/logs/+page.svelte b/web/src/routes/frontends/[id]/logs/+page.svelte deleted file mode 100644 index d252d9b..0000000 --- a/web/src/routes/frontends/[id]/logs/+page.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -Logs · {ctx.item?.name ?? 'Frontend'} · Loxy - - diff --git a/web/src/routes/frontends/new/+page.svelte b/web/src/routes/frontends/new/+page.svelte deleted file mode 100644 index 5236e01..0000000 --- a/web/src/routes/frontends/new/+page.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -New frontend · Loxy - - -

New frontend

- history.back()} /> diff --git a/web/src/routes/hosts/+page.svelte b/web/src/routes/hosts/+page.svelte index a0421b7..4a47f94 100644 --- a/web/src/routes/hosts/+page.svelte +++ b/web/src/routes/hosts/+page.svelte @@ -15,8 +15,8 @@ import ErrorBox from '$lib/components/ErrorBox.svelte'; const hosts = resource(async () => (await unwrap(api.GET('/hosts'))).hosts, { keys: () => ['hosts'] }); - const bes = resource(async () => (await unwrap(api.GET('/backends'))).backends, { keys: () => ['backends'] }); - const beName = (id: number) => bes.data?.find((b) => b.id === id)?.name ?? `#${id}`; + const projects = resource(async () => (await unwrap(api.GET('/projects'))).projects, { keys: () => ['projects'] }); + const projectName = (id: number) => projects.data?.find((b) => b.id === id)?.name ?? `#${id}`; let q = $state(''); const rows = $derived((hosts.data ?? []).filter((s) => { const t = q.trim(); @@ -57,7 +57,7 @@ if (r) { open = false; void hosts.reload(); } } async function remove(s: Host) { - if (s.used_by?.length) { toast.error(`${s.name} is used by ${s.used_by.length} backend${s.used_by.length === 1 ? '' : 's'}. Remove it there first.`); return; } + if (s.used_by?.length) { toast.error(`${s.name} is a target of ${s.used_by.length} project${s.used_by.length === 1 ? '' : 's'}. Remove it there first.`); return; } const ok = await confirm({ title: `Delete ${s.name}?`, message: 'The host is removed from the inventory. This cannot be undone.', confirmLabel: 'Delete host', danger: true }); if (!ok) return; const r = await mutate(api.DELETE('/hosts/{id}', { params: { path: { id: s.id } } }), { success: 'Host deleted' }); @@ -71,30 +71,30 @@

Hosts

-

Your inventory of machines. Add them to backends directly, or label them and let a backend's label selector pick them up.

+

Your inventory of machines. Add them to a project as targets, or label them and let a project's label selector pick them up.

{#if hosts.error && !hosts.data} {:else if !hosts.data} {:else if !hosts.data.length} -
+
{:else}
- +
{#each rows as s (s.id)} - - - - - - + + + + + + {:else} @@ -141,4 +141,9 @@ .search .input { padding-left: 30px; } .nm { font-weight: 500; } .note { max-width: 240px; overflow: hidden; text-overflow: ellipsis; } + @media (max-width: 699px) { + .hosts :global(tr) { position: relative; } + .hosts :global(td.actions) { position: absolute; top: 4px; right: 4px; padding: 0; } + .hosts :global(td.lead) { flex-direction: column; align-items: flex-start; padding-right: 52px; } + } diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index e383c82..c612c7e 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import { api, setCsrf } from '$lib/api/client'; import { session } from '$lib/stores/session.svelte'; + import Logo from '$lib/components/Logo.svelte'; let email = $state(''); let password = $state(''); @@ -47,8 +48,9 @@
- +

Sign in to Loxy

+

Your load balancers, live.

@@ -75,10 +77,12 @@ diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte new file mode 100644 index 0000000..f616cfb --- /dev/null +++ b/web/src/routes/projects/+page.svelte @@ -0,0 +1,169 @@ + + +Projects · Loxy + +
+

Projects

+ {#if projects.data?.length}Create project{/if} +

Each project is one load balancer: an address people connect to, and the servers it spreads their traffic across.

+
+ +{#if projects.error && !projects.data} + +{:else if !projects.data} + +{:else if projects.data.length === 0} +
+ + Create project + +
+{:else} +
+ + {#if hasLabels || selector} + + {/if} + +
+ +
+
NameIPv4IPv6Private IPv4LabelsUsed byActions
{s.name}{#if s.notes}
{s.notes}
{/if}
{#if s.used_by?.length}{#each s.used_by as b, i (b)}{i ? ', ' : ''}{beName(b)}{/each}{:else}—{/if}{s.name}{#if s.notes}
{s.notes}
{/if}
{#if s.used_by?.length}{#each s.used_by as b, i (b)}{i ? ', ' : ''}{projectName(b)}{/each}{:else}—{/if} openForm(s) }, { label: 'Delete', onclick: () => remove(s), danger: true, separator: true }]} />
+ + + + + + + + {#if hasLabels}{/if} + + + + {#each rows as p (p.id)} + {@const st = projectStatus(p)} + open(p)}> + + + + + + + {#if hasLabels}{/if} + + {:else} + + {/each} + +
ProjectReach it atTargetsRequests/sConnectionsLabels
+ + + + e.stopPropagation()}>{p.name} + + {#if st === 'applying'}Applying change… + {:else if st === 'apply_error'}Last change failed + {:else}{algorithmLabel(p.algorithm)}{/if}{p.description ? ` · ${p.description}` : ''} + + + {#if p.maintenance}Maintenance{/if} + + + e.stopPropagation()}> + {p.counts.targets} target{p.counts.targets === 1 ? '' : 's'} + + + + + +
No projects match these filters.
+
+{/if} + + diff --git a/web/src/routes/projects/[id]/+layout.svelte b/web/src/routes/projects/[id]/+layout.svelte new file mode 100644 index 0000000..2c641b7 --- /dev/null +++ b/web/src/routes/projects/[id]/+layout.svelte @@ -0,0 +1,242 @@ + + +{p?.name ?? 'Project'} · Loxy + + + +{#if res.error && !p} + +{:else if !p} + +{:else} +
+
+ + {#if editing} + { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') editing = false; }} /> + {:else} +

+ {/if} + + {#if p.maintenance}Maintenance{/if} + +
+ {#if openUrl}Open{/if} + + {#snippet trigger()}Actions{/snippet} + +
+
+
+ req/s + open connections + {#if tab !== 'overview'} + {#if p.endpoints.length} + + + {:else} + Not reachable yet. Add a service. + {/if} + {/if} + {#if p.description}{p.description}{/if} +
+
+ + {#if p.status === 'error'} + + {/if} + + +
{@render children()}
+{/if} + + + {#if cfgText}{:else}{/if} +

Read-only. These are the sections rendered for this project.

+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/web/src/routes/projects/[id]/+page.ts b/web/src/routes/projects/[id]/+page.ts new file mode 100644 index 0000000..8802567 --- /dev/null +++ b/web/src/routes/projects/[id]/+page.ts @@ -0,0 +1,2 @@ +import { redirect } from '@sveltejs/kit'; +export function load({ params }) { redirect(307, `/projects/${params.id}/overview`); } diff --git a/web/src/routes/projects/[id]/domains/+page.svelte b/web/src/routes/projects/[id]/domains/+page.svelte new file mode 100644 index 0000000..91ff15b --- /dev/null +++ b/web/src/routes/projects/[id]/domains/+page.svelte @@ -0,0 +1,82 @@ + + +Domains · {ctx.project?.name ?? 'Project'} · Loxy + +
+
+

Attach a domain

+ +
+ + {#if domains.error && !domains.data} + + {:else if !domains.data} + + {:else if !list.length} +

No domains yet. Without one, the project is reachable on its own port only.

+ {:else} +
+ {#each list as d (d.id)} + +
(open[d.id] = (e.currentTarget as HTMLDetailsElement).open)}> + + {d.fqdn} + + {#if d.https}{d.url?.startsWith('https') ? 'HTTPS' : 'HTTPS pending'}{:else}HTTP only{/if} + + {#if d.url} e.stopPropagation()}>{d.url}{/if} + +
+ { void domains.reload(); void certs.reload(); }} /> +
+
+ {/each} +
+ {/if} +
+ + diff --git a/web/src/routes/projects/[id]/graphs/+page.svelte b/web/src/routes/projects/[id]/graphs/+page.svelte new file mode 100644 index 0000000..e33633d --- /dev/null +++ b/web/src/routes/projects/[id]/graphs/+page.svelte @@ -0,0 +1,56 @@ + + +Graphs · {ctx.project?.name ?? 'Project'} · Loxy + +{#if (services.data?.length ?? 0) > 1} +
+ + +
+{/if} + +{#key `${id}:${sid}`} + +{/key} + + diff --git a/web/src/routes/projects/[id]/logs/+page.svelte b/web/src/routes/projects/[id]/logs/+page.svelte new file mode 100644 index 0000000..44028f4 --- /dev/null +++ b/web/src/routes/projects/[id]/logs/+page.svelte @@ -0,0 +1,10 @@ + + +Logs · {ctx.project?.name ?? 'Project'} · Loxy + + diff --git a/web/src/routes/projects/[id]/overview/+page.svelte b/web/src/routes/projects/[id]/overview/+page.svelte new file mode 100644 index 0000000..4a4b22a --- /dev/null +++ b/web/src/routes/projects/[id]/overview/+page.svelte @@ -0,0 +1,199 @@ + + +
+ + +
+
+
+

{http ? 'Requests per second' : 'New connections per second'}

+ last 2 minutes{peak ? ` · peak ${one(peak)}` : ''} +
+
+
h.v)} height={56} label="{http ? 'Requests' : 'New connections'} per second over the last 2 minutes" min={3} />
+
+
+
Open connections
+
Inbound
{#if inBps != null} bytes(n, '/s')} />{:else}—{/if}
+
Outbound
bytes(n, '/s')} />
+ {#if http}
Response time
{#if resp} `${Math.round(n)} ms`} />{:else}—{/if}
{/if} +
+ Open graphs +
+ +
+
+

Health

+
+ {#if p.maintenance} +

Maintenance is on: every request gets a 503 until you turn it off.

+ {/if} + {#if p.health.total === 0} +

No health checks yet. They start as soon as the project has a target and a service.

+ {:else if p.health.status === 'healthy' || !p.health.messages.length} +

{p.health.healthy} of {p.health.total} checks pass. All targets answer.

+ {:else} +

{p.health.healthy} of {p.health.total} checks pass.

+
    {#each p.health.messages.slice(0, 4) as m (m)}
  • {checkText(m)}
  • {/each}
+ {/if} + See each target +
+
+ +
+

Recent events

All activity
+ {#if !events.data}
+ {:else if !events.data.length} + {:else} +
    + {#each events.data as e (e.id)} +
  1. + + {eventText(e.message)} + +
  2. + {/each} +
+ {/if} +
+
+ +
+
+

Services

Manage
+ {#if !services.data}
+ {:else if !services.data.length}Add service + {:else} +
    + {#each services.data as s (s.id)} +
  • + {serviceTitle(s)} + {s.domains?.length ? s.domains.join(', ') : ''} + +
  • + {/each} +
+ {/if} +
+ +
+

Settings

Edit
+
+
Algorithm
{algorithmLabel(p.algorithm)}
+
Maintenance
{p.maintenance ? 'On' : 'Off'}
+
Labels
{#if Object.keys(p.labels).length}{:else}None{/if}
+
Created
{dateTime(p.created_at)}
+
+
+
+
+ + diff --git a/web/src/routes/projects/[id]/services/+page.svelte b/web/src/routes/projects/[id]/services/+page.svelte new file mode 100644 index 0000000..dfcd747 --- /dev/null +++ b/web/src/routes/projects/[id]/services/+page.svelte @@ -0,0 +1,152 @@ + + +Services · {ctx.project?.name ?? 'Project'} · Loxy + +
+

Each service is a port this project listens on and the port it forwards to. Ports 80 and 443 are shared by all projects — use Domains for those.

+ + startNew('http') }, + { label: 'HTTPS (TLS termination)', onclick: () => startNew('https') }, + { label: 'TCP', onclick: () => startNew('tcp') } + ]} + > + {#snippet trigger()}Add service{/snippet} + +
+ +{#if editing === 'new' && draft} +
+

New service

+ {#key draft} (editing = null)} oncertcreated={() => void certs.reload()} />{/key} +
+{/if} + +{#if services.error && !services.data} + +{:else if !services.data} + +{:else if services.data.length === 0 && editing !== 'new'} +
+ + + + +
+{:else} +
+ {#each services.data as s (s.id)} + {@const url = endpointFor(s)} +
+
+ {#if s.protocol === 'https'}{/if} +

{serviceTitle(s)}

+ {#if s.sticky_sessions}Sticky{/if} + {#if s.proxy_protocol !== 'none'}PROXY {s.proxy_protocol}{/if} + + + {#if editing !== s.id} + startEdit(s) }, + { label: 'Duplicate', onclick: () => duplicate(s) }, + { label: 'Delete', onclick: () => remove(s), danger: true, separator: true } + ]} /> + {/if} +
+ {#if editing === s.id && draft} + (editing = null)} oncertcreated={() => void certs.reload()} /> + {:else} +
+ {#if url}
Endpoint
{#if /^https?:/.test(url)}{url}{:else}{url}{/if}
{/if} + {#if s.domains?.length}
Domains
{s.domains.join(', ')}
{/if} +
Health check
{hcSummary(s)}
+
Timeout
{s.timeout_s} s
+ {#if s.sticky_sessions}
Sticky cookie
{s.cookie_name} · {s.cookie_lifetime_s} s
{/if} + {#if s.certificates.length}
Certificates
{s.certificates.map(certName).join(', ')}
{/if} +
+ {#if s.health.status !== 'healthy' && s.health.messages.length} +
    {#each s.health.messages.slice(0, 3) as m (m)}
  • {checkText(m)}
  • {/each}
+ {/if} + {#if s.health.total === 0}

No targets to check. Add targets

{/if} + {/if} +
+ {/each} +
+{/if} + + diff --git a/web/src/routes/projects/[id]/settings/+page.svelte b/web/src/routes/projects/[id]/settings/+page.svelte new file mode 100644 index 0000000..a6c48e8 --- /dev/null +++ b/web/src/routes/projects/[id]/settings/+page.svelte @@ -0,0 +1,106 @@ + + +Settings · {p.name} · Loxy + +
+ +

General

+
+ + + {#if errs.name}{errs.name}{/if} +
+
+ + + {#if errs.description}{errs.description}{/if} +
+
+ + + {#if errs.algorithm}{errs.algorithm}{:else}How new connections are spread across healthy targets. Applies to all services.{/if} +
+
+ Labels + {#key formKey}{/key} + {#if errs.labels}{errs.labels}{:else}Used by alert rules to pick projects, e.g. env=prod.{/if} +
+
+ + + +
+ + +
+

Delete project

+

Stops all services of {p.name} and removes its targets and domains. Hosts and certificates are not touched. Metrics are kept until they expire.

+
+
+
+ + diff --git a/web/src/routes/projects/[id]/targets/+page.svelte b/web/src/routes/projects/[id]/targets/+page.svelte new file mode 100644 index 0000000..7b3c341 --- /dev/null +++ b/web/src/routes/projects/[id]/targets/+page.svelte @@ -0,0 +1,244 @@ + + +Targets · {ctx.project?.name ?? 'Project'} · Loxy + +
+

Targets are the servers that receive this project's traffic. Loxy checks each one and only sends traffic to those that answer.

+ + {#if total}{healthy} of {total} healthy{/if} + {#if targets.data?.length}{/if} +
+ +{#snippet adminBadge(t: Target)} + {#if t.removing}Removing + {:else if t.admin_state === 'drain'}Draining + {:else if t.admin_state === 'maint'}Maintenance{/if} + {#if t.backup}Backup{/if} +{/snippet} + +{#if targets.error && !targets.data} + +{:else if !targets.data} + +{:else if !targets.data.length} +
+ + + +
+{:else} +
+ + + + + {#each targets.data as t (t.id)} + {@const rows = rowsOf(t)} + + {#each rows as r, i (r?.slot_id ?? -1)} + + {#if i === 0} + + {/if} + + + + {#if i === 0} + + + {/if} + + {/each} + + {/each} +
TargetAddressStatus{multiSvc ? 'Checks per service' : 'Last check'}WeightActions
+ + + {t.type === 'ip' ? t.ip : t.type === 'host' ? (r?.name ?? `Host #${t.host_id}`) : t.label_selector} + + + {#if t.type === 'label_selector'}{t.resolved.length} matching host{t.resolved.length === 1 ? '' : 's'}{/if} + {#if t.port}port {t.port}{/if} + {#if t.use_private_ip}private IP{/if} + {@render adminBadge(t)} + + + {#if r}{#if t.type === 'label_selector' && r.name}{r.name}{/if}{r.address}{:else}{t.type === 'label_selector' ? 'No hosts match yet' : '—'}{/if} + {#if r}{:else}{/if}
+ {#if r?.checks.length} + {#if multiSvc} + {#each r.checks as c (c.service_id)} + + {svcLabel(c.service_id)} + + {/each} + {:else} + {r.checks[0].check ? checkText(r.checks[0].check) : '—'} + {/if} + {:else}—{/if} +
{t.weight}
+
+{/if} + + + {#key pickerKey} + + {/key} + {#if serverErr}{/if} + {#snippet footer()} + + + {/snippet} + + + +
{ e.preventDefault(); void saveWeight(); }}> +
+ + + {#if !weightOk}Enter a whole number from 0 to 256 + {:else}Relative share of new connections. 0 stops new traffic without draining.{/if} +
+
+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/web/src/routes/projects/new/+page.svelte b/web/src/routes/projects/new/+page.svelte new file mode 100644 index 0000000..774d782 --- /dev/null +++ b/web/src/routes/projects/new/+page.svelte @@ -0,0 +1,314 @@ + + +Create project · Loxy + + +
+

Create project

+

Three short steps. It gets its own port the moment you create it; a domain is optional.

+
+ +
+
+
+

Name it

+
+ + clear('name')} placeholder="shop" autocomplete="off" spellcheck="false" aria-invalid={err.name ? 'true' : undefined} aria-describedby="pname-h" /> + {#if err.name}{err.name}{:else}Only you see this, in the panel.{/if} +
+
+ +
+

Where should traffic go?

+

Targets are the servers that receive the traffic. You can add or change them later.

+
+ + +
+ + {#if targetServerErr}{/if} +
+ + clear('destination_port')} aria-invalid={err.destination_port ? 'true' : undefined} aria-describedby="sdest-h" /> + {#if err.destination_port}{err.destination_port} + {:else}The port your servers answer on.{/if} +
+
+ +
+

How people reach it

+
+
+ + clear('listen_port')} aria-invalid={err.listen_port ? 'true' : undefined} aria-describedby="slisten-h" /> + {#if err.listen_port}{err.listen_port} + {:else}Leave on Auto: Loxy picks the next free port from 20000.{/if} +
+ {#if svc.protocol !== 'tcp'} +
+ + + {#if err.domain}{err.domain}{:else}Served on ports 80 and 443. You'll get the DNS record to create next.{/if} +
+ {/if} +
+ {#if useDomain} + + {/if} + {#each svcOther as m (m)}

{m}

{/each} +
+ +
+ More options description, algorithm, HTTPS without a domain +
+
+ + + {#if err.description}{err.description}{/if} +
+ +
+ How to share traffic between servers +
+ {#each ALGS as a (a.id)} + + {/each} +
+ {#if err.algorithm}

{err.algorithm}

{/if} +
+ + {#if svc.protocol !== 'tcp'} + + {#if svc.protocol === 'https'} + clear('certificates')} oncreated={() => void certs.reload()} /> +

Using a domain? You don't need this: HTTPS on 443 is set up for the domain automatically.

+ {/if} + {/if} +

Health checks, timeouts, sticky sessions and PROXY protocol are on the Services tab after creating.

+
+
+ + {#each generalErr as m (m)}{/each} + +
+ Cancel + +
+
+ + +
+ + diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 414db8d..de78f89 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -133,7 +133,7 @@
{#if uErr.email}{uErr.email}{/if}
{#if uErr.password}{uErr.password}{/if}
-
+ {#snippet footer()} diff --git a/web/src/routes/settings/ApiTokens.svelte b/web/src/routes/settings/ApiTokens.svelte index cc60b7a..8bccd67 100644 --- a/web/src/routes/settings/ApiTokens.svelte +++ b/web/src/routes/settings/ApiTokens.svelte @@ -129,7 +129,7 @@ {#if err.role}{err.role}{/if} diff --git a/web/src/routes/settings/Notifications.svelte b/web/src/routes/settings/Notifications.svelte index cdfc8f7..069dce8 100644 --- a/web/src/routes/settings/Notifications.svelte +++ b/web/src/routes/settings/Notifications.svelte @@ -19,13 +19,14 @@ const KIND_LABEL: Record = { webhook: 'Webhook', slack: 'Slack', telegram: 'Telegram', email: 'Email' }; const EVENT_KINDS = [ - ['server.health.changed', 'Server health changed'], + ['target.health.changed', 'Target health changed'], ['apply.failed', 'Config apply failed'], ['config.applied', 'Config applied'], ['config.rollback', 'Config rolled back'], ['domain.status.changed', 'Domain status changed'], ['domain.sync.failed', 'Domain sync failed'], - ['certificate.updated', 'Certificate updated'] + ['certificate.updated', 'Certificate updated'], + ['backup.failed', 'Backup failed'] ] as const; const SEVERITIES: [Severity, string][] = [['info', 'Info and above'], ['warning', 'Warning and above'], ['error', 'Errors only']]; @@ -234,7 +235,7 @@
- {#if rErr.label_filter}{rErr.label_filter}{:else}Label selector on frontend/backend labels. Leave empty to match everything.{/if} + {#if rErr.label_filter}{rErr.label_filter}{:else}Label selector on project labels. Leave empty to match everything.{/if}
diff --git a/web/src/routes/system/+page.svelte b/web/src/routes/system/+page.svelte index 09c5864..c2fa1c0 100644 --- a/web/src/routes/system/+page.svelte +++ b/web/src/routes/system/+page.svelte @@ -36,7 +36,7 @@ if (r) { ipOpen = false; void nodes.reload(); } } async function delIp(n: Node, ipid: number, a: string) { - const ok = await confirm({ title: `Remove ${a}?`, message: 'Frontends bound to this address keep their bind, but it is no longer suggested. Remove those binds first if the address is going away.', confirmLabel: 'Remove address', danger: true }); + const ok = await confirm({ title: `Remove ${a}?`, message: 'It is no longer listed in the DNS records domains need.', confirmLabel: 'Remove address', danger: true }); if (!ok) return; const r = await mutate(api.DELETE('/nodes/{id}/ips/{ipid}', { params: { path: { id: n.id, ipid } } }), { success: 'Address removed' }); if (r !== undefined) void nodes.reload(); @@ -84,14 +84,14 @@
Config version
-
applied {n.applied_version} · desired {n.desired_version}{#if n.applied_version === n.desired_version} in sync{/if}
+
applied {n.applied_version} · desired {n.desired_version}{#if n.applied_version === n.desired_version}in sync{/if}
Features
{#each n.features ?? [] as f (f)}{f}{:else}—{/each}

Node addresses

- Suggested when you add binds to a frontend. + Used for the DNS records of your domains and for project endpoints.
@@ -111,7 +111,7 @@
- {:else}

No addresses yet. Frontends can still bind to “*” (all addresses).

{/if} + {:else}

No addresses yet. Add the server's public IPs so Loxy can show which DNS records domains need.

{/if} {/each} {/if} @@ -177,4 +177,6 @@ diff --git a/web/static/favicon.svg b/web/static/favicon.svg index b7ff7c2..a6ed5be 100644 --- a/web/static/favicon.svg +++ b/web/static/favicon.svg @@ -1 +1 @@ - +