Skip to content

feat(support): add support service with WebSockets and Yamux - #47

Open
edospadoni wants to merge 70 commits into
mainfrom
feature/support-service
Open

edospadoni wants to merge 70 commits into
mainfrom
feature/support-service

Conversation

@edospadoni

@edospadoni edospadoni commented Mar 10, 2026 •

Copy link
Copy Markdown
Member

Support Service — Architecture

How it works

A tunnel client on the customer's system opens a persistent WebSocket to our support service. The connection is multiplexed with yamux — one WebSocket carries many parallel streams. When an operator clicks "Open" in the UI, traffic flows through the tunnel to reach the remote service (web UI, terminal, API) as if it were local.

graph LR
    subgraph Customer System
        TC[tunnel-client<br/>yamux mux] --> WU[Web UI]
        TC --> SA[SSH/API]
        TC --> ETC[...]
    end

    TC ---|WebSocket<br/>single connection| SS

    BR[Browser<br/>operator] --> NG[nginx<br/>proxy]
    NG --> BE[Backend :8080<br/>sessions, auth]
    BE --> SS[Support :8082<br/>tunnels, yamux]
Loading

Session Lifecycle

stateDiagram-v2
    [*] --> pending
    pending --> active : WebSocket established
    active --> closed : operator closes
    active --> grace_period : disconnect
    grace_period --> active : reconnect (same session)
    grace_period --> expired : timeout (30-60s)
Loading

WebSocket + yamux Multiplexing

The tunnel client opens one WebSocket to the support service. On top of it, yamux creates a multiplexed session — like having many TCP connections inside a single one.

WebSocket connection (single, persistent)
|
+-- yamux session
    |
    +-- stream #0  [manifest]     client → server: service list (JSON)
    +-- stream #1  [diagnostics]  client → server: health report (JSON)
    +-- stream #2  [users]        client → server: ephemeral credentials report (JSON)
    +-- stream #3  [COMMAND]      server → client: add_services / remove_services
    +-- stream #4  [HTTP proxy]   operator browses NethVoice UI
    +-- stream #5  [terminal]     operator opens xterm.js shell
    +-- ...        (up to 64 concurrent streams per tunnel)

How it connects:

  1. Tunnel client sends GET /support/api/tunnel with HTTP Basic Auth
  2. Support service upgrades to WebSocket, wraps it as net.Conn
  3. yamux.Server is created over the wrapped connection (keepalive 15s)
  4. Client opens a manifest stream with a JSON service list — the reachable services on the remote system
  5. Client opens a diagnostics stream with a health snapshot (CPU, RAM, disk, uptime + plugin results)
  6. Client provisions ephemeral support users and opens a users stream to report credentials
  7. Each proxied request from an operator opens a new yamux stream, forwarded to the target service on the customer system

Server-initiated streams: the support service can also open streams toward the tunnel-client. These start with a COMMAND <version>\n header and carry a JSON payload. The tunnel-client processes the command and responds OK\n or ERROR <msg>\n.

On disconnect: the tunnel enters a grace period (30-60s). If the client reconnects, the same session is reused. If the grace expires, the session is closed and ephemeral credentials are wiped from the database.


Ephemeral User Provisioning

When the tunnel-client connects, it provisions temporary support users on the managed system:

Platform What is created Privileges
NS8 (NethServer) Cluster-admin user + domain users (one per LDAP/Samba domain) Cluster owner role (*)
NethSecurity Local user promoted to admin Full web UI admin access

Lifecycle:

  1. On connect: tunnel-client creates users, stores credentials in a local state file (/var/run/my-support-users.json), reports them to the server via yamux USERS_REPORT stream
  2. On disconnect/session end: tunnel-client deletes all ephemeral users and removes the state file
  3. On crash recovery: next startup reads the orphaned state file, runs cleanup (delete users + teardown plugins), then removes the file

After user provisioning, users.d/ plugins configure applications to accept the support credentials (e.g., creating FreePBX admin entries for NethVoice).

Plugin system details: see Plugin Systems: diagnostics.d/ and users.d/ for the complete developer reference on writing diagnostic and user configuration plugins.


Static Service Injection

Operators can add arbitrary host:port services to a running tunnel without reconnection. This is useful for services not auto-discovered via Traefik — for example the web management interface of a device on the customer's LAN (IP phone, managed switch, NAS, etc.).

Operator clicks "Add service" → fills in name, target (host:port), label, TLS
  → POST /api/support-sessions/:id/services
  → Backend validates and publishes to Redis pub/sub: {action: "add_services", services: {...}}
  → Support service opens an outbound yamux COMMAND stream to the tunnel-client
  → Tunnel-client merges the new service, re-sends updated manifest
  → Support service updates its registry for that session
  → Operator can immediately open the new service via the subdomain proxy

Example: to access a Yealink phone's web UI at 192.168.1.100:443 on a customer system, add a service with target: 192.168.1.100:443, tls: true. The phone's interface becomes available at:

https://phone-yealink--<session-uuid>.support.my.nethesis.it/

…as if the operator were on the same LAN as the phone.


How the UI Proxy works (subdomain)

When an operator clicks a service link (e.g. NethVoice UI), the browser opens a new tab on a dedicated subdomain. Each service gets its own origin, so all the app's absolute paths (/_next/, /api/, /static/) work natively.

1. Frontend: POST /api/support-sessions/:id/proxy-token  {service: "nethvoice-ui"}
   Backend:  generates scoped JWT (session_id + service_name + org_role, 8h TTL)
   Response: {url: "https://nethvoice-ui--c37f4ce78b024a1fb123456789abcdef.support.example.com/", token: "ey..."}

2. Browser navigates to: https://nethvoice-ui--c37f4ce78b024a1fb123456789abcdef.support.example.com/?token=ey...
   nginx:   matches *.support.* --> rewrites to /support-proxy/* --> backend
   Backend: validates JWT, sets HttpOnly SameSite=Strict cookie, redirects to same URL without ?token=

3. All subsequent requests carry the cookie automatically:
   Browser --> nginx --> Backend (SubdomainProxy) --> Support service --> yamux stream --> Customer system

The ?token= is removed from the URL after the first request (redirect), so it never leaks in logs, referrer headers, or browser history.


How the Web Terminal works (xterm.js)

The terminal needs a WebSocket from the browser, but browsers can't send Authorization headers on WebSocket connections. Solution: one-time ticket exchanged beforehand.

1. Frontend: POST /api/support-sessions/:id/terminal-ticket  (JWT in Authorization header)
   Backend:  generates random ticket, stores in Redis with 30s TTL
   Response: {ticket: "a1b2c3..."}

2. Frontend opens WebSocket: GET /api/support-sessions/:id/terminal?ticket=a1b2c3...
   Backend:  Redis GETDEL (atomic read + delete, single-use)
             validates ticket matches session
             opens raw TCP to support service, sends WebSocket upgrade with X-Session-Token
             hijacks browser connection (http.Hijacker)
             bridges both sides bidirectionally:

   Browser (xterm.js) <--WebSocket--> Backend (TCP bridge) <--WebSocket--> Support <--yamux stream--> PTY on customer system

The tunnel client spawns a PTY (pseudo-terminal) directly on the customer system — no SSH daemon involved. The PTY output is forwarded as raw bytes through the yamux stream back to the browser's xterm.js.

Why TCP hijacking instead of httputil.ReverseProxy?

httputil.ReverseProxy can't handle WebSocket upgrades. After the 101 Switching Protocols, the connection becomes a raw bidirectional byte stream — not HTTP. The solution is http.Hijacker: take control of the raw TCP socket from Go's HTTP server. Two goroutines then io.Copy bytes in both directions (browser ↔ support service) with no HTTP overhead.


Access Patterns & Auth

Who does what Auth mechanism How it works
System → tunnel HTTP Basic Auth system_key:system_secret (SHA256), 3-tier cache (memory → Redis → DB), rate-limited
Operator → session CRUD JWT + RBAC connect:systems permission, RBAC scope verified on all operations
Operator → web terminal One-time ticket JWT exchanged for 30s Redis ticket → GETDEL on use → WebSocket via TCP hijack
Operator → UI proxy Scoped proxy JWT 8h token with {session_id, service_name, org_role} → SameSite=Strict cookie on subdomain
Backend → support service Per-session token + INTERNAL_SECRET X-Session-Token (64-char hex, per-session) + INTERNAL_SECRET (required, fail-fast at startup)

Security Highlights

🔑 No shared secrets Each session gets its own token. Compromising one doesn't affect others
🎫 Terminal ticket 30s TTL, single-use (GETDEL), JWT never touches the URL
🍪 Proxy cookie Token arrives as ?token=, stored as HttpOnly SameSite=Strict cookie, URL cleaned via redirect
⏱ Constant-time comparisons crypto/subtle for all token validations
🛡 SSRF protection Blocks cloud metadata, link-local, multicast + DNS re-validation at proxy time
🖼 Frame protection CSP frame-ancestors 'self' on proxied responses
🔄 Cache invalidation Secret regeneration → Redis pub/sub → flush memory + Redis caches instantly
📝 Audit trail Every operator action logged: who, when, what service, access type
🧹 Credential cleanup Ephemeral credentials wiped from DB on session close/expire, advisory lock prevents race conditions
🔒 INTERNAL_SECRET Required at startup (fail-fast), HMAC-signed Redis commands, no fallback
🔐 RBAC enforcement Close/Extend/Add/Remove services all verify organization scope

Inter-service Communication

Backend ──INTERNAL_SECRET────▶ Support Service    (required, HMAC-signed Redis commands)
Backend ──X-Session-Token────▶ Support Service    (per-request, per-session scope)
Backend ──Redis pub/sub──────▶ Support Service    (close, add_services, remove_services, cache invalidation)
Support ──yamux COMMAND──────▶ Tunnel Client      (server-initiated: add_services, remove_services)
Support ──yamux stream───────▶ Tunnel Client      (proxied HTTP, terminal)
Support ──WebSocket 4000─────▶ Tunnel Client      (graceful close, no reconnect)

Components & Files

Component Path Purpose
Support service services/support/ WebSocket tunnels, yamux, session DB, service proxy
Tunnel client services/support/cmd/tunnel-client/ Runs on customer system: discovery, users, diagnostics, streams
Backend APIs backend/methods/support.go, support_proxy.go Session CRUD, terminal ticket, proxy token, subdomain proxy
Frontend frontend/src/components/support/ Session dashboard, service list, terminal (xterm.js)
Proxy proxy/nginx.conf Subdomain routing, tunnel endpoint exposure
DB schema backend/database/migrations/009_*, 018_*–022_* support_sessions, support_access_logs, diagnostics, users columns

Related Comments


Testing Environment

To trigger a fresh deployment of all services in the PR preview environment, comment:

update deploy

Automatic PR environments:

Merge Checklist

Code Quality:

  • Backend Tests
  • Collect Tests
  • Sync Tests
  • Frontend Tests

Builds:

  • Backend Build
  • Collect Build
  • Sync Build
  • Frontend Build

@edospadoni
edospadoni deployed to feature/support-service - my-frontend-qa PR #47 March 10, 2026 08:00 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 08:00 — with Render Active
@github-actions

Copy link
Copy Markdown
Contributor

🔗 Redirect URIs Added to Logto

The following redirect URIs have been automatically added to the Logto application configuration:

Redirect URIs:

  • https://my-proxy-qa-pr-47.onrender.com/login-redirect

Post-logout redirect URIs:

  • https://my-proxy-qa-pr-47.onrender.com/login

These will be automatically removed when the PR is closed or merged.

@github-actions

github-actions Bot commented Mar 10, 2026 •

Copy link
Copy Markdown
Contributor

🤖 my.nethesis.it API structural change detected

Preview documentation

Structural change details

Added (13)

  • DELETE /support-sessions/{id}
  • GET /support-sessions
  • GET /support-sessions/diagnostics
  • GET /support-sessions/{id}
  • GET /support-sessions/{id}/diagnostics
  • GET /support-sessions/{id}/logs
  • GET /support-sessions/{id}/services
  • GET /support-sessions/{id}/terminal
  • GET /support-sessions/{id}/users
  • PATCH /support-sessions/{id}/extend
  • POST /support-sessions/{id}/proxy-token
  • POST /support-sessions/{id}/services
  • POST /support-sessions/{id}/terminal-ticket

Modified (5)

  • GET /systems
    • Response modified: 200
      • Content type modified: application/json
        • Property modified: data
          • Property modified: systems
  • GET /systems/{id}
    • Response modified: 200
      • Content type modified: application/json
        • Property modified: data
          • Property added: support_session_id
  • POST /systems
    • Response modified: 201
      • Content type modified: application/json
        • Property modified: data
          • Property added: support_session_id
  • POST /systems/{id}/regenerate-secret
    • Response modified: 200
      • Content type modified: application/json
        • Property modified: data
          • Property added: support_session_id
  • PUT /systems/{id}
    • Response modified: 200
      • Content type modified: application/json
        • Property modified: data
          • Property added: support_session_id
Powered by Bump.sh

@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 08:11 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 08:15 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 08:52 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 09:32 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 09:39 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 09:54 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 10:07 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 10:22 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 10:31 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 10:39 — with Render Active
@edospadoni
edospadoni force-pushed the feature/support-service branch from c62b877 to 007bd6d Compare March 10, 2026 10:53
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 10:54 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 13:23 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 10, 2026 14:33 — with Render Active
@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 11, 2026 13:09 — with Render Active
@edospadoni

edospadoni commented Mar 11, 2026 •

Copy link
Copy Markdown
Member Author

tunnel-client binary (linux/amd64)

Download:

tunnel-client.zip

Quick start

# Make it executable
chmod +x tunnel-client-linux-amd64

# Run it
./tunnel-client-linux-amd64 \
  --url wss://my-proxy-qa-pr-47.onrender.com/support/api/tunnel \
  --key <SYSTEM_KEY> \
  --secret <SYSTEM_SECRET>

Parameters

Flag Env var Description
-u, --url SUPPORT_URL WebSocket tunnel URL (required)
-k, --key SYSTEM_KEY System key from registration (required)
-s, --secret SYSTEM_SECRET System secret from registration (required)
-n, --node-id NODE_ID Cluster node ID, auto-detected on NS8
-r, --redis-addr REDIS_ADDR Redis address, auto-detected on NS8
--static-services STATIC_SERVICES Manual service definition: name=host:port[:tls],...
--exclude EXCLUDE_PATTERNS Comma-separated glob patterns to exclude services
--tls-insecure TLS_INSECURE Skip TLS certificate verification
--discovery-interval DISCOVERY_INTERVAL Service re-discovery interval (default 5m)
--reconnect-delay RECONNECT_DELAY Base reconnect delay (default 5s)
--max-reconnect-delay MAX_RECONNECT_DELAY Max reconnect delay (default 5m)
--diagnostics-dir DIAGNOSTICS_DIR Directory with diagnostic plugin scripts (default /usr/share/my/diagnostics.d)
--diagnostics-plugin-timeout DIAGNOSTICS_PLUGIN_TIMEOUT Timeout per diagnostic plugin (default 10s)
--diagnostics-total-timeout DIAGNOSTICS_TOTAL_TIMEOUT Max time to wait for all diagnostics before giving up (default 30s)

Service discovery modes

The tunnel-client auto-detects the environment:

  • NS8: discovers services from Redis + Traefik routes
  • NethSecurity: discovers services from OpenWrt/nginx config
  • Static: define services manually with --static-services

Diagnostics plugin system

At connect time, the tunnel-client collects a health snapshot and sends it to MY over the tunnel. Operators see the results directly in the support session popover — before opening a terminal or proxy — so they have immediate context on the system state.

How it works:

  1. When the tunnel-client starts, it runs all diagnostic plugins in parallel with the WebSocket connection (no delay to the connection itself)
  2. After sending the service manifest, it waits up to --diagnostics-total-timeout (default 30s) for the plugins to finish, then pushes the aggregated report over a dedicated yamux stream
  3. The support service stores the report on the session; MY shows it in the session popover

Built-in plugin (system): always runs regardless of configuration. Collects:

  • OS name and version (from /etc/os-release)
  • CPU load averages (1m / 5m / 15m from /proc/loadavg)
  • RAM usage (from /proc/meminfo, warning >85%, critical >95%)
  • Root disk usage (warning >85%, critical >95%)
  • System uptime

External plugins: any executable file placed in /usr/share/my/diagnostics.d/ is run automatically. Files are executed in alphabetical order, each with its own timeout. This allows NS8 modules, NethSecurity, and third-party integrations to ship their own health checks independently.

Each plugin must:

  • Write a JSON object to stdout
  • Signal severity via exit code: 0 = ok, 1 = warning, 2 = critical
#!/bin/bash
# /usr/share/my/diagnostics.d/10-myservice.sh

STATUS="ok"
SUMMARY="all good"

if ! systemctl is-active --quiet myservice; then
  STATUS="critical"
  SUMMARY="myservice is not running"
fi

echo "{\"id\":\"myservice\",\"name\":\"My Service\",\"status\":\"$STATUS\",\"summary\":\"$SUMMARY\"}"
exit $([ "$STATUS" = "ok" ] && echo 0 || echo 2)

The overall session status shown in MY is the worst status across all plugins (critical > warning > ok). If a plugin exceeds its timeout it is marked timeout and does not block the others.

If --diagnostics-dir points to a directory that does not exist, only the built-in system plugin runs — no error, no configuration needed on systems that have not installed any plugins yet.

Environment variables

All flags can also be passed as env vars:

export SUPPORT_URL=wss://my-proxy-qa-pr-47.onrender.com/support/api/tunnel
export SYSTEM_KEY=<your-key>
export SYSTEM_SECRET=<your-secret>
./tunnel-client-linux-amd64

@edospadoni
edospadoni deployed to feature/support-service - my-backend-qa PR #47 March 11, 2026 14:06 — with Render Active
GetSupportSessionLogs now resolves the session through the caller's org scope before returning logs, closing a cross-tenant read. Subdomain proxy token and cookie lifetime are configurable via SUPPORT_PROXY_TOKEN_TTL, defaulting to 1h instead of 8h.
main's alerts/backup release added migrations through 025, colliding with
the support migrations at 018-021. Renumber the five support migrations to
026-030 (preserving order) and drop their stale self-writes to
schema_migrations, which the runner already handles by filename number.
Main took 039-051 meanwhile (048 is reserved for PR #237). Also fix a
leftover conflict marker in schema.sql, make 054 re-runnable, align the
052 comments with schema.sql, take formatDateTimeNoSeconds from
vue-components and declare the support routes in authz/routes.yml.
Keep the users state on persistent storage, record each identity as
soon as it exists and add --cleanup-only for boot-time cleanup. Send
the reconnect token and stop on 410, never delete identities fetched
from the server, keep secrets out of argv, require wss:// unless
--dev, pin injected targets and cap streams, PTYs and header reads.
Strip internal headers before proxying, namespace appliance cookies
per session, encrypt ephemeral credentials at rest and wipe them on
close. Require the reconnect token to resume a session, listen on the
backend auth invalidation channel, serve fetched credentials only to
sibling nodes, fix keepalive and tunnel replacement, bound control
streams, trust only configured proxies and validate manifest paths.
Reject cross-session writes on the subdomain proxy, re-check the
operator on every request with 15m tokens and log first use per
service. Drop the main-origin appliance proxy, gate the Host rewrite
to debug mode, decrypt and audit credential reads, hide session ids
from non-support users and open proxied UIs with noopener. Scope
WebSocket settings to the terminal route, tighten the CSP and support
server headers, make support_enabled a kill switch and run the prod
support service as a private service behind nginx.
A dedicated server block for support.my.nethesis.it (and the QA
twin) exposes only /api/tunnel and answers 404 to everything else,
so the dashboard is never served on a host that shares the cookie
scope of the *.support.* appliance proxy.
The proxy subdomain carried the raw service name, so long route names
or names with dots produced hosts that DNS and the wildcard
certificate reject. Use a DNS-safe label instead: a readable prefix of
at most 20 chars plus 8 hex chars of sha256(name), so
{label}--{session} never exceeds 63 chars. The support service maps
the label back to the service in the session manifest.
@edospadoni

Copy link
Copy Markdown
Member Author

up deploy

@github-actions

Copy link
Copy Markdown
Contributor

🔌 Preview environment resumed

Back online (last build restored):

  • my-backend-qa PR #47
  • my-collect-qa PR #47
  • my-frontend-qa PR #47
  • my-proxy-qa PR #47

Skipped (not found):

  • my-mimir-qa PR #47

Resume restarts the last build. To rebuild the latest commit, comment update deploy.

@edospadoni

Copy link
Copy Markdown
Member Author

update deploy

Auto-updated .render-build-trigger files to ensure all services
are deployed in PR preview environments.

🤖 Generated by GitHub Actions
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Build triggers updated!

All .render-build-trigger files have been automatically updated to ensure fresh deployments of all services in the PR preview environment.

Local services are dialed directly, bypassing Traefik, and the proxy
now strips the browser's X-Forwarded-* headers, so apps that build
absolute redirects from them (FreePBX visualplan) failed with an empty
target. Set X-Forwarded-Host/Proto as Traefik would and rewrite
Location headers pointing at an upstream host to its proxy subdomain.
Local services were dialed on their backend port, skipping the route's
Traefik middlewares (forwardAuth, injected headers, prefix stripping),
so they behaved differently from a direct visit. Dial the local
Traefik like the remote nodes' ones.
NS8 guards some routes (e.g. rspamd) with a Traefik forwardAuth to the
cluster-admin http-basic endpoint, so they take the cluster admin
credentials rather than the module's domain user. The tunnel-client
flags those routes from list-routes' forward_auth and the services
dialog shows the cluster admin credentials next to them.
A services request made while the tunnel or the support service was
reconnecting cached an empty list for the session, hiding the Services
button until a full page reload.
…tors

Credential views are now logged as 'view' access entries, which never
get a disconnect time, so every opening of the services dialog showed
up as a permanently connected operator.
Start, expiry and node count of a system row were aggregated over the
whole session history, so a closed and extended session stretched the
times of the live one. Use the active/pending sessions when the system
has any, otherwise the latest one.
The Nethesis light headset renders at 20x23 px against the 24x24 px of
its Font Awesome solid counterpart, so the menu entry shrank when not
selected. Grow it slightly; other entries render identically already.
The Font Awesome transform misplaces glyphs whose viewBox is not
square (the Nethesis light headset is 24x25) and pushed the icon over
the previous entry. A CSS scale keeps it centred in its box.
Reverts 3c8c29f and 58439a1: the icon choice and sizing are left
to the frontend developer.

This branch was successfully deployed

4 active (3 outdated) deployments
feature/support-service - my-frontend-qa PR #47 — 617baffa Deployed Oct 30, 2026 by edospadoni
feature/support-service - my-backend-qa PR #47 — 654ab190 Deployed Oct 30, 2026 by edospadoni
feature/support-service - my-proxy-qa PR #47 — a5cec8cc Deployed Oct 30, 2026 by render[bot]
feature/support-service - my-collect-qa PR #47 — a5cec8cc Deployed Oct 30, 2026 by edospadoni
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