qurl-go

module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 4, 2026 License: MIT

README

qurl-go

Use the LayerV qURL Platform from Go: protect a private URL once, then mint short-lived access links for it.

LayerV hosts qURL. Your Go app keeps a tiny surface area: protect the URL, create a portal link for the returned resource, and share the link.

Portal recipients do not need LayerV credentials, API keys, keypairs, or SDK state. They open the qURL link. Credentials are only for software that protects URLs or creates portals.

Go Reference CI

Why qURL

Agents and services increasingly need to reach private MCP servers, APIs, and internal tools. Every standing public endpoint becomes inventory for scanners, fingerprinting, credential attacks, and AI-assisted probing before a legitimate user or agent ever arrives.

qURL is an invisibility primitive for authenticated access. A portal is cryptographic, just-in-time permission for one actor to reach one private resource without turning that resource into public inventory.

Install

go get github.com/layervai/qurl-go/qurl@latest

Requires Go 1.26+.

Quickstart

package main

import (
	"context"
	"time"

	"github.com/layervai/qurl-go/qurl"
)

func issuePortal(ctx context.Context) (string, error) {
	client, err := qurl.OpenClient()
	if err != nil {
		return "", err
	}

	resource, err := client.ProtectURL(ctx, "https://internal.example.com/dashboard")
	if err != nil {
		return "", err
	}

	portal, err := resource.CreatePortal(ctx, qurl.ValidFor(5*time.Minute))
	if err != nil {
		return "", err
	}
	return portal.Link, nil
}

If qURL Connector already protects the service, use its immutable connector slug:

resource, err := client.GetConnectorResourceBySlug(ctx, "prod-dashboard")
if err != nil {
	return err
}
portal, err := resource.CreatePortal(ctx, qurl.ValidFor(5*time.Minute))

If you persist the resource id, future calls can reconstruct the handle without another lookup:

resource := client.ResourceByID(resourceID)
portal, err := resource.CreatePortal(ctx, qurl.ValidFor(time.Hour))

Connect to LayerV

Only software that protects URLs or creates portals needs LayerV credentials. A user or agent that only receives and opens a qURL link does not set up anything.

Application issuers normally run the LayerV setup flow once, then use:

client, err := qurl.OpenClient()

For protected external credential storage, implement qurl.CredentialProvider and pass it to qurl.NewClient.

Connect a service or agent

Your service registers once, then keeps serving. Registration happens over an authenticated UDP channel — no inbound ports, no public endpoint, nothing for a scanner to find.

store, err := qurl.OpenFileAgentState("/var/lib/layerv/qurl/agent-state.json")
if err != nil {
	return err
}
defer store.Close()

client, binding, err := qurl.ConnectAgentRuntime(ctx, store,
	qurl.WithAgentRuntimeEnrollmentCredential(enrollmentCredential),
	qurl.WithAgentRuntimeMetadata(hostname, version),
	qurl.WithAgentRuntimeOTPProvider(readOneTimeCode),
)
if err != nil {
	return err
}
defer binding.Destroy()

That is the whole enrollment. You supply the credential you were issued, a file to keep state in, and a way to read the one-time code; the SDK already knows how to reach LayerV.

readOneTimeCode is a function you write. That call blocks while LayerV emails a code to the address on your credential and waits for your callback to return it — The one-time code below shows one.

Then it stays connected on its own. Run that on every start, under a supervisor, and stop thinking about the lifecycle:

  • Restarts are safe — it enrolls only when nothing is registered yet.
  • Crashes and dropped replies resume the same registration, for up to 90 days.
  • Leases renew themselves, at startup and mid-run.
  • Relocations are followed by that same renewal.

A process restarting after a weekend outage runs the same code as one restarting after thirty seconds. If your service should not hold an enrollment credential at runtime, drop the credential option: the same call then renews and serves an existing registration but can never create one. See Connect a service or agent.

Keep the state file and keep the metadata stable. The file is what makes a resume possible, and the hostname and version you pass become part of the saved registration.

The one-time code

Enrollment sends a one-time code to the address on your credential, and your callback returns it. That is the default path.

It is not a "human" path. Agents increasingly have their own mailboxes, and a service account or a shared operations alias works just as well. All that matters is that something can read the address the code went to:

func readOneTimeCode(ctx context.Context, challenge qurl.AgentOTPChallenge) (string, error) {
	return pollMailboxForCode(ctx) // your inbox, your operator, your call
}

Return exactly 8 decimal digits, and honor the ctx — it is already bounded by the assignment ticket. The challenge is for logging and correlation only: it carries no credential and nothing replayable. LayerV sends at most one code per attempt, and the SDK never retries behind your back or writes the code to disk.

If nothing can read a mailbox — a sealed appliance, an air-gapped build agent — enroll with a pre-issued credential and say so explicitly:

client, binding, err := qurl.ConnectAgentRuntime(ctx, store,
	qurl.WithAgentRuntimeEnrollmentCredential(credential),
	qurl.WithAgentRuntimeMetadata(hostname, version),
	qurl.WithAgentRuntimeHeadlessEnrollment(),
)

That is the escape hatch, not the shortcut. Use it only when no address in reach can receive the code — and note it cannot be combined with an OTP provider, since one option says no code can be read and the other says how to read one.

Not sure which you have? The token will not tell you — credentials carry no kind you can parse, and LayerV reports it on the first authenticated call. Go by how you got it: issued against an address means the code path, pre-issued for a machine means headless. Or just run the default and read the error, which names the kind LayerV actually reported:

qurl: registration key kind "bootstrap" is disallowed; accepted kinds: account

A wrong first guess costs you nothing — nothing is registered, and the retry reuses the same agent identity rather than enrolling a second one. Connect a service or agent has the full decision table.

Credentials

Pass the credential LayerV issued you. Credentials must be LayerV-minted tokens of at least 32 characters. Passwords and hand-picked strings are rejected before anything is saved or sent.

Taking manual control

Renewal and relocation are automatic, and following a relocation means going where LayerV said to go in an authenticated reply — never a guessed or config-supplied address. To renew at a moment you choose, or to opt out of the automatic behavior entirely, see Taking manual control.

Most recipients open qURL links directly and do not use this SDK. Programmatic recipients call:

portal, err := qurl.EnterPortal(ctx, link)

That is the whole integration. The SDK ships the issuer keys it trusts and the cells it can reach, so there is no trust configuration to assemble first.

EnterPortal checks that the link was really issued by LayerV, then opens it over a direct UDP connection. Browsers cannot send UDP, so links opened in a browser go through an HTTPS path instead; the SDK picks whichever works and you do not configure either one.

To point the SDK at a different deployment (self-hosted, or a sandbox), set QURL_DEPLOYMENT to a deployment JSON file; to take full programmatic control, install a Provider with SetDefaultProvider. A build that ships no issuer keys fails closed rather than opening a link it cannot verify.

Guides

Error handling

Match errors by type or sentinel, not message text:

Error Meaning
qurl.ErrInvalidClientConfig Resource-client credentials or options are malformed
qurl.ErrInvalidRegisterConfig Native lifecycle inputs are malformed
qurl.ErrAssignmentRecoveryRequired Registration ran out of retries; start recovery
qurl.ErrEndpointNoReply The host resolved and every datagram was sent, but nothing answered: the server is down or the network path drops UDP to it silently. *qurl.EndpointNoReplyError carries the destination and attempt count
qurl.ErrAgentBindingPersistence A state save failed or its acknowledgement was lost; reload before retry because the refreshed assignment may already be durable
qurl.ErrCompletionRecoveryRequired Resume the exact persisted completion candidate
qurl.ErrAgentRecoveryExpired This registration is older than 90 days and can no longer be resumed; enroll again
qurl.ErrAgentRecoveryMigrationRequired Saved state predates the current format; keep the file and enroll again
*qurl.NativeCredentialRecoveryRequiredError Completed native credential state is absent or malformed; explicit native recovery or reprovisioning is required
*qurl.AgentAssignmentChangedError A refresh pinned with WithAgentRuntimePinnedAssignment found that LayerV moved your service; drop the option to follow the move
*qurl.APIError LayerV returned a non-2xx steady-state resource response
*qurl.ServerDenyError LayerV refused the request

Security notes

  • Treat LayerV credentials, agent state, and qURL links like credentials. Do not log them.
  • Never guess or construct LayerV addresses yourself; use what the SDK ships.
  • Keep saved registration state across an unclear reply, and keep the exact pending completion candidate across ambiguous completion delivery.
  • Wipe the private-key bytes taken from AgentRuntimeBinding once you are done with them.
  • Keep issuer credentials in protected state, KMS, a secret manager, or another protected store.
  • Links opened in a browser and services connected over UDP are separate trust paths.

Changes

Unreleased
  • Breaking, and it requires action: you must upgrade to keep connecting. The NHP wire protocol moves to 1.1, which authenticates the packet header inside the AEAD. 1.0 and 1.1 do not interoperate in either direction and there is no compatibility mode, so once LayerV's servers move to 1.1, an agent built against v0.2.0 or earlier fails every request with an explicit version error. Rebuild against this release and redeploy. Nothing about your code changes — no API moved — but a binary that is not rebuilt will not reconnect on its own.

    This closes a real defect rather than tidying the wire: under 1.0 the header's flag word was covered only by an unkeyed digest, so anyone who knew an agent's static public key and sat on the network path could alter how a reply was decoded and hand the caller bytes the server never sent. Authenticating the header is the fix, and it cannot be done compatibly.

  • Breaking: enrollment now defaults to the emailed one-time code, for any runtime that can read a mailbox rather than humans specifically. A runtime with no address in reach opts out with the new WithAgentRuntimeHeadlessEnrollment; callers that previously enrolled with a pre-issued credential and no options must add it. Policy and provider must now agree in both directions: accepting the OTP kind without WithAgentRuntimeOTPProvider fails with ErrAgentOTPRequired before any network I/O, and installing a provider while excluding that kind is rejected as contradictory with ErrInvalidRegisterConfig.

  • Added the native UDP connection lifecycle for services and agents: enrollment, emailed one-time codes, direct connections, strict conformance, and crash-safe activation/completion.

  • Leases and relocation are now handled for you. Warm open renews an expired lease, a held binding renews itself as expiry approaches, re-running the connect call is safe on every start, and an authority-directed move is followed rather than surfaced. Placement is still only ever taken from an authenticated Hub result whose assignment generation advances. WithAgentRuntimeReassignmentAdoption is now a no-op and deprecated; opt out with WithAgentRuntimeOfflineOpen or WithAgentRuntimePinnedAssignment.

  • Added ConnectAgentRuntime, the single call a service makes on every start. It enrolls when nothing is registered yet (supply the credential with WithAgentRuntimeEnrollmentCredential), resumes an interrupted enrollment, and otherwise returns the existing registration. RegisterAgentRuntime and OpenRegisteredAgentRuntime are deprecated in its favor and unchanged.

  • AgentRuntimeBinding's exported assignment fields are now written once, at construction, and never mutated by a renewal. They are safe to read from any goroutine; binding.Assignment() reports live placement.

  • Breaking: OpenRegisteredAgentRuntime now takes the closed AgentRuntimeOpenOption set instead of ClientOption, matching the other lifecycle entry points. WithAgentClientBaseURL and WithAgentClientHTTPClient are unchanged there; generic WithBaseURL, WithHTTPClient, and WithIssuerStatePath are now rejected at compile time rather than at run time. The resource-only OpenRegisteredAgent still takes ClientOption.

  • An interrupted registration now finishes at the placement its candidate is bound to before placement is reconciled, so a resume recovers a registration that was already recorded instead of losing it.

  • Bounded native registration recovery to 90 days after the first authenticated assignment-ticket expiry, with a per-datagram deadline fence, immutable replacement anchor, and fail-closed pre-v6 pending-state migration.

  • Registration retries are budgeted per step, so a single call can span several of them before giving up. Use an outer context deadline when a smaller aggregate wall-clock ceiling is required.

  • Removed the superseded public HTTP agent assignment/registration lifecycle. Everyday resource calls still use HTTPS, and browser behavior is unchanged.

  • Added sealed full-AgentState storage and AWS-backed AgentState stores.

License

MIT © LayerV AI

Directories

Path Synopsis
awsstore module
internal
agentstatecontract
Package agentstatecontract holds wire-neutral AgentState constants shared by the root SDK and in-repository storage modules.
Package agentstatecontract holds wire-neutral AgentState constants shared by the root SDK and in-repository storage modules.
cryptoutil
Package cryptoutil owns the module's small cryptographic-randomness and secret-buffer primitives.
Package cryptoutil owns the module's small cryptographic-randomness and secret-buffer primitives.
nhpcontract
Package nhpcontract holds wire limits shared by the public qurl runtime and the internal NHP codec.
Package nhpcontract holds wire limits shared by the public qurl runtime and the internal NHP codec.
qv2
Package qv2 is the internal cryptographic core of the qURL Go SDK: it verifies incoming qURL links and mints new ones.
Package qv2 is the internal cryptographic core of the qURL Go SDK: it verifies incoming qURL links and mints new ones.
testkeys
Package testkeys generates throwaway public keys for the SDK's runnable examples and tests, so the example files don't each re-implement the same key-gen helpers.
Package testkeys generates throwaway public keys for the SDK's runnable examples and tests, so the example files don't each re-implement the same key-gen helpers.
udpfence
Package udpfence carries an internal, fail-closed authorization check from a lifecycle transaction to the native UDP write boundary.
Package udpfence carries an internal, fail-closed authorization check from a lifecycle transaction to the native UDP write boundary.
x25519key
Package x25519key validates X25519 public identities at control-plane and transport trust boundaries.
Package x25519key validates X25519 public identities at control-plane and transport trust boundaries.
Package qurl is the Go SDK for the LayerV qURL Platform.
Package qurl is the Go SDK for the LayerV qURL Platform.
Package relayknock is the low-level NHP relay-knock layer of the qURL Go SDK.
Package relayknock is the low-level NHP relay-knock layer of the qURL Go SDK.
internal/nhpwire
Package nhpwire is the internal NHP Noise wire codec shared by the public relayknock package (client/initiator API) and the relayknocktest test-support package (server/responder helpers).
Package nhpwire is the internal NHP Noise wire codec shared by the public relayknock package (client/initiator API) and the relayknocktest test-support package (server/responder helpers).
nativeudp
Package nativeudp is the native NHP-over-UDP transport of the qURL Go SDK.
Package nativeudp is the native NHP-over-UDP transport of the qURL Go SDK.
relayknocktest
Package relayknocktest provides the server/responder-role NHP wire helpers a test double (or cross-language conformance tooling) needs to stand in for an NHP relay+server: build a server-originated reply, and open an initiator packet an agent posted.
Package relayknocktest provides the server/responder-role NHP wire helpers a test double (or cross-language conformance tooling) needs to stand in for an NHP relay+server: build a server-originated reply, and open an initiator packet an agent posted.

Jump to

Keyboard shortcuts

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