bao: serve the browser UI to admins via a loopback-only listener
openbao gains a second listener, `ui`, on 127.0.0.1:<deploy.bao.uiPort> (default 8204) with TLS off and no client-certificate requirement, and `ui = true`. The existing listeners are unchanged. An nginx inside the store's container, on 127.0.0.1:<deploy.bao.uiProxyPort> (default 8206), forwards only /ui/ and /v1/ to it, redirects / to /ui/, answers 403 on sys/unseal, sys/seal, sys/step-down, sys/rekey* and sys/generate-root*, and 404 on everything else. The gateway on the store's host serves `swarm.bao.ui.domain` (default bao-ui.<swarm>) behind the authelia auth_request subrequest, proxying to that nginx; the name joins serviceDomains and localNames like every other gateway-published swarm service. authelia gets an access_control rule restricting that name to group:admins, rendered wherever authelia runs, since the default policy admits any session. Trade-off, ruled by the operator on the parent issue: the UI listener asks for no client certificate, so on that door a bao token alone is the credential. Three comments and a doc line claimed every API listener demands a client certificate; they now except the loopback UI listener. The module-eval case counting declared listeners excludes `ui` by name, as it already did `metrics`. On a self-signed gateway, the UI's name is a swarm service name, so its host requests the services leaf from the store. `swarm-services-cert` sits Before= and RequiredBy= the gateway's cert import, which nginx Requires=. On a host whose only swarm name is the UI, that would hold nginx, and with it the stream passthrough every reader dials, on a login to a store that may be sealed. hive-tls drops those two edges exactly when the UI is the only local swarm name: nginx starts on the existing hive-leaf fallback, and the script's existing re-import reloads nginx once the leaf issues. Every other host keeps both edges.
This commit is contained in:
parent
a86379eac5
commit
3898ca33c7
10 changed files with 376 additions and 29 deletions
|
|
@ -41,6 +41,13 @@ let
|
|||
deploy.bao.serverKeyFile = "/etc/pki/bao-key.pem";
|
||||
};
|
||||
|
||||
autheliaOnly = hive { deploy.authelia.enable = true; };
|
||||
|
||||
baoWithForge = hive {
|
||||
deploy.bao.enable = true;
|
||||
deploy.forgejo.enable = true;
|
||||
};
|
||||
|
||||
# The store's units live inside its container, so the gates below have to
|
||||
# look there rather than at the host's service set.
|
||||
baoUnits = machine: machine.containers.swarm-bao.config.systemd.services;
|
||||
|
|
@ -281,6 +288,95 @@ let
|
|||
&& !(lib.hasInfix restart sh)
|
||||
&& lib.hasInfix "# then unseal" sh;
|
||||
}
|
||||
{
|
||||
# The browser UI's listener must not loosen the one every reader dials.
|
||||
# `loopback` is named so an empty filter cannot pass.
|
||||
name = "every listener but metrics and the UI still requires a client certificate";
|
||||
ok =
|
||||
let
|
||||
l = (baoSettings baoPkcs11).listener;
|
||||
api = builtins.removeAttrs l [
|
||||
"metrics"
|
||||
"ui"
|
||||
];
|
||||
in
|
||||
api ? loopback
|
||||
&& lib.all (x: (x.tls_require_and_verify_client_cert or false) == true) (lib.attrValues api);
|
||||
}
|
||||
{
|
||||
# No client certificate on this one, so loopback is all that keeps it
|
||||
# away from everything but this netns.
|
||||
name = "the UI listener is loopback-only and the UI is served";
|
||||
ok =
|
||||
let
|
||||
s = baoSettings baoPkcs11;
|
||||
in
|
||||
(s.ui or false) == true
|
||||
&& lib.hasPrefix "127.0.0.1:" (s.listener.ui.address or "")
|
||||
&& !(s.listener.ui ? tls_require_and_verify_client_cert);
|
||||
}
|
||||
{
|
||||
# The nginx in front of it is the listener's only client and must bind
|
||||
# loopback too, and it refuses the endpoints a browser never needs.
|
||||
name = "the UI's nginx binds loopback and refuses the unseal and root-generation paths";
|
||||
ok =
|
||||
let
|
||||
v = baoPkcs11.containers.swarm-bao.config.services.nginx.virtualHosts.bao-ui;
|
||||
in
|
||||
lib.all (x: x.addr == "127.0.0.1") v.listen
|
||||
&& (v.locations."~* ^/v1/sys/(unseal|seal|step-down)/?$".return or null) == "403"
|
||||
&& (v.locations."~* ^/v1/sys/(rekey|generate-root)".return or null) == "403";
|
||||
}
|
||||
{
|
||||
# A new vhost gets no group gate from `auth_request`; authelia's
|
||||
# default policy admits any session. Evaluated on a host that runs
|
||||
# authelia and no store, because that is where the rule has to render.
|
||||
name = "authelia restricts the store's UI name to admins wherever authelia runs";
|
||||
ok =
|
||||
let
|
||||
rules =
|
||||
autheliaOnly.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules;
|
||||
in
|
||||
!autheliaOnly.services.hyperhive.deploy.bao.enable
|
||||
&& builtins.elem {
|
||||
domain = "bao-ui.t.local";
|
||||
subject = [ "group:admins" ];
|
||||
policy = "one_factor";
|
||||
} rules;
|
||||
}
|
||||
{
|
||||
# The other half of that rule: the vhost exists, and asks authelia.
|
||||
name = "the store's host serves the UI name behind the authelia subrequest";
|
||||
ok =
|
||||
let
|
||||
v = baoPkcs11.services.nginx.virtualHosts."bao-ui.t.local";
|
||||
in
|
||||
lib.hasInfix "auth_request /__hive_authelia;" (v.locations."/".extraConfig or "")
|
||||
&& (v.locations."/".proxyPass or "") == "http://127.0.0.1:8206";
|
||||
}
|
||||
{
|
||||
# The stream passthrough every reader dials rides the same nginx as the
|
||||
# UI's vhost. On a host whose only swarm name is the UI, that nginx must
|
||||
# not wait on the services leaf, which needs a login to this very store.
|
||||
# The control is a store host that also fronts forge: its gateway waits,
|
||||
# exactly as it did before the UI existed.
|
||||
name = "a store host fronting only the UI does not hold nginx on a store login";
|
||||
ok =
|
||||
let
|
||||
waits =
|
||||
m:
|
||||
builtins.elem "hive-gateway-self-signed-cert.service" m.systemd.services.swarm-services-cert.requiredBy;
|
||||
orders =
|
||||
m:
|
||||
builtins.elem "hive-gateway-self-signed-cert.service" m.systemd.services.swarm-services-cert.before;
|
||||
in
|
||||
baoPkcs11.services.hyperhive.swarm.localServiceDomains == [ "bao-ui.t.local" ]
|
||||
&& baoPkcs11.systemd.services ? hive-gateway-self-signed-cert
|
||||
&& !(waits baoPkcs11)
|
||||
&& !(orders baoPkcs11)
|
||||
&& waits baoWithForge
|
||||
&& orders baoWithForge;
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "bao-basics" cases
|
||||
|
|
|
|||
|
|
@ -297,8 +297,8 @@ let
|
|||
cases = [
|
||||
{
|
||||
# Reads the rendered unit on the HOST, which is where the write happens:
|
||||
# every API listener demands a client certificate, and the host is the
|
||||
# side that has one.
|
||||
# every API listener but the loopback UI one demands a client
|
||||
# certificate, and the host is the side that has one.
|
||||
name = "a store host renders the granting unit on the host, logging in as the granter";
|
||||
ok =
|
||||
let
|
||||
|
|
|
|||
|
|
@ -115,13 +115,19 @@ let
|
|||
}
|
||||
{
|
||||
# Control for the case above: these settings are rendered per deployment,
|
||||
# not constants a passing case could be indifferent to. The metrics
|
||||
# listener is excluded by name — it answers on a port of its own and is
|
||||
# not one of the API addresses this case is counting.
|
||||
# not constants a passing case could be indifferent to. The metrics and
|
||||
# UI listeners are excluded by name — each answers on a loopback port of
|
||||
# its own and is not one of the declared addresses this case is counting.
|
||||
name = "a declared extra address renders a second listener beside loopback";
|
||||
ok =
|
||||
builtins.length (
|
||||
builtins.filter (n: n != "metrics") (builtins.attrNames (baoSettings baoTwoAddresses).listener)
|
||||
builtins.filter (
|
||||
n:
|
||||
!(builtins.elem n [
|
||||
"metrics"
|
||||
"ui"
|
||||
])
|
||||
) (builtins.attrNames (baoSettings baoTwoAddresses).listener)
|
||||
) == 2;
|
||||
}
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue