Skip to main content
Version: Next

Proxy hosts and load-balancer API

These routes require a session. Every write also requires the matching X-CSRF-Token; see the API overview.

Route table

MethodPathRequired permission / scopeSuccessKey failures
GET/api/proxy-hostsproxy_hosts.read; global or scoped200 Host[]401, 403, 500
POST/api/proxy-hostsproxy_hosts.write; global201 Host400, 403, 409 duplicate_domain, 502 reload_failed
GET/api/proxy-hosts/{id}proxy_hosts.read; that host200 Host401, 404
PATCH/api/proxy-hosts/{id}proxy_hosts.write; that host200 Host400, 404, 409, 502 reload_failed
DELETE/api/proxy-hosts/{id}proxy_hosts.write; that host204401, 404, 502 reload_failed
GET/api/proxy-hosts/{id}/authproxy_hosts.read; that host200 ProxyHostAuth401, 404, 500 database_error
PUT/api/proxy-hosts/{id}/authproxy_hosts.write; that host200 ProxyHostAuth400 invalid_input, 404, `500 database_error
GET/api/load-balancerproxy_hosts.read; global200 LoadBalancerSnapshot401, 403, 503 runtime_unavailable
PUT/api/load-balancerproxy_hosts.write; global200 LoadBalancerSnapshot400 invalid_load_balancer_config, 401, 403, 502 reload_failed, 503 runtime_unavailable

An unauthorized read or write of a particular host is intentionally reported as 404, not 403, so a scoped user cannot discover another host. Listing is different: it returns only hosts visible to the user and returns 403 when the user has neither global nor scoped read permission. Creating a host and updating the whole load-balancer configuration require a global grant; a host-scoped grant is sufficient only for the corresponding existing host.

Proxy host representation

Every host response has exactly these fields:

{
"id": 42,
"name": "local test app",
"domain": "app.example.test",
"upstream_host": "127.0.0.1",
"upstream_port": 3000,
"tls_mode": "disabled",
"certificate_id": null,
"enabled": true
}

POST and PATCH take the same complete request object: name, domain, upstream_host, upstream_port, tls_mode, certificate_id, and enabled. tls_mode defaults to "disabled" and enabled defaults to true when they are absent in a create request. Name, domain, and upstream host cannot be blank; upstream_port cannot be zero. Domains are stored lowercased.

curl --fail-with-body -k -b "$cookie_jar" \
-H "X-CSRF-Token: $csrf_token" \
-H 'Content-Type: application/json' \
--data '{"name":"local test app","domain":"app.example.test","upstream_host":"127.0.0.1","upstream_port":3000,"tls_mode":"disabled","certificate_id":null,"enabled":true}' \
https://127.0.0.1:8081/api/proxy-hosts

The create operation can reject duplicate domains with 409 and duplicate_domain. A host update can also produce 409 for a conflicting persisted configuration. A 502 reload_failed means the configuration change was committed but was not activated in the live proxy; investigate and retry or repair the configuration rather than assuming it was rolled back. Successful host changes publish proxy_hosts.changed.

DELETE has no body on 204. Do not interpret it as a JSON response.

Per-host Basic Auth

GET /api/proxy-hosts/{id}/auth returns only safe configuration:

{
"host_id": 42,
"enabled": true,
"realm": "Test-only protected host",
"username": "test-user",
"updated_at": "2026-08-17T00:00:00Z"
}

The password is write-only and is never returned. When no record exists, the endpoint returns a disabled default with realm set to "BeaRust protected host", an empty username, and an empty updated_at.

After authorization, this read route does not separately check whether the host exists. Therefore, a globally authorized reader receives that disabled default for an unknown {id} as well as for an existing host with no auth record. Its 404 is the authorization mask for a reader without access to the requested host (for example, an out-of-scope scoped reader), not an authoritative existence result. PUT /api/proxy-hosts/{id}/auth does check host existence and returns 404 for an unknown host after authorization.

PUT accepts any subset of enabled, realm, username, and password. The existing values are retained for omitted fields. A supplied password must be 12--512 characters. A realm must be 1--128 characters; a username is at most 128 characters. Enabling Basic Auth requires a username and a stored password. The API stores only a password hash, reloads the host-auth runtime, and publishes proxy_hosts.changed after a successful update.

{
"enabled": true,
"realm": "Test-only protected host",
"username": "test-user",
"password": "test-only-password-at-least-12"
}

Never use a production Basic Auth secret in an API example or commit it to a repository.

Load-balancer configuration

GET /api/load-balancer reads the attached live runtime, rather than a separate static view. Its response is:

{
"generation": 7,
"pools": [
{
"name": "local-pool",
"algorithm": "round_robin",
"connect_timeout_seconds": 5,
"request_timeout_seconds": 30,
"passive_health": true,
"backends": [
{
"id": 0,
"address": "127.0.0.1:3000",
"health_check": "http",
"health_path": "/healthz",
"weight": 1,
"healthy": true,
"inflight": 0,
"response_time_ewma_ms": null,
"passive_failures": 0
}
]
}
],
"routes": [{"name":"test-route","host":"app.example.test","path_prefix":"/","upstream_pool":"local-pool"}],
"capabilities": {
"algorithms": ["round_robin","least_connections","weighted","ip_hash","adaptive_weight","plugin"],
"health_checks": ["tcp","http"],
"passive_health": true,
"adaptive_weighting": true
}
}

id, healthy, inflight, response_time_ewma_ms, and passive_failures are runtime observations, not write fields. PUT /api/load-balancer accepts only pools and routes. Each pool requires name, algorithm, connect_timeout_seconds, request_timeout_seconds, passive_health, and a backends array. A backend has address, health_check (tcp or http), health_path (string or null), and weight. Each route has name, host, path_prefix, and upstream_pool.

The server validates the complete candidate configuration before persisting or activating it. It returns 400 invalid_load_balancer_config when validation fails, 503 runtime_unavailable when no live runtime is attached, and 502 reload_failed when a committed update cannot be applied.

Source pointers

Route handlers and the runtime snapshot are in bearust/src/control_plane/mod.rs; wire types are in bearust/src/control_plane/models.rs and bearust/frontend/src/api.ts. Permission/scope behavior is in bearust/src/control_plane/rbac.rs; runtime read/write behavior is covered by tests/control_plane_load_balancer.rs.