qurl-connector

module
v0.14.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: Apache-2.0

README

qURL Connector

qURL Connector is the local tunnel runtime for qURL. It admits each shared resource with the Network-invisible Handshake Protocol (NHP), then carries approved traffic over a resource-bound FRP session.

local service -> qURL Connector -> qURL -> recipient

Users install only the qurl CLI. It embeds this module. On Linux, macOS, and Windows, the CLI uses the native per-user job manager when a local share is first published or started. The daemon resumes desired-on shares after login and recovers automatically across sleep, wake, network changes, assignment refreshes, and session rotation. Linux uses a systemd user service; macOS uses launchd; Windows uses Task Scheduler. Each manager restarts failure exits, and the next login or foreground qurl command repairs a clean daemon exit. Linux fails clearly when the host has no real systemd user manager instead of pretending that it installed a persistent background process. The user manager must support Type=exec, append log output, and RestrictSUIDSGID. qURL reports these requirements if the installed systemd cannot load the managed service definition.

On Windows, native state is under %LOCALAPPDATA%\qurl-connector. The first run creates the directory and its security-sensitive files with protected ACLs for the current user, SYSTEM, and Administrators. This is a greenfield contract: the Connector does not adopt a state directory or file with inherited or foreign ACLs. If ACL validation fails, stop the Connector, move that state directory aside, and start again so it can create protected state and enroll a new identity. Connector and pinned qurl-go writers use the same protected-file contract for all production identity and session state. A custom Windows state path must use a local filesystem under a user-owned namespace where Windows can flush directory updates. Network paths and system-owned parents are not supported state locations.

Agent state key storage

A new state directory is sealed to the machine's TPM 2.0 when the process can use one: the Linux resource manager /dev/tpmrm0 (usually tss group membership) or TPM Base Services on Windows. macOS, and machines without a usable TPM, keep the owner-only plaintext envelope. A TPM that is present but not responding (busy, starting, timing out, or self-testing) fails the operation instead of falling back, because the choice is permanent for the directory. The TPM holds a random key that never leaves it; the state's data key is encrypted under that key. The choice is fixed when the directory is created:

  • LAYERV_KEY_PROVIDER=file keeps a new directory plaintext; tpm requires the TPM. The cloud and local-key providers are unchanged. An empty or whitespace value counts as unset, so a new directory then takes the TPM when one is usable; set file explicitly to pin plaintext.
  • A TPM-sealed directory reopens with no environment, so the managed daemon serves it like a plaintext one.
  • Existing directories keep their envelope. There is no migration in either direction.
  • A TPM-sealed directory cannot be restored from backup onto other hardware, carried by a VM clone or image, or read after the TPM is cleared. The error says the storage root key changed. Recovery is to move the state directory aside and enroll again. Enroll after cloning, or set LAYERV_KEY_PROVIDER=file when building images.
  • Where the key is held under the owner hierarchy (the default outside Windows), a TPM-sealed directory also stops opening if another component later takes ownership of the TPM, most commonly booting Windows on a dual-boot machine. The error names this cause; recovery is the same.
  • Each state load, and each save (which seals and then verifies), re-derives the TPM storage key and runs one sealing command. That is fast on firmware TPMs but can take a few seconds on discrete TPM chips. Saves and loads happen on lifecycle events (enrollment, refresh, session changes), not per request.

Sealing binds the state to the machine; it does not protect it from other users of that machine. The sealed key has no password or boot-state policy, so anyone who can read the state file and reach the TPM on the same machine can unseal it. The owner-only file permissions remain the access boundary. What the TPM removes is the ability to copy the file and read it elsewhere. Sealing also does not authenticate the envelope: someone who can write the state directory can replace it, exactly as with plaintext state.

cmd/frpc is retained for development and diagnostics. It is not a supported customer distribution, Homebrew formula, release binary, or container image.

The developer command names two public LayerV endpoints in source: https://api.layerv.ai/v1 for the public API and hub.nhp.layerv.ai:443 for the public NHP Hub. Hostnames are not credentials. No Hub public key is embedded; the command fails closed unless an explicit trusted key is supplied.

Security model

  • Tunnel TLS verifies the admitted server hostname with system CA certificates by default. A custom CA file remains available for private deployments. Certificate and key renewal do not require client updates.
  • NHP admission is resource-specific. A token or session issued for one resource cannot register a different resource.
  • The managed daemon does not retain an account bearer. Account-authorized lifecycle changes remain in the foreground qurl command.
  • New agent state is sealed to the local TPM 2.0 when one is usable; see Agent state key storage.
  • Connector state is owner-only and fails closed on unsafe permissions, symlinks, contradictory identity, or malformed persisted data.
  • Session renewal is make-before-break: a replacement must reach FRP's running state before the old route drains.
  • Assignment recovery is automatic, bounded, and persisted; normal network failures do not require an approval flag or reprovisioning.

Please report vulnerabilities through the repository's private security-advisory form, not a public issue.

Build from source

Requirements:

  • Go 1.26.6 or newer
  • Git

All Go dependencies, including the reviewed LayerV FRP fork, are public and available through the public Go module proxy.

make verify-deps
make frpc
./bin/qurl-connector version

make verify-deps checks the pinned FRP release against its public-proxy checksums, source commit, and live release tag. make frpc builds the developer-only command locally.

Development

make test       # hermetic package and command tests
make test-race  # race detector
make lint       # golangci-lint
make vet        # go vet
make frpc       # build the developer-only command

The reusable production runtime lives under pkg/share. It owns native assignment recovery, resource-bound NHP admission, FRP session readiness, make-before-break renewal, and per-resource failure isolation. Command code must use that implementation rather than introducing another knock/session supervisor. SessionGroupRunner serves one or many routes on one admission and one FRP session (up to MaxGroupRoutes), with live route add/remove/restart and per-route failure reporting.

See CONTRIBUTING.md for change and validation expectations.

Scaling proof

TestHermeticSessionGroupServes1000Routes in pkg/share proves that one Connector process serves many routes on one NHP admission and one FRP control session, and measures what that costs. It runs in-process against the real FRP client and the real FRP server from the pinned fork; the knock is a scripted admitter and the tunnel-auth plugin is a hermetic HTTP plugin that answers NewProxy the way the platform does. Everything between them is production code on loopback.

The normal suite runs it at 50 routes. The opt-in run is:

make proof-1000   # 1000 routes with the race detector off, then 200 with it on

QURL_PROOF_ROUTES sets the route count and QURL_PROOF_REPORT writes the measurements as JSON (make proof-1000 leaves them in bin/). One run asserts, in order: every route registers on the single admission and answers through the vhost; ten routes leave and ten join with no second knock and no sibling re-registration; one route restarts under a fresh proxy name alone; the admission rotates and the replacement carries every route before the old session retires, with a background request loop running across the overlap; and one route the server rejects as resource_not_found is withdrawn while every sibling keeps answering.

Measured on darwin/arm64 (Apple M5 Max, 18 cores, Go 1.27.1):

1000 routes, race off 200 routes, race on
Run() to every route serving 632 ms 132 ms
vhost sweep, every route once, 32-way 79 ms (cold p50 2.2 ms, p99 5.9 ms) 38 ms (cold p50 5.2 ms, p99 9.9 ms)
steady-state latency, 2000 requests p50 0.31 ms, p99 0.46 ms p50 0.67 ms, p99 1.37 ms
rotation: Admit to every route re-registered 625 ms (promoted at 633 ms) 129 ms (promoted at 133 ms)
drain: promotion to old proxies withdrawn 22 ms 59 ms
overlap requests / failures 96,203 / 0 8,810 / 0 (drain only)
goroutines per route (the FRP client's status worker) 1.02 1.10
open FDs at registration (baseline 15) 19 19
open FDs after every route was hit once 2,085 (2.07 per route, idle work connections) 485
goroutines after that sweep 7,172 (FRP client 2,002; 5,155 net/http and stream goroutines, mostly the in-process server's) 1,778
Go sys at registration / after that sweep 53 MiB / 203 MiB 45 MiB / 69 MiB
peak RSS, whole test process 393 MiB 875 MiB (race detector)
FRP server online HTTP proxies 1000 200
NewProxy admitted / rejected 2011 / 1 411 / 1
goroutines / FDs after stop (baseline 42 / 15) 45 / 17 45 / 17

Registration and rotation are linear and fast: about 80 µs per route on the server's serial NewProxy path, and about 0.6 ms per route end to end because proxies are handed to FRP through a 16-wide registration window that the session refills once per status poll: 10 ms in the proof, 100 ms in production, where the refill alone therefore bounds a 1000-route registration at about 6 s and a 2000-route one at about 13 s, inside the 30 s lead floor. The production rotation lead's a-priori estimate of 50 ms per route is still well over a hundred times what this machine needs. A platform behind the server is slower: its per-NewProxy authorization and registration round trips take hundreds of milliseconds, and because the server reads a session's messages serially, N proxies handed to FRP at once form an N-deep queue. Two mechanisms keep that queue from turning into churn. Proxies register through a window (16 in flight per session), so no proxy waits long enough for the pinned client's 20 s NewProxy re-send to fire and register twice; and the rotation lead follows the registration rate the runner measured on the last full cycle (with a 1.5× margin), so a replacement is started early enough to register every route before the old admission expires. The window is proven hermetically at platform speed by TestHermeticSessionGroupRegistersEveryRouteOnceOnSlowServer in the default lanes and by TestHermeticSessionGroupRotationRegistersEveryRouteOnce across a rotation under make proof-1000; the measured lead by the runner's tests. The window cannot bound what the pinned client re-sends on its own after a control loss, when its new control registers the whole pushed set at Login; that is a fork-side change. The steady-state cost of a route is one goroutine and no file descriptor. Memory and descriptors scale with idle work connections, not routes: the FRP server keeps up to five idle work connections per Host for 60 s, so once every route has been hit the process holds about two descriptors per route (client and server ends, in-process), roughly five goroutines per connection, and roughly 75 KiB of stream buffers per connection. Goroutines and descriptors return to baseline after stop; Go's sys is not handed back to the OS promptly, as usual.

Caveats: hermetic knock, hermetic tunnel-auth plugin, one machine, loopback, in-process server (goroutine and descriptor counts include the server's share; the breakdown is printed). Peak RSS covers the whole test process, and the race-on figure is dominated by the detector's shadow memory; Go sys is the runtime's high-water mark and includes the proof's own goroutine-dump buffers. Peak RSS and descriptor counts are not measured on Windows. Under the race detector the overlap loop starts only after the old proxies have left the server (see the gap below), so the race-on overlap column covers the drain, not the whole rotation. Server-side budgets are proven separately by the platform.

One gap was found, and it is not in this repository. The pinned FRP client admits a work connection only while its proxy is in the running phase, and that phase lags the server on both edges of a proxy's life: the server adds a new proxy to its load-balancer group inside RegisterProxy and only then sends NewProxyResp, and Wrapper.Stop marks a proxy closed in the same instant it sends CloseProxy. A request the server dispatches to the proxy inside either window is refused by the client and answered 404 by the server's reverse proxy. Under a deliberately hostile overlap load (16 workers, no pause, about 36,000 requests per second across 20 routes) it shows as one to three such 404s per rotation in roughly half the runs, every one at its own route's re-registration instant; at the proof's background load it appears about once per few hundred thousand requests (0 in 96,203 in the published 1000-route run, 1 in 95,018 in an earlier one, 1 in about 500,000 across the 50-route runs). The proof attributes every overlap failure to that exact instant, bounds how many it will excuse, and fails on any failure outside it. The same site is also a data race: Wrapper.InWorkConn reads the proxy phase after releasing the wrapper's lock, so the race detector reports the dispatch whenever it happens under -race, which is why the race lane starts its overlap loop after withdrawal. The fix belongs in the FRP fork's client: read the phase under the lock, accept work connections while a proxy awaits its NewProxyResp, and keep accepting through the drain grace after CloseProxy is sent.

Developer command configuration

The standalone binary reads qurl-proxy.yaml from the current directory, the binary's etc directory, or the user's qURL config directory. A minimal route looks like:

server:
  protocol: tcp

routes:
  - id: my-webapp
    type: http
    local_port: 8080

Resource, routing, and knock identities are issued by the qURL platform. Do not derive or substitute one identity for another. Standard placement comes from the authenticated NHP response; custom Hub and endpoint overrides are intended only for deployments that own the corresponding trust configuration.

Native session operations require QURL_CONNECTOR_NATIVE_OWNER_ID from the authenticated account context. The command never derives the owner from a CRID, route, API resource, or NHP packet. AWS accounts, regions, and storage names are private NHP server configuration and are not Connector settings or build data.

The developer command stays in the foreground:

qurl-connector run

Supply chain

  • Releases are immutable Go module source tags; this repository publishes no customer binary or container artifact.
  • The module pins Go dependencies and the FRP fork's exact reviewed source revision and checksum.
  • Dependabot, dependency review, govulncheck, CodeQL, and secret scanning gate changes.

License

Licensed under the Apache License 2.0.

Directories

Path Synopsis
cmd
frpc command
Package contracts embeds the machine-readable cross-repo wire-contract snapshots under contracts/ so in-repo tests bind production code to the exact bytes sibling repos vendor.
Package contracts embeds the machine-readable cross-repo wire-contract snapshots under contracts/ so in-repo tests bind production code to the exact bytes sibling repos vendor.
internal
pinnedfs
Package pinnedfs provides directory-handle-backed filesystem operations for Connector state and configuration transactions.
Package pinnedfs provides directory-handle-backed filesystem operations for Connector state and configuration transactions.
pkg
agentstate
Package agentstate adapts qurl-go's complete native-agent state envelope to the Connector's host-volume and cloud-key-provider conventions.
Package agentstate adapts qurl-go's complete native-agent state envelope to the Connector's host-volume and cloud-key-provider conventions.
audit
Package audit emits per-decision audit entries from the qURL connector's control-plane paths (native registration, knock, login, proxy, teardown).
Package audit emits per-decision audit entries from the qURL connector's control-plane paths (native registration, knock, login, proxy, teardown).
config
Package config provides YAML-based configuration for the qURL Connector.
Package config provides YAML-based configuration for the qURL Connector.
hubpin
Package hubpin validates the X25519 public key that binds a configured Hub endpoint to its expected server identity.
Package hubpin validates the X25519 public key that binds a configured Hub endpoint to its expected server identity.
internal/atomicfile
Package atomicfile provides a crash-safe file write primitive shared by the agent-state and bootstrap layers.
Package atomicfile provides a crash-safe file write primitive shared by the agent-state and bootstrap layers.
strictproof
Package strictproof validates the Go toolchain and module graph used for a release build.
Package strictproof validates the Go toolchain and module graph used for a release build.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL