diff --git a/README.md b/README.md index e072446d..a0babc9c 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,20 @@ General workflow: - THe UI Uses the token to invoke Luci APIs: `curl http://localhost:8080/clientX/cgi-bin/luci/rpc/...` +### Serving unit UIs + +A unit reporting a supported `ui_version` is opened at `https:////`, where +traefik proxies the unit's own nginx and the unit serves its own UI at its own version. Older units +render the copy of the standalone UI bundled into the controller. + +This runs unit-supplied JavaScript on the controller's origin — a path prefix is not an origin +boundary, so everything under `//` shares one `localStorage` with the controller UI. And +`/www-ns/branding.js` is a conffile, editable on the unit and loaded before the app. + +**Accepted trade-off: root on any managed unit is equivalent to controller admin.** If that ever +stops being acceptable, give each unit its own origin (per-unit subdomain plus wildcard +certificate) rather than filtering the shared one. + ### Services The controller is composed by 4 services: diff --git a/api/methods/unit.go b/api/methods/unit.go index dd96cef8..c279eb1b 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -100,9 +100,23 @@ func GetUnit(c *gin.Context) { } func GetToken(c *gin.Context) { + // extract user from JWT claims + user := jwt.ExtractClaims(c)["id"].(string) + // get unit id unitId := c.Param("unit_id") + // This endpoint hands out a full admin token for the unit, so it must be gated on the caller's + // grants and not merely on being authenticated to the controller. + if !UserCanAccessUnit(user, unitId) { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "user does not have access to this unit", + Data: nil, + })) + return + } + token, expire, err := getUnitToken(unitId) if err != nil { diff --git a/api/models/unit.go b/api/models/unit.go index 884977ec..d20546b4 100644 --- a/api/models/unit.go +++ b/api/models/unit.go @@ -46,7 +46,10 @@ type UnitInfo struct { SSHPort int `json:"ssh_port"` FQDN string `json:"fqdn"` APIVersion string `json:"api_version"` - Description string `json:"description"` + // UIVersion is the ns-ui package version. Empty on units whose ns-api predates it, which the + // controller UI treats as "cannot serve its own UI under a path prefix". + UIVersion string `json:"ui_version"` + Description string `json:"description"` } type CheckSystemUpdate struct { diff --git a/proxy/entrypoint.sh b/proxy/entrypoint.sh index 8b36beb8..e933e5eb 100755 --- a/proxy/entrypoint.sh +++ b/proxy/entrypoint.sh @@ -13,6 +13,8 @@ api_port=${API_PORT:-5000} allowed_ips=${ALLOWED_IPS:-""} public_endpoints=${PUBLIC_ENDPOINTS:-""} +# All generated files merge into one traefik config, so every router/service/middleware name must +# be unique across them. Duplicates are dropped first-wins, in alphabetical file order. output_public_routers () { if [ -n "$public_endpoints" ]; then OLD_IFS="$IFS" @@ -24,8 +26,10 @@ output_public_routers () { printf " routerapi%s:\n" "$name" printf " entryPoints:\n" printf " - web\n" + # higher than routerapi so public endpoints bypass the IP allow list + printf " priority: 60\n" printf " middlewares:\n" - printf " - stripprefix\n" + printf " - stripprefix-api\n" printf " service: service-api\n" printf " rule: PathPrefix(\`%s\`)\n" "$endpoint" done @@ -33,7 +37,7 @@ output_public_routers () { } output_middlewares_list () { - printf " - stripprefix\n" + printf " - %s\n" "$1" if [ -n "$allowed_ips" ]; then printf " - ipallowlist\n" fi @@ -85,9 +89,6 @@ providers: serversTransport: insecureSkipVerify: true -core: - defaultRuleSyntax: v2 - EOF cat << EOF > "${CONFIG_DIR}api.yaml" @@ -97,8 +98,9 @@ $(output_public_routers) routerapi: entryPoints: - web + priority: 50 middlewares: -$(output_middlewares_list) +$(output_middlewares_list stripprefix-api) service: service-api rule: PathPrefix(\`/api\`) @@ -110,28 +112,33 @@ $(output_middlewares_list) passHostHeader: true middlewares: - stripprefix: + stripprefix-api: stripPrefix: prefixes: - "/api" $(output_whitelist_middleware) EOF +# Both files used to define a middleware named "stripprefix" with different prefixes; api.yaml won +# the merge, so /ui was forwarded unstripped. Hence the -api/-ui suffixes. +# ipallowlist stays duplicated in both files: identical definitions are harmless, whereas a +# cross-file reference that failed to resolve would fail open. cat << EOF > "${CONFIG_DIR}ui.yaml" http: routers: -$(output_public_routers) - routerui: entryPoints: - web + priority: 50 middlewares: -$(output_middlewares_list) +$(output_middlewares_list stripprefix-ui) service: service-ui rule: PathPrefix(\`/ui\`) routerui-root: entryPoints: - web + # fallback for anything not claimed by the API, /ui, or a per-unit route + priority: 1 $(output_ui_middlewares_list) service: service-ui rule: PathPrefix(\`/\`) @@ -144,7 +151,7 @@ $(output_ui_middlewares_list) passHostHeader: true middlewares: - stripprefix: + stripprefix-ui: stripPrefix: prefixes: - "/ui" diff --git a/ui/Containerfile b/ui/Containerfile index 15e5bd45..12524cc0 100644 --- a/ui/Containerfile +++ b/ui/Containerfile @@ -1,16 +1,14 @@ FROM docker.io/alpine:3.22.5 AS build RUN apk add --no-cache \ - git \ - nodejs \ - npm + git \ + nodejs \ + npm WORKDIR /build # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui -ARG UI_VERSION=2.23.3 -# FIXME: when git 2.49 is available in alpine, use --revision="$UI_VERSION" instead of --branch="$UI_VERSION" -RUN git clone --depth=1 --branch="$UI_VERSION" https://github.com/NethServer/nethsecurity-ui . \ +ARG UI_VERSION=077c2ec4aa729d3af77e08112e21d84195f12690 +RUN git clone --depth=1 --revision="$UI_VERSION" https://github.com/NethServer/nethsecurity-ui . \ && npm ci \ - && sed -i 's/standalone/controller/g' .env.production \ - && npm run build + && VITE_UI_MODE=controller npm run build FROM docker.io/alpine:3.22.5 AS dist RUN apk add --no-cache lighttpd diff --git a/vpn/handle-connection b/vpn/handle-connection index c3d7eea7..0f6a2019 100755 --- a/vpn/handle-connection +++ b/vpn/handle-connection @@ -19,14 +19,19 @@ else fi # Add route to traefik -cat < /etc/openvpn/proxy/$common_name.yaml +# Written via .tmp + mv so traefik's fsnotify watcher cannot read a half-written file. +cat < /etc/openvpn/proxy/$common_name.yaml.tmp http: # Add the router routers: router$common_name: entryPoints: - web + # must outrank the catch-all PathPrefix(\`/\`) serving the controller UI + priority: 100 + # addslash must come before stripprefix middlewares: + - m$common_name-addslash - m$common_name-stripprefix service: service-$common_name rule: PathPrefix(\`/$common_name\`) @@ -40,9 +45,20 @@ http: passHostHeader: true # Add middleware + # Names are unit-prefixed: all units share one file-provider namespace, duplicates merge + # first-wins. middlewares: + # Without the trailing slash the unit's relative asset URLs resolve against the origin root + # and hit the controller's bundle. Replacement is path-only: traefik ignores + # X-Forwarded-Proto here, so an absolute one would emit a http:// Location. + m$common_name-addslash: + redirectRegex: + regex: "^https?://[^/]+/$common_name(\\\\?.*)?\$" + replacement: "/$common_name/\${1}" + permanent: false m$common_name-stripprefix: stripPrefix: prefixes: - "/$common_name" -EOF \ No newline at end of file +EOF +mv /etc/openvpn/proxy/$common_name.yaml.tmp /etc/openvpn/proxy/$common_name.yaml \ No newline at end of file