Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<controller>/<unit-uuid>/`, 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 `/<unit-uuid>/` 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:
Expand Down
14 changes: 14 additions & 0 deletions api/methods/unit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion api/models/unit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 18 additions & 11 deletions proxy/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,16 +26,18 @@ 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
fi
}

output_middlewares_list () {
printf " - stripprefix\n"
printf " - %s\n" "$1"
if [ -n "$allowed_ips" ]; then
printf " - ipallowlist\n"
fi
Expand Down Expand Up @@ -85,9 +89,6 @@ providers:
serversTransport:
insecureSkipVerify: true

core:
defaultRuleSyntax: v2

EOF

cat << EOF > "${CONFIG_DIR}api.yaml"
Expand All @@ -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\`)

Expand All @@ -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(\`/\`)
Expand All @@ -144,7 +151,7 @@ $(output_ui_middlewares_list)
passHostHeader: true

middlewares:
stripprefix:
stripprefix-ui:
stripPrefix:
prefixes:
- "/ui"
Expand Down
14 changes: 6 additions & 8 deletions ui/Containerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 18 additions & 2 deletions vpn/handle-connection
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,19 @@ else
fi

# Add route to traefik
cat <<EOF > /etc/openvpn/proxy/$common_name.yaml
# Written via .tmp + mv so traefik's fsnotify watcher cannot read a half-written file.
cat <<EOF > /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\`)
Expand All @@ -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
EOF
mv /etc/openvpn/proxy/$common_name.yaml.tmp /etc/openvpn/proxy/$common_name.yaml
Loading