Skip to content

Add Frontend proxies tab to the HTTP routes page - #1258

Open
stephdl wants to merge 27 commits into
mainfrom
sdl-8098-frontend-proxies
Open

Add Frontend proxies tab to the HTTP routes page#1258
stephdl wants to merge 27 commits into
mainfrom
sdl-8098-frontend-proxies

Conversation

@stephdl

@stephdl stephdl commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Expose the Traefik trusted proxies settings in the cluster admin UI. Until now the
only way to configure them was api-cli run module/traefik1/set-trusted-proxies.

The HTTP routes page gets a tab bar: the existing route table moves into a
Routes tab, and a new Frontend proxies tab is added. The selected tab is
tracked in the view query parameter, so reloading the page keeps it.

The new tab lists one row per node running behind a frontend HTTP proxy, with its
proxy addresses and its trust depth. Nodes without any frontend proxy are hidden,
so a fresh cluster shows an empty state. Above the table sit a text filter, a node
filter and a Clear filters link. Values are read by running get-trusted-proxies
on every Traefik instance, one task per instance, the same way the Routes tab
already runs list-routes.

The Add/Edit modal takes the node, the proxy addresses (one per line) and the
trust depth. The Add dropdown only offers nodes that are not configured yet, since
set-trusted-proxies replaces the whole list of a node. Deleting a row sends an
empty proxy list, which is what clears the configuration.

This is a UI-only change: no Traefik action, schema or configuration template is
touched.

Related issue

NethServer/dev#8098

Design

Built against the Figma source
(node 4597-32737),
frames HTTP routes 2 (empty state), HTTP routes 3 (Add modal) and
HTTP routes 9 (populated table), plus the design Note frame — which is where
the trust depth tooltip requirement comes from.

Four deliberate deviations, all noted below: the CIDR wording, the tab label, the
empty state wording, and the restart warning the design does not show.

Two library gaps had to be filled. ns8-ui-lib ships no routing pictogram, so the
subpaths of the Carbon network--3 icon used by the design live in a new local
NetworkPictogram.vue — the repo's first local pictogram, following the shape of
the library's own pictogram files. And cv-number-input has no tooltip slot, so
the trust depth label reproduces the label-with-tooltip markup of
NsTextInput.vue.

Only bare IPv4 and IPv6 addresses are accepted, no CIDR

The design's textarea placeholder shows 10.0.0.0/24 and its helper text reads
Enter one IPv4, IPv6 or CIDR address per line. Neither was copied, because
the backend rejects CIDR ranges: the field offers 192.168.1.10 and
2001:db8::1 as examples, and the helper text reads Enter one IPv4 or IPv6
address per line
.

The restriction comes from the action's own validation, in
ns8-traefik/imageroot/actions/set-trusted-proxies/20set_trusted_proxies#L29-L38:

def validate_request(request):
    for ipvalue in request['proxies']:
        # Check if ipvalue is a string representing IPv4 or IPv6
        try:
            # IP validation
            ipaddress.ip_address(ipvalue)
        except ValueError:
            agent.set_status('validation-failed')
            json.dump([{'field':'proxies','parameter':'proxies','value': ipvalue,'error':'bad_ip_address'}], fp=sys.stdout)
            sys.exit(3)

ipaddress.ip_address() only parses a bare host address, so a CIDR range exits
with validation-failed before anything is written:

'192.168.1.10'  -> OK
'2001:db8::1'   -> OK
'10.0.0.0/24'   -> ValueError: '10.0.0.0/24' does not appear to be an IPv4 or IPv6 address
'2001:db8::/32' -> ValueError: '2001:db8::/32' does not appear to be an IPv4 or IPv6 address

Two notes about that check:

  • The input schema does not express it. proxies.items is a plain
    {"type": "string"} with no format and no pattern, so the constraint only
    lives in that imperative Python.
  • It is a module-level restriction, not a Traefik one. The target field
    entryPoints.<ep>.forwardedHeaders.trustedIPs does accept CIDR ranges in
    Traefik.

Accepting CIDR would mean switching to ipaddress.ip_network(value, strict=False)
in ns8-traefik, which is out of scope here. The UI is wired for it either way:
the modal maps the backend bad_ip_address code in its -validation-failed-
handler, so relaxing the action later only requires putting the /nn group back
in the client-side patterns and updating the two strings.

Open question, needs a decision: should the UI allow a trust depth of 0?

Not settled, and deliberately left as is in this PR. Today the field mirrors the
action schema — "depth": {"type": "integer", "minimum": 0} — so 0 is accepted, the
Add modal defaults to 1, and the table shows whatever is stored.

Sending 0 is accepted and applied, but it probably never does what an operator
expects. With a non-empty proxy list,
20set_trusted_proxies#L23-L26
stores PROXIES_DEPTH=0request.get('depth', 1) does not fall back to 1, because
the key is present.

What matters is that trustedIPs and depth are two independent mechanisms:

  • forwardedHeaders.trustedIPs is set on the entrypoints, and it is what makes Traefik
    accept the incoming X-Forwarded-For. So the real client IP still reaches the access
    log with a depth of 0.
  • depth only feeds ipAllowList.ipStrategy.depth
    (set-route/20writeconfig#L96-L99),
    so it only affects routes that carry an ip_allowlist.

For those routes, a depth of 0 means the allowlist matches the upstream proxy
address instead of the real client, so a sourceRange written for client IPs silently
stops doing what it says. Traefik documents a depth of 0 or less as making the field
ignored, falling back to the direct peer; that part was read from the documentation and
not verified against a running instance.

There is a second, less visible effect:
restore-module/60restore_settings#L20
gates the whole restore on proxies_depth > 0, so a valid configuration with a depth
of 0 is never restored from a backup
— it reports "Trusted proxies configuration was
not found in the backup. Nothing to do." even though trustedIPs are present. The test
should look at the proxy list, not at the depth. That is an ns8-traefik bug, listed
in the follow-up below.

So the practical position is that a depth of at least 1 is what operators want. Two
things to agree on before changing anything:

  1. Should the UI enforce a minimum of 1, keeping the field adjustable so a chain of
    several proxies is still configurable — which is what the design's -/+ stepper
    and issue Configure Traefik behind frontend HTTP proxy dev#8098 both imply — or is a depth other than 1 never useful
    in practice, in which case the field could go away entirely and 1 be sent always?
  2. If a minimum of 1 is enforced, what should the Edit modal do with an existing
    configuration that already stores 0, set through api-cli or before the change?
    Repairing it to 1 on open makes saving fix the configuration, but reverses the
    earlier "show the depth as returned by the backend" decision; showing 0 with an
    inline error is more faithful but opens the modal in an invalid state.

Either way the UI would become more restrictive than the backend, which keeps
accepting 0 through api-cli. That is a defensible choice, just one worth making
explicitly rather than by accident.

Two inconsistencies inside the design, resolved by majority

  • Tab label: Frontend proxy on the Routes frame, Frontend proxies on every
    other frame. Kept Frontend proxies.
  • Empty state: No trusted proxies configured on frames 3 and 4, No frontend
    proxy configured
    on frames 2 and 9. Kept the frontend proxy wording, which
    is consistent with the tab name and with the user-facing vocabulary of the
    feature; trusted proxies stays the backend term.

Surviving the Traefik restart

set-trusted-proxies ends with systemctl --user restart traefik.service, and
Traefik is what serves the cluster admin UI and its websocket
(ns8-traefik create-module/60cluster_admin routes /cluster-admin). So saving a
frontend proxy drops the admin's own connection — which the design does not mention,
but testing on a real cluster made unmissable.

Two consequences, both handled here.

The restart is announced. The Traefik will be restarted warning is back in the
Add/Edit modal and in the delete confirmation, naming the node and saying the page
will reconnect on its own. This is a deliberate deviation from the design, and it is
what the route and certificate flows already do.

The modal closes as soon as the task is created, like the delete-route flow
already does, instead of spinning until the page reconnects. The completion event
cannot arrive any earlier — Traefik carries the websocket it would travel on — so
waiting for it only showed a spinner for the whole outage. The task notification
reports the outcome, and the table refreshes on reconnection.

Lost task events no longer wedge the page. The loading counters of this UI are
driven purely by websocket task events (loading.listRoutesNum,
loading.getTrustedProxiesNum, loading.setTrustedProxies). When the socket dies
mid-task the completion event is lost, the counter never returns to zero, and the
table stays on its skeleton rows until the browser is reloaded by hand. The three
components now watch the Vuex isWebsocketConnected flag — which App.vue already
maintains and which no component used until now — and on reconnection they reset the
counter and read their data again. Decrements are clamped with Math.max(0, n - 1)
and rows are keyed by node (routes by Traefik instance) so an event delivered after a
reset can neither unbalance the counter nor duplicate a row. The modal simply stops
spinning and closes: Traefik having restarted is the proof the action was applied.

This also fixes the same wedge on the Routes tab, which was reachable by reloading
the page while Traefik was restarting.

One known limitation is left untouched, because it is pre-existing and belongs to the
shared websocket layer rather than to this feature: vue-native-websocket is set up
with both connectManually: true and reconnection: true (src/main.js:24-28), so
there are two independent reconnect drivers. $connect() builds a new Observer on
every call and abandons the previous one, which keeps retrying on its own
(Main.js:16-20, Observer.js:41-55), while App.vue:415-421 schedules another
initWebSocket() after each disconnect. Since every Observer emits into the same
singleton Emitter, the accumulated ones keep re-triggering App.vue's connect and
disconnect handlers, which is why the page appeared to reload endlessly. Fixing that
is a one-line change but it touches plumbing every page depends on and entangled with
login, logout, session expiry and core updates, so it deserves its own change and its
own testing. With the handling above the symptom is contained: the page recovers its
data by itself, no manual reload needed.

Two other backend behaviours the UI works around

  • Omitting depth on a non-empty proxy list makes the action reset it to 1
    (20set_trusted_proxies#L24),
    so the modal always sends both proxies and depth. Editing only the address
    list no longer silently loses a trust depth of, say, 3.
  • get-trusted-proxies builds the list from a Python set()
    (20get_trusted_proxies#L17-L20),
    so the order is not stable between calls. The UI sorts the addresses to keep the
    display consistent across reloads.

How to test

On a cluster with at least two nodes, go to
https://<leader>/cluster-admin/#/settings/http-routes.

  1. The Routes tab still lists the routes, with the node filter and the
    create/edit/delete/detail modals. The URL keeps ?view=routes and reloading
    restores the tab.
  2. The Frontend proxies tab of a fresh cluster shows the empty state: routing
    pictogram, No frontend proxy configured, and a single blue
    Add frontend proxy button inside the card — no button above the table.
  3. Add 192.168.1.10 on node 1 with trust depth 1. The row appears and the
    Add frontend proxy button moves above the table. Reload: the values persist.
    Cross-check with api-cli run module/traefik1/get-trusted-proxies — it returns
    {"proxies": ["192.168.1.10"], "depth": 1}.
  4. Edit node 1 and replace the address with 2001:db8::1. The row updates. The Edit
    modal shows no Node field — the node cannot be changed and the title already names
    it.
  5. Edit node 1 again with three lines: 192.168.1.10, 10.0.0.5, 2001:db8::1.
    All three are listed and stored.
  6. Enter 10.0.0.0/24: inline error Invalid IPv4 address: 10.0.0.0/24, save
    blocked, focus moves to the field. Then 2001:db8::/32 gives the IPv6 message,
    and notanip the IPv4 one.
  7. Set trust depth to 3, save, reload: still 3. Then -1 is rejected inline,
    and an empty value reports Required. The (i) next to Trust depth explains
    the hop count, and Number of frontend proxy levels renders in full under the
    field.
  8. With depth 3 saved, edit only the address list and save. Reload and check the
    depth is still 3.
  9. Configure node 2 with a different list and depth 2. Node 1 is unchanged: the
    setting is per node.
  10. Filter by a node: the table narrows. Clear filters restores it. A text
    filter matching nothing shows the no-results state with its own
    Clear filters button.
  11. Delete node 1's entry. The row disappears, get-trusted-proxies on node 1
    returns {"proxies": [], "depth": 0}, and node 2 is untouched.
  12. With node 2 configured, reopen Add frontend proxy: node 2 is not in the
    dropdown. Once every node has a frontend proxy the button is disabled and carries a
    tooltip saying so — on a single node cluster that is the state right after the first
    configuration, and without the tooltip it reads as a broken button.
  13. From behind an upstream proxy, journalctl --user -u traefik on a configured
    node logs the real client IP instead of the proxy IP.
  14. Stop traefik.service on one node and load the tab: the table shows the error
    state and the other node's row still renders.

The restart handling needs one specific scenario: configure a frontend proxy on the
node your browser is connected to
, since configuring any other node does not drop
your session.

  1. The modal warns that Traefik will be restarted, naming that node.
  2. Save. The modal closes right away. Then do not touch the browser: the
    websocket drops and reconnects, and the table must refresh on its own with the new
    row, with no skeleton left. No manual reload.
  3. Same on delete: the warning shows, and the row disappears on its own after the
    reconnection.
  4. Reload the page by hand during the restart and let it settle: both the Routes
    tab and the Frontend proxies tab must end up populated, neither stuck on skeleton
    rows.

The connected/disconnected toasts may still churn for a while — that is the websocket
layer limitation described above, untouched. What matters is that the page recovers
its data without intervention.

Automated checks

core/ui has no test script and no jest/vitest configuration, so no unit test
was added for the address and trust depth validation. Lint, the production build
and an audit of the i18n keys referenced by the touched components all pass:

cd core/ui
./node_modules/.bin/vue-cli-service lint    # DONE  No lint errors found!
NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/vue-cli-service build
                                            # DONE  Build complete.

yarn lint needs the binary path because vue-cli-service is not on the Yarn 3
PATH, and the build needs --openssl-legacy-provider because webpack 4 uses md4
on Node 22. Both are pre-existing and unrelated to this change.

Reused components

No new UI component was written apart from the pictogram noted above. The tab bar
is NsTabs, following src/components/domains/DomainUsersAndGroups.vue. The
filter row, the two-branch empty state and the primary button inside the empty
state follow src/views/settings/SettingsTlsCertificates.vue. The table, empty
state, modal and delete confirmation are NsDataTable, NsEmptyState, NsModal
and NsDangerDeleteModal from ns8-ui-lib. The IPv4 and IPv6 patterns come from
src/components/settings/CreateOrEditHttpRouteModal.vue, with the CIDR group
removed.

Follow-up in ns8-traefik, out of scope here

Found while reading the actions, none of them touched by this PR:

  • set-trusted-proxies rejects CIDR ranges although Traefik's trustedIPs accepts
    them — see the section above. Would need ipaddress.ip_network(value, strict=False).
  • restore-module/60restore_settings:20 gates the restore on proxies_depth > 0, so a
    valid configuration with a depth of 0 is never restored from a backup.
  • get-trusted-proxies/validate-output.json:3-4 has title and $id saying
    set-trusted-proxies — copy/paste slip.
  • get-trusted-proxies builds the list with list(set(...)), so the order it returns
    is not stable. The UI sorts to work around it.

Dependencies

None.

stephdl added 4 commits July 28, 2026 13:03
Add the action titles for get-trusted-proxies and set-trusted-proxies, the
settings_http_routes keys of the new tab and a generic common.add label.
Let the operator set the frontend proxy addresses and the trust depth of a
node. Accept bare IPv4 and IPv6 addresses only, since the traefik
set-trusted-proxies action validates them with ipaddress.ip_address() and
rejects CIDR ranges. Always send both proxies and depth, because omitting
depth makes the action reset it to 1.
List the nodes running behind a frontend HTTP proxy, one row per node, by
running get-trusted-proxies on every traefik instance. Nodes without any
frontend proxy are hidden. Deleting a row sends an empty proxy list, which
also resets the trust depth to 0.
Move the existing route table into a Routes tab and add a Frontend proxies
tab. Track the selected tab in the view query parameter and pass the
already discovered traefik instances down to the new tab.
@stephdl stephdl self-assigned this Jul 28, 2026
stephdl added 15 commits July 28, 2026 15:06
Use the empty state wording of the design, drop the CIDR example and the word
CIDR from the address field, since the traefik set-trusted-proxies action
rejects CIDR ranges. Add the trust depth tooltip and the node placeholder, and
remove the strings of the description paragraph and of the restart warning.
Hold the subpaths of the Carbon network--3 icon, which the design uses for the
frontend proxies empty state. The library ships no routing pictogram.
Drop the node tooltip and add one to the trust depth, following the label
markup of NsTextInput since cv-number-input has no tooltip slot. Widen the
trust depth field so its helper text is no longer clipped, and remove the
restart warning.
Add the filter row with the node dropdown and the clear filters link, and turn
the table search off in favour of it. Show the add button as primary, inside
the empty state when no node is configured and above the table otherwise.
Drop the description paragraph and the restart warning.
One message for both the save and the delete path: applying either one
restarts Traefik and drops the HTTP clients of the node.
Restore the restart warning: saving disconnects the HTTP clients of the node,
and the design does not mention it. Stop waiting for the task completion event
once the websocket comes back, since Traefik restarting means the action was
applied and the event is gone with the old connection.
Traefik serves the cluster admin UI and its websocket, so restarting it makes
the events of the running get-trusted-proxies tasks unreachable and the table
stayed in its loading state until a manual page reload. Read the values again
when the websocket comes back, and keep the counter and the rows consistent if
an event is delivered after the reset. Restore the restart warning in the
delete confirmation.
Reloading the page while Traefik restarts left the routes table stuck in its
loading state, because the list-routes events were delivered to the closed
websocket. Read the routes again when the websocket comes back.
Hide the node field when editing: it cannot be changed, the modal title already
names the node, and a disabled combo box does not paint its value. Cap the
notifications at the width of the form fields. Close as soon as the task is
created, since the completion event cannot arrive before Traefik has restarted
and the page has reconnected.
Carbon makes the search box and the combo box full width, so cap them to keep
the design proportions. Leave the node filter empty until the user picks a
value and reuse the sentinel of the routes table: NsComboBox resolves the label
of a value only against the options loaded when it is set, so the preselected
sentinel was painted as a raw value.
Keep one line per comment, like the surrounding views, and drop the ones the
code already says.
The button was greyed out with no reason given once every node had a frontend
proxy, which is the normal state of a single node cluster. Wrap it in a tooltip
when there is nothing left to add, the same way the TLS certificates page does,
and keep it plain while the values are still loading. Shorten the comments of
this file too.
Carbon renders number inputs in monospace at 0.75rem, so the value looked much
smaller than the addresses right above it.
NsComboBox repaints its text only when the new value matches an option, so
clearing the value left the previously picked node on screen while the model was
already empty. Remount it when the modal opens for an addition. Hide the restart
warning until a node is picked, since it named an empty node.
Drop the trust depth reset from the delete confirmation: it is an implementation
detail, and a depth without any proxy means nothing. Tighten the restart warning
too.
stephdl added 4 commits July 29, 2026 13:30
The read counters were clamped with Math.max(0, n - 1) because a batch
started on websocket reconnection ran while the events of the previous
one were still arriving, and the surplus decrements could clear the
loading state with an empty table.

Tear the $root listeners down at the start of each read and on destroy,
count the whole batch upfront and drop the clamps. This also stops a
handler from running on a destroyed component.

The first-connection guard is gone: it assumed the socket came up after
the mount, but it is already connected when the page is opened, so the
reconnection that follows a Traefik restart was swallowed and the tables
stayed on their skeleton rows. A duplicated read on a cold load is
harmless now that stale events cannot unbalance a counter.

Release two loading states that stayed true for good: the
list-installed-modules error branch, and the routes counter when the
chain is aborted before it starts.
internalNodes was built once, inside listInstalledModulesCompleted, from
the vuex clusterNodes. App.vue fills that state through get-cluster-status,
which on a page reload usually answers after list-installed-modules: the
list came out empty and both the node filter and the modal combo box had
nothing to offer, while the table rows kept showing because they are built
from the get-trusted-proxies output instead.

Make it a computed of clusterNodes and traefikInstances so it no longer
depends on which task answers first, and move the combo box repaint to a
watcher that runs when the options actually exist.
stephdl added 2 commits July 29, 2026 14:48
NsComboBox fills its input from the options only when its value changes:
the options watcher calls updateOptions(), which never recomputes the text
from the current value. The filter started on an empty value, which matches
no option, so the box kept its placeholder while the table behaved as if
"Any node" were selected.

Re-set the value once the options exist, the workaround the TLS
certificates page already uses on its own node filter.
Carbon gives .bx--grid a max-width of 99rem plus auto margins at its
largest breakpoint, so past 1584px the grid nested in the tab panel was
capped and recentered: the filters, the button and the table all sat well
inside the tile. Nothing shows below that width, which is why a 27" looks
right and a 32" does not.
Same missing fullWidth as the frontend proxies tab, on the grid nested in
the routes tab panel. This one predates the tabs, the grid was already
nested in the tile.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant