Skip to content

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.go parses the package source, finds every method on a handler mounted behind portalAuth, and fails unless it calls one of the functions in gateFuncs or is listed in ungatedByDesign with a written reason. A new RPC is covered the moment it exists; a new gate helper must be added to gateFuncs.
  • portal_state_scope_coverage_test.go fails unless every RPC that reads a stored state report also narrows it through a scoping helper (gwconfig.FilterStateToScope and 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. Me provisions 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.
  • CreateOrganization is platform-admin only. The org is recorded with owner_id = 'system'; the creating admin gets no grant on it.
  • Invite is gated by requireInviteAuthority. ORG_MEMBER, PARTNER and users with no grant cannot create accounts.
  • Search is 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).