Nexus Connect surface¶
All services are mounted on one http.ServeMux in internal/app/app.go; each
Connect handler owns its sub-path (/gateway.v1.GatewayDeviceService/…).
Method-level reference: portal.v1,
gateway.v1, relay.v1.
Services and authentication¶
| Service | Caller | Interceptor | Credential |
|---|---|---|---|
gateway.v1.GatewayEnrollmentService |
agent, first boot | none | bootstrap token in the request |
gateway.v1.GatewayDeviceService |
agent | DeviceTokenInterceptor |
Authorization: Bearer <api_token>, stored SHA-256 hashed in devices.api_token |
gateway.v1.GatewayInternalService |
realtime DO, infrastructure | InternalKeyInterceptor |
X-Internal-Key = NEXUS_INTERNAL_API_KEY |
relay.v1.RelayEnrollmentService |
relay, first boot | none | bootstrap token in the request |
relay.v1.RelayService |
relay | RelayTokenInterceptor |
Authorization: Bearer <relay api_token> |
portal.v1.PortalSessionService, PortalOrgService, PortalGatewayService, PortalAdminService, PortalUsersService |
portal | portalAuth |
Authorization: Bearer <Zitadel access token> |
portal.v1.ReleasePublisherService |
edge/gateway CI |
ReleasePublisherInterceptor |
X-Release-Key = NEXUS_RELEASE_PUBLISHER_KEY |
portal.v1.PortalLinkService is defined and implemented but not mounted.
Portal tokens¶
portalAuth is NewPortalAuthInterceptor (internal/gwconnect/portal_auth.go).
It parses the bearer JWT against Zitadel's JWKS
($NEXUS_ZITADEL_ISSUER/oauth/v2/keys), requires issuer
NEXUS_ZITADEL_ISSUER and audience NEXUS_ZITADEL_PROJECT_ID, and puts sub,
email and name (or given_name family_name) into the context. When the
token carries no email or name, Me fills them from the Zitadel Management API.
Each environment has its own Zitadel project (provisioned by
infra/terraform/modules/environment/zitadel.tf) on a shared Zitadel instance.
The portal requests no project-audience scope, so a token's aud is the
portal client's own project, and a nexus accepts only tokens issued for its own
environment.
With NEXUS_DEV_AUTH=true, portalAuth is replaced by NewDevAuthInterceptor,
which injects a fixed dev-operator identity and checks no token. Local
development only.
Authorization¶
portalAuth is authentication only. It proves who is calling and stops
there. Every portal handler decides for itself whether the caller may proceed,
by calling a gate that asks authz (internal/gwconnect/portal_authz.go and
neighbours). The gate each RPC uses is listed in
portal.v1.
| Gate | Decision |
|---|---|
requireDeviceAccess(id, actions…) |
Action on portal:org/<device org>, else on portal:device/<id>, else platform admin. A device in the holding zone (no org) is platform-admin only. Refuses with NotFound. |
requirePlatformAdmin, isPlatformAdmin, callerIsPlatformAdmin |
* on portal:system (target id *) |
requireSystemAdmin (PortalAdminService) |
codes:create on portal:system; only the PLATFORM_ADMIN wildcard grants it |
requireDeviceManage |
platform admin, or device:manage on portal:system |
requireDeviceAudit, requireOrgAudit |
read and audit |
authorizeConfigRead/Write/Apply/Confirm |
device read/write, or a segment grant — see partner scoping |
requireGrantAuthority |
see who may grant |
requireInviteAuthority |
platform admin, or any grant that could give an invitee something |
visibleOrgs |
narrows a list to the orgs the caller can see, instead of refusing |
Two AST tests in internal/gwconnect make a missing gate a test failure:
portal_rpc_gate_coverage_test.goparses the package source, finds every method on a handler mounted behindportalAuth, and fails unless it calls one of the functions ingateFuncsor is listed inungatedByDesignwith a written reason. A new RPC is covered the moment it exists; a new gate helper must be added togateFuncs.portal_state_scope_coverage_test.gofails unless every RPC that reads a stored state report also narrows it through a scoping helper (gwconfig.FilterStateToScopeand friends), or is listed with a reason.
ungatedByDesign currently holds: Me, Enroll, Transfer,
GetUserPreferences, SetUserPreference, ListRoles, Resolve, Search
and PublishAgentRelease.
authz must be configured
An unconfigured authz client answers every IsAllowed with true. nexus
refuses to start without NEXUS_AUTHZ_ADDR unless NEXUS_DEV_AUTH=true.
On startup nexus calls AuthzService.SeedRoles with authz.RoleSeeds(). The
role table is in Authz → Model.
Organizations and users¶
- No organization is created on first login.
Meprovisions nothing. A user with no grant sees the portal's no-organization screen until a platform admin creates an org and invites them, or an owner grants them a role. CreateOrganizationis platform-admin only. The org is recorded withowner_id = 'system'; the creating admin gets no grant on it.Inviteis gated byrequireInviteAuthority.ORG_MEMBER,PARTNERand users with no grant cannot create accounts.Searchis exact-match on one whole email address. Anything else returns no users, so the RPC cannot list the shared Zitadel directory.
HTTP endpoints¶
| Path | Notes |
|---|---|
GET /healthz |
Public. {"status":"ok","version":"<build version>"}. The portal polls it and shows the version in About. |
CORS (internal/httpmw/cors.go) wraps the whole mux. Allowed origins:
https://portal.digilan-edge.com, https://dev.portal.digilan-edge.com,
https://stg.portal.digilan-edge.com, http://localhost:5173.
Configuration¶
Read in internal/config/config.go.
| Variable | Purpose |
|---|---|
PORT / NEXUS_LISTEN_ADDR |
Listen address |
NEXUS_DATABASE_DSN |
Postgres DSN (shared with authz) |
NEXUS_LOG_LEVEL, NEXUS_LOG_FORMAT |
Logging |
NEXUS_ZITADEL_ISSUER, NEXUS_ZITADEL_PROJECT_ID |
Portal token validation |
NEXUS_ZITADEL_URL, NEXUS_ZITADEL_SERVICE_TOKEN |
Management API (user lookup, invite, search) |
NEXUS_AUTHZ_ADDR, NEXUS_AUTHZ_API_KEY |
authz service |
NEXUS_INTERNAL_API_KEY |
Shared secret with the realtime Worker |
NEXUS_REALTIME_URL |
Realtime Worker base URL for pushes |
NEXUS_MRWIFI_URL, NEXUS_MRWIFI_API_KEY |
Monsieur Wifi claim flow |
NEXUS_OTA_S3_ENDPOINT, _REGION, _BUCKET, _ACCESS_KEY, _SECRET_KEY |
Private OTA bucket; nexus presigns downloads |
NEXUS_RELEASE_PUBLISHER_KEY |
X-Release-Key for ReleasePublisherService |
NEXUS_ACME_DIRECTORY_URL, NEXUS_ACME_ACCOUNT_KEY |
ACME for gateway and relay certificates |
NEXUS_RELAY_ZONE_SUFFIX |
Zone gateway and relay names are published under |
NEXUS_RELAY_TUNNEL_KEY |
Key for per-peer rung-3 tunnel tokens |
NEXUS_CLOUDFLARE_ZONE_ID, NEXUS_CLOUDFLARE_API_TOKEN |
DNS records |
NEXUS_REDIS_ADDR |
Optional |
NEXUS_DEV_AUTH |
true disables portal authentication (local only) |
Housekeeping¶
nexus runs scale-to-zero, so in-process tickers are not a schedule. One sweep runs at startup and then hourly:
| Sweep | Rule |
|---|---|
gateway_logs |
pf logs older than 48 h, everything else older than 7 days |
gateway_metrics |
older than 24 h |
viewer_tickets, shell_tickets |
expired rows |
device_command_replies |
expired, uncollected replies (also swept on each write) |
| relays | abandoned relays and their tunnel names — see Relay fleet |
Online status is derived from devices.last_seen_at at read time; nothing
sweeps it. Work that must happen on time is driven by requests or heartbeats
instead (PortalLinkService.AdvanceLink, relay failover on relay heartbeats).