From aac7353902115e63d95adfce2e5339b38dcc01b6 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Mon, 26 Oct 2026 14:42:14 -0700 Subject: [PATCH 1/3] feat: bound guest demos by cumulative ready authority --- api/observability/v1/vocabulary.json | 1 - cmd/tnl/output.go | 2 +- cmd/tnl/output_test.go | 2 +- contributing.md | 5 + docs/maintainers/connector/platform-access.md | 2 +- docs/maintainers/control/guest-demos.md | 24 +++ docs/maintainers/control/workload-sessions.md | 2 +- .../publisher/publisher-provenance.md | 2 +- .../control/api/public/guest_authorizer.go | 4 +- .../api/public/guest_authorizer_test.go | 2 +- internal/control/maintenance/guest_cleanup.go | 2 +- internal/control/state/controlstate.go | 2 +- .../state/controlstatedb/guest_trials.sql.go | 163 +++++++++++------- .../control/state/controlstatedb/models.go | 3 +- .../state/controlstatedb/publish_runs.sql.go | 6 +- .../control/state/controlstatedb/querier.go | 5 +- internal/control/state/guest_cleanup.go | 13 +- internal/control/state/guests.go | 29 +++- .../control/state/guests_integration_test.go | 123 +++++++------ .../state/migration_integration_test.go | 1 + .../state/migrations/00001_baseline.sql | 22 +-- internal/control/state/public_urls.go | 4 +- internal/control/state/publish_run_closure.go | 16 +- .../control/state/publish_run_heartbeat.go | 4 + .../control/state/publish_run_readiness.go | 4 + internal/control/state/publish_runs.go | 14 +- .../control/state/publisher_enrollment.go | 2 +- .../publisher_identities_integration_test.go | 6 +- .../state/publisher_identity_access.go | 2 +- .../control/state/queries/guest_trials.sql | 75 ++++---- .../control/state/queries/publish_runs.sql | 6 +- internal/control/state/traffic.go | 5 + internal/control/state/traffic_test.go | 14 ++ internal/control/state/usage.go | 5 - internal/control/state/visitor_policy.go | 8 +- internal/control/state/visitor_tokens.go | 2 +- .../daemon/guest_demo_integration_test.go | 109 +++++++++++- internal/observability/metrics.go | 6 +- internal/observability/metrics_test.go | 2 +- internal/observability/values.go | 3 +- internal/policy/trafficlimits/limits.go | 7 + readme.md | 2 +- 42 files changed, 468 insertions(+), 243 deletions(-) create mode 100644 docs/maintainers/control/guest-demos.md diff --git a/api/observability/v1/vocabulary.json b/api/observability/v1/vocabulary.json index 2fed2b3e2..cc8f65984 100644 --- a/api/observability/v1/vocabulary.json +++ b/api/observability/v1/vocabulary.json @@ -501,7 +501,6 @@ "ready", "expired", "ready_limit", - "transfer_limit", "cleanup", "opened", "closed", diff --git a/cmd/tnl/output.go b/cmd/tnl/output.go index d0b840a59..a105ccc29 100644 --- a/cmd/tnl/output.go +++ b/cmd/tnl/output.go @@ -222,7 +222,7 @@ func (o *publishOutput) ready(url string, publishRunNumber uint64) error { ) } if o.guestDemo { - fields = append(fields, clioutput.Field{Label: "guest trial", Value: "15 min / about 5 MiB across runs"}) + fields = append(fields, clioutput.Field{Label: "guest trial", Value: "20 min ready across runs, including idle time"}) } if o.fallbackPublicURL == publishRunNumber { fields = append(fields, clioutput.Field{Label: "transport", Value: "TLS/TCP fallback"}) diff --git a/cmd/tnl/output_test.go b/cmd/tnl/output_test.go index b785f490c..dc08719a6 100644 --- a/cmd/tnl/output_test.go +++ b/cmd/tnl/output_test.go @@ -175,7 +175,7 @@ func TestGuestDemoReadyExplainsTrialAndSignIn(t *testing.T) { if err := output.ready("https://demo-1.guest-01234567.tnl.dev", 1); err != nil { t.Fatal(err) } - if !strings.Contains(stderr.String(), "15 min / about 5 MiB across runs") || + if !strings.Contains(stderr.String(), "20 min ready across runs, including idle time") || !strings.Contains(stderr.String(), "tnl auth login to publish your app") || !strings.Contains(stderr.String(), "demo-1.guest-01234567") { t.Fatalf("guest ready output = %q", stderr.String()) diff --git a/contributing.md b/contributing.md index 7a9361cec..e8b49efc9 100644 --- a/contributing.md +++ b/contributing.md @@ -376,6 +376,11 @@ baseline requires a fresh database and a coordinated stop/start of all serving roles. Control checks both the exact migration version and baseline identity before serving. +The coordinated peer-path rewrite keeps schema version 1 and uses baseline +`tnl-control-fresh-v1-peer-paths`. Stop every serving role, create and migrate a +fresh PostgreSQL database, then start the matching control, ingress, relay, and +publisher implementation together. The previous version-1 database is rejected. + The single PostgreSQL baseline includes durable publisher certificate-plan checkpoints. Fresh identities have an empty plan inventory; submitted plans remain required after order cleanup. See [publisher groups](docs/maintainers/publisher/publisher-groups.md). diff --git a/docs/maintainers/connector/platform-access.md b/docs/maintainers/connector/platform-access.md index 192a7bea4..55e8d7623 100644 --- a/docs/maintainers/connector/platform-access.md +++ b/docs/maintainers/connector/platform-access.md @@ -175,7 +175,7 @@ remain run-bound and use the configured readiness path. see [compose publisher](../../../deploy/publisher/compose/readme.md). the PostgreSQL baseline identity is -`tnl-control-fresh-v1-visitors`, at version 1. it adds verified +`tnl-control-fresh-v1-peer-paths`, at version 1. it adds verified session bindings and removes the former HTTP-only credential storage. use a fresh database and coordinated baseline rollout. diff --git a/docs/maintainers/control/guest-demos.md b/docs/maintainers/control/guest-demos.md new file mode 100644 index 000000000..2f66e0a6c --- /dev/null +++ b/docs/maintainers/control/guest-demos.md @@ -0,0 +1,24 @@ +# guest ready-time authority + +guest demos have 20 cumulative minutes of ready authority, including idle time, +one active demo, and a 72-hour credential. control starts the interval only after +the publication is ready. it persists `ready_deadline` once from the remaining +budget and credential expiry; renewal cannot move that deadline. + +publisher leases, ingress projections, and visitor-policy leases are capped at +the deadline. before readiness, a policy lease is conservatively capped by the +remaining budget from the observation time. ingress and publisher policy guards +close existing visitor connections locally when their lease expires, including +when control is unavailable. + +`serving_until` records the latest authority issued to the active run. closing +the run settles its interval once through that horizon, capped by the ready +deadline. a restarted demo waits until that authority expires; closing a control +record alone cannot fence an offline process. late recovery charges no time after +the last issued horizon. this can charge the short outstanding lease tail after +a normal stop, rather than refund time that an offline owner might still use. + +the trial's connection and byte-rate limits are server-owned. they do not track +cumulative transfer. guest source digests and the public URL's IP policy remain +independent of usage accounting. see [guest state](../../../internal/control/state/guests.go) +and [settlement queries](../../../internal/control/state/queries/guest_trials.sql). diff --git a/docs/maintainers/control/workload-sessions.md b/docs/maintainers/control/workload-sessions.md index cd3443b43..250c6955f 100644 --- a/docs/maintainers/control/workload-sessions.md +++ b/docs/maintainers/control/workload-sessions.md @@ -355,7 +355,7 @@ verification before dialing. ## verify the fresh PostgreSQL baseline and control's matching identity are -`tnl-control-fresh-v1-visitors`. +`tnl-control-fresh-v1-peer-paths`. its connector constraint allows workload auth material through six hours; current authorization remains bounded by the renewable lease. it requires the same fresh-database, coordinated-baseline rollout as the assembled services work. diff --git a/docs/maintainers/publisher/publisher-provenance.md b/docs/maintainers/publisher/publisher-provenance.md index 62fd6cd89..fddb5ec3d 100644 --- a/docs/maintainers/publisher/publisher-provenance.md +++ b/docs/maintainers/publisher/publisher-provenance.md @@ -96,7 +96,7 @@ original signing identity. A newly provisioned identity needs a new binding; there is no automatic takeover of an observed slot. The fresh PostgreSQL baseline identity is now -`tnl-control-fresh-v1-visitors`. +`tnl-control-fresh-v1-peer-paths`. This is part of the approved fresh-database rewrite, not an in-place migration. ## generated process environment diff --git a/internal/control/api/public/guest_authorizer.go b/internal/control/api/public/guest_authorizer.go index 9b73af62e..108ef05ba 100644 --- a/internal/control/api/public/guest_authorizer.go +++ b/internal/control/api/public/guest_authorizer.go @@ -51,8 +51,8 @@ func (a guestAuthorizer) Authorize(ctx context.Context, request authorization.Re request.Operation == authorization.OperationFeedbackManage || request.Operation == authorization.OperationPreviewVisit || request.Operation != authorization.OperationPublicURLDelete && - (!guest.ExpiresAt.After(time.Now()) || guest.UsedReady >= controlstate.GuestReadyAllowance || guest.UsedBytes >= controlstate.GuestByteAllowance) { - if !guest.ExpiresAt.After(time.Now()) || guest.UsedReady >= controlstate.GuestReadyAllowance || guest.UsedBytes >= controlstate.GuestByteAllowance { + (!guest.ExpiresAt.After(time.Now()) || guest.UsedReady >= controlstate.GuestReadyAllowance || !guest.ReadyDeadline.IsZero() && !guest.ReadyDeadline.After(time.Now())) { + if !guest.ExpiresAt.After(time.Now()) || guest.UsedReady >= controlstate.GuestReadyAllowance || !guest.ReadyDeadline.IsZero() && !guest.ReadyDeadline.After(time.Now()) { return authorization.Decision{}, controlstate.ErrGuestTrialSpent } return authorization.Decision{}, authorization.ErrGuestDemoOnly diff --git a/internal/control/api/public/guest_authorizer_test.go b/internal/control/api/public/guest_authorizer_test.go index a72097d99..623f14545 100644 --- a/internal/control/api/public/guest_authorizer_test.go +++ b/internal/control/api/public/guest_authorizer_test.go @@ -116,7 +116,7 @@ func TestGuestAuthorizationLimitsPublicURLsToOneLocalDemo(t *testing.T) { if _, err := authorizer.Authorize(t.Context(), create); !errors.Is(err, authorization.ErrForbidden) { t.Fatalf("foreign public URL accepted: %v", err) } - guest.UsedBytes = controlstate.GuestByteAllowance + guest.UsedReady = controlstate.GuestReadyAllowance authorizer.store = guestStoreStub{guest: guest, owned: true} if _, err := authorizer.Authorize(t.Context(), create); !errors.Is(err, controlstate.ErrGuestTrialSpent) { t.Fatalf("exhausted guest accepted: %v", err) diff --git a/internal/control/maintenance/guest_cleanup.go b/internal/control/maintenance/guest_cleanup.go index 50ee546f6..93963c751 100644 --- a/internal/control/maintenance/guest_cleanup.go +++ b/internal/control/maintenance/guest_cleanup.go @@ -24,7 +24,7 @@ func RunGuestPrivateStateCleanup(ctx context.Context, store guestPrivateStateSto stats, err := store.RecentGuestTrialStats(ctx, now) if err == nil { metrics.SetGuestTrialStats(stats.Issued, stats.Allocated, stats.Ready, - stats.Expired, stats.ReadyLimit, stats.TransferLimit) + stats.Expired, stats.ReadyLimit) } return count, err }, func(count int, err error) { diff --git a/internal/control/state/controlstate.go b/internal/control/state/controlstate.go index 108a04807..d28e1217e 100644 --- a/internal/control/state/controlstate.go +++ b/internal/control/state/controlstate.go @@ -26,7 +26,7 @@ import ( const ( // the coordinated baseline requires the exact migration and identity. schemaVersion int64 = 1 - baselineID = "tnl-control-fresh-v1-visitors" + baselineID = "tnl-control-fresh-v1-peer-paths" versionTable = "control.goose_db_version" bootstrapRetryDelay = 25 * time.Millisecond ) diff --git a/internal/control/state/controlstatedb/guest_trials.sql.go b/internal/control/state/controlstatedb/guest_trials.sql.go index c882c6123..04664c7b8 100644 --- a/internal/control/state/controlstatedb/guest_trials.sql.go +++ b/internal/control/state/controlstatedb/guest_trials.sql.go @@ -40,9 +40,9 @@ WHERE guest.id = ( WHERE public_url_id = $3 ) AND guest.active_publish_run_id IS NULL + AND (guest.serving_until IS NULL OR guest.serving_until <= $2) AND guest.expires_at > $2 - AND guest.used_ready_ns < 900000000000 - AND guest.used_bytes < 5242880 + AND guest.used_ready_ns < 1200000000000 ` type BeginGuestPublishRunParams struct { @@ -95,26 +95,21 @@ func (q *Queries) CountRecentGuestTrialsByIP(ctx context.Context, arg CountRecen const finishGuestPublishRun = `-- name: FinishGuestPublishRun :execrows UPDATE control.guest_trials -SET used_ready_ns = used_ready_ns + CASE +SET used_ready_ns = LEAST(1200000000000, used_ready_ns + CASE WHEN active_ready_at IS NULL THEN 0 - ELSE GREATEST(0, (EXTRACT(EPOCH FROM ($1::timestamptz - active_ready_at)) * 1000000000)::bigint) - END, + ELSE GREATEST(0, (EXTRACT(EPOCH FROM (LEAST(ready_deadline, + COALESCE(serving_until, $1::timestamptz)) - active_ready_at)) * 1000000000)::bigint) + END), end_reason = COALESCE(end_reason, CASE WHEN $2::text = 'guest_expired' OR expires_at <= $1::timestamptz THEN 'expired' - WHEN $2::text = 'guest_transfer_limit' OR used_bytes >= 5242880 THEN 'transfer_limit' - WHEN $2::text = 'guest_ready_limit' OR used_ready_ns + CASE - WHEN active_ready_at IS NULL THEN 0 - ELSE GREATEST(0, (EXTRACT(EPOCH FROM ($1::timestamptz - active_ready_at)) * 1000000000)::bigint) - END >= 900000000000 THEN 'ready_limit' + WHEN ready_deadline <= COALESCE(serving_until, $1::timestamptz) THEN 'ready_limit' END), ended_at = COALESCE(ended_at, CASE WHEN $2::text = 'guest_expired' OR expires_at <= $1::timestamptz THEN expires_at - WHEN $2::text IN ('guest_transfer_limit', 'guest_ready_limit') OR used_bytes >= 5242880 - OR used_ready_ns + CASE WHEN active_ready_at IS NULL THEN 0 ELSE - GREATEST(0, (EXTRACT(EPOCH FROM ($1::timestamptz - active_ready_at)) * 1000000000)::bigint) - END >= 900000000000 THEN $1::timestamptz + WHEN ready_deadline <= COALESCE(serving_until, $1::timestamptz) THEN ready_deadline END), active_ready_at = NULL, + ready_deadline = NULL, active_publish_run_id = NULL, updated_at = $1 WHERE active_publish_run_id = $3 @@ -256,7 +251,7 @@ func (q *Queries) ForgetOldGuestIssuanceDigests(ctx context.Context, arg ForgetO } const getGuestTrialByCredentialID = `-- name: GetGuestTrialByCredentialID :one -SELECT id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, used_bytes, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, created_at, updated_at FROM control.guest_trials +SELECT id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, ready_deadline, serving_until, created_at, updated_at FROM control.guest_trials WHERE credential_id = $1 ` @@ -277,7 +272,6 @@ func (q *Queries) GetGuestTrialByCredentialID(ctx context.Context, credentialID &i.IssuanceIpDigest, &i.ExpiresAt, &i.UsedReadyNs, - &i.UsedBytes, &i.FirstDemoAllocatedAt, &i.FirstReadyAt, &i.EndReason, @@ -285,6 +279,8 @@ func (q *Queries) GetGuestTrialByCredentialID(ctx context.Context, credentialID &i.LastDemoNumber, &i.ActivePublishRunID, &i.ActiveReadyAt, + &i.ReadyDeadline, + &i.ServingUntil, &i.CreatedAt, &i.UpdatedAt, ) @@ -292,7 +288,7 @@ func (q *Queries) GetGuestTrialByCredentialID(ctx context.Context, credentialID } const getGuestTrialByID = `-- name: GetGuestTrialByID :one -SELECT id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, used_bytes, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, created_at, updated_at FROM control.guest_trials +SELECT id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, ready_deadline, serving_until, created_at, updated_at FROM control.guest_trials WHERE id = $1 ` @@ -313,7 +309,6 @@ func (q *Queries) GetGuestTrialByID(ctx context.Context, id string) (ControlGues &i.IssuanceIpDigest, &i.ExpiresAt, &i.UsedReadyNs, - &i.UsedBytes, &i.FirstDemoAllocatedAt, &i.FirstReadyAt, &i.EndReason, @@ -321,6 +316,8 @@ func (q *Queries) GetGuestTrialByID(ctx context.Context, id string) (ControlGues &i.LastDemoNumber, &i.ActivePublishRunID, &i.ActiveReadyAt, + &i.ReadyDeadline, + &i.ServingUntil, &i.CreatedAt, &i.UpdatedAt, ) @@ -373,10 +370,7 @@ func (q *Queries) GuestOwnsPublicURL(ctx context.Context, arg GuestOwnsPublicURL const guestRunAllowanceSpent = `-- name: GuestRunAllowanceSpent :one SELECT CASE WHEN expires_at <= $1::timestamptz THEN 'guest_expired' - WHEN used_bytes >= 5242880 THEN 'guest_transfer_limit' - WHEN used_ready_ns + CASE WHEN active_ready_at IS NULL THEN 0 - ELSE GREATEST(0, (EXTRACT(EPOCH FROM ($1::timestamptz - active_ready_at)) * 1000000000)::bigint) - END >= 900000000000 THEN 'guest_ready_limit' + WHEN used_ready_ns >= 1200000000000 OR ready_deadline <= $1::timestamptz THEN 'guest_ready_limit' ELSE '' END::text AS reason FROM control.guest_trials @@ -422,7 +416,7 @@ INSERT INTO control.guest_trials ( $7, $8, $9, $10, $11, $12, $13, $13 ) -RETURNING id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, used_bytes, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, created_at, updated_at +RETURNING id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, ready_deadline, serving_until, created_at, updated_at ` type InsertGuestTrialParams struct { @@ -472,7 +466,6 @@ func (q *Queries) InsertGuestTrial(ctx context.Context, arg InsertGuestTrialPara &i.IssuanceIpDigest, &i.ExpiresAt, &i.UsedReadyNs, - &i.UsedBytes, &i.FirstDemoAllocatedAt, &i.FirstReadyAt, &i.EndReason, @@ -480,14 +473,68 @@ func (q *Queries) InsertGuestTrial(ctx context.Context, arg InsertGuestTrialPara &i.LastDemoNumber, &i.ActivePublishRunID, &i.ActiveReadyAt, + &i.ReadyDeadline, + &i.ServingUntil, &i.CreatedAt, &i.UpdatedAt, ) return i, err } +const limitGuestPublishRunExpiry = `-- name: LimitGuestPublishRunExpiry :one +UPDATE control.publish_runs +SET publisher_expires_at = LEAST(publisher_expires_at, $1::timestamptz) +WHERE id = $2 +RETURNING id, public_url_id, team_id, membership_id, acting_identity_id, publisher_identity_id, publisher_grant_id, publish_run_number, idempotency_key, request_digest_ciphertext, request_digest_storage_key_id, share_capable, publish_run_token_id, publish_run_token_digest, policy_revision, policy_denials, certificate_cache_key, certificate_scope, certificate_identifiers, certificate_challenge_method, traffic_limits, state, created_at, last_heartbeat_at, publisher_expires_at, certificate_installed_at, certificate_issuance_id, certificate_not_after, ready_at, closed_at, close_reason, assignments_open +` + +type LimitGuestPublishRunExpiryParams struct { + Deadline pgtype.Timestamptz + PublishRunID string +} + +func (q *Queries) LimitGuestPublishRunExpiry(ctx context.Context, arg LimitGuestPublishRunExpiryParams) (ControlPublishRun, error) { + row := q.db.QueryRow(ctx, limitGuestPublishRunExpiry, arg.Deadline, arg.PublishRunID) + var i ControlPublishRun + err := row.Scan( + &i.ID, + &i.PublicURLID, + &i.TeamID, + &i.MembershipID, + &i.ActingIdentityID, + &i.PublisherIdentityID, + &i.PublisherGrantID, + &i.PublishRunNumber, + &i.IdempotencyKey, + &i.RequestDigestCiphertext, + &i.RequestDigestStorageKeyID, + &i.ShareCapable, + &i.PublishRunTokenID, + &i.PublishRunTokenDigest, + &i.PolicyRevision, + &i.PolicyDenials, + &i.CertificateCacheKey, + &i.CertificateScope, + &i.CertificateIdentifiers, + &i.CertificateChallengeMethod, + &i.TrafficLimits, + &i.State, + &i.CreatedAt, + &i.LastHeartbeatAt, + &i.PublisherExpiresAt, + &i.CertificateInstalledAt, + &i.CertificateIssuanceID, + &i.CertificateNotAfter, + &i.ReadyAt, + &i.ClosedAt, + &i.CloseReason, + &i.AssignmentsOpen, + ) + return i, err +} + const lockGuestTrialByID = `-- name: LockGuestTrialByID :one -SELECT id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, used_bytes, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, created_at, updated_at FROM control.guest_trials WHERE id = $1 FOR UPDATE +SELECT id, credential_id, credential_hash, namespace_label, team_id, membership_id, domain_id, dns_authority_reference, source_ip_digest, source_ip_key_id, issuance_ip_digest, expires_at, used_ready_ns, first_demo_allocated_at, first_ready_at, end_reason, ended_at, last_demo_number, active_publish_run_id, active_ready_at, ready_deadline, serving_until, created_at, updated_at FROM control.guest_trials WHERE id = $1 FOR UPDATE ` func (q *Queries) LockGuestTrialByID(ctx context.Context, id string) (ControlGuestTrial, error) { @@ -507,7 +554,6 @@ func (q *Queries) LockGuestTrialByID(ctx context.Context, id string) (ControlGue &i.IssuanceIpDigest, &i.ExpiresAt, &i.UsedReadyNs, - &i.UsedBytes, &i.FirstDemoAllocatedAt, &i.FirstReadyAt, &i.EndReason, @@ -515,6 +561,8 @@ func (q *Queries) LockGuestTrialByID(ctx context.Context, id string) (ControlGue &i.LastDemoNumber, &i.ActivePublishRunID, &i.ActiveReadyAt, + &i.ReadyDeadline, + &i.ServingUntil, &i.CreatedAt, &i.UpdatedAt, ) @@ -524,10 +572,13 @@ func (q *Queries) LockGuestTrialByID(ctx context.Context, id string) (ControlGue const markGuestRunReady = `-- name: MarkGuestRunReady :execrows UPDATE control.guest_trials SET active_ready_at = $1, updated_at = $1, + ready_deadline = LEAST(expires_at, $1::timestamptz + + ((1200000000000 - used_ready_ns)::double precision / 1000000000) * interval '1 second'), first_ready_at = COALESCE(first_ready_at, $1) WHERE active_publish_run_id = $2 AND active_ready_at IS NULL AND expires_at > $1 + AND used_ready_ns < 1200000000000 ` type MarkGuestRunReadyParams struct { @@ -549,18 +600,16 @@ SELECT count(*) FILTER (WHERE first_demo_allocated_at >= $1)::bigint AS allocated, count(*) FILTER (WHERE first_ready_at >= $1)::bigint AS ready, count(*) FILTER (WHERE end_reason = 'expired' AND ended_at >= $1)::bigint AS expired, - count(*) FILTER (WHERE end_reason = 'ready_limit' AND ended_at >= $1)::bigint AS ready_limit, - count(*) FILTER (WHERE end_reason = 'transfer_limit' AND ended_at >= $1)::bigint AS transfer_limit + count(*) FILTER (WHERE end_reason = 'ready_limit' AND ended_at >= $1)::bigint AS ready_limit FROM control.guest_trials ` type RecentGuestTrialStatsRow struct { - Issued int64 - Allocated int64 - Ready int64 - Expired int64 - ReadyLimit int64 - TransferLimit int64 + Issued int64 + Allocated int64 + Ready int64 + Expired int64 + ReadyLimit int64 } func (q *Queries) RecentGuestTrialStats(ctx context.Context, since pgtype.Timestamptz) (RecentGuestTrialStatsRow, error) { @@ -572,44 +621,24 @@ func (q *Queries) RecentGuestTrialStats(ctx context.Context, since pgtype.Timest &i.Ready, &i.Expired, &i.ReadyLimit, - &i.TransferLimit, ) return i, err } -const updateGuestTransferredBytes = `-- name: UpdateGuestTransferredBytes :execrows -WITH observed AS ( - SELECT routes.guest_id, COALESCE(SUM(buckets.ingress_bytes + buckets.egress_bytes), 0)::bigint AS bytes - FROM control.guest_public_urls AS routes - LEFT JOIN control.public_url_usage_buckets AS buckets ON buckets.public_url_id = routes.public_url_id - WHERE routes.guest_id = ( - SELECT owned.guest_id FROM control.guest_public_urls AS owned WHERE owned.public_url_id = $2 - ) - GROUP BY routes.guest_id -) +const updateGuestServingAuthority = `-- name: UpdateGuestServingAuthority :one UPDATE control.guest_trials AS guest -SET used_bytes = GREATEST(guest.used_bytes, observed.bytes), - end_reason = COALESCE(guest.end_reason, CASE - WHEN guest.expires_at <= $1::timestamptz THEN 'expired' - WHEN GREATEST(guest.used_bytes, observed.bytes) >= 5242880 THEN 'transfer_limit' - END), - ended_at = COALESCE(guest.ended_at, CASE - WHEN guest.expires_at <= $1::timestamptz THEN guest.expires_at - WHEN GREATEST(guest.used_bytes, observed.bytes) >= 5242880 THEN $1::timestamptz - END), - updated_at = GREATEST(guest.updated_at, $1) -FROM observed WHERE guest.id = observed.guest_id +SET serving_until = CASE WHEN guest.active_ready_at IS NULL THEN guest.serving_until + ELSE GREATEST(guest.serving_until, LEAST(guest.expires_at, guest.ready_deadline, run.publisher_expires_at)) END +FROM control.publish_runs AS run +WHERE run.id = $1 AND guest.active_publish_run_id = run.id +RETURNING LEAST(guest.expires_at, COALESCE(guest.ready_deadline, guest.expires_at))::timestamptz AS deadline ` -type UpdateGuestTransferredBytesParams struct { - ObservedAt pgtype.Timestamptz - GuestRouteID string -} - -func (q *Queries) UpdateGuestTransferredBytes(ctx context.Context, arg UpdateGuestTransferredBytesParams) (int64, error) { - result, err := q.db.Exec(ctx, updateGuestTransferredBytes, arg.ObservedAt, arg.GuestRouteID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil +// retain the latest issued serving horizon, including after the run closes. +// a replacement cannot reuse this time while an offline owner may still serve. +func (q *Queries) UpdateGuestServingAuthority(ctx context.Context, publishRunID string) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, updateGuestServingAuthority, publishRunID) + var deadline pgtype.Timestamptz + err := row.Scan(&deadline) + return deadline, err } diff --git a/internal/control/state/controlstatedb/models.go b/internal/control/state/controlstatedb/models.go index 2a06ed044..ccf8d2cd0 100644 --- a/internal/control/state/controlstatedb/models.go +++ b/internal/control/state/controlstatedb/models.go @@ -444,7 +444,6 @@ type ControlGuestTrial struct { IssuanceIpDigest pgtype.Text ExpiresAt pgtype.Timestamptz UsedReadyNs int64 - UsedBytes int64 FirstDemoAllocatedAt pgtype.Timestamptz FirstReadyAt pgtype.Timestamptz EndReason pgtype.Text @@ -452,6 +451,8 @@ type ControlGuestTrial struct { LastDemoNumber int64 ActivePublishRunID pgtype.Text ActiveReadyAt pgtype.Timestamptz + ReadyDeadline pgtype.Timestamptz + ServingUntil pgtype.Timestamptz CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz } diff --git a/internal/control/state/controlstatedb/publish_runs.sql.go b/internal/control/state/controlstatedb/publish_runs.sql.go index 8c9fdb2e6..1c0f090e7 100644 --- a/internal/control/state/controlstatedb/publish_runs.sql.go +++ b/internal/control/state/controlstatedb/publish_runs.sql.go @@ -555,10 +555,8 @@ WHERE sessions.closed_at IS NULL JOIN control.guest_trials AS guest ON guest.id = guest_url.guest_id WHERE guest_url.public_url_id = routes.id AND ( guest.expires_at <= $1 - OR guest.used_bytes >= 5242880 - OR guest.used_ready_ns >= 900000000000 - OR guest.active_ready_at IS NOT NULL AND guest.used_ready_ns + - GREATEST(0, (EXTRACT(EPOCH FROM ($1::timestamptz - guest.active_ready_at)) * 1000000000)::bigint) >= 900000000000 + OR guest.used_ready_ns >= 1200000000000 + OR guest.ready_deadline <= $1 ) )) ORDER BY sessions.publisher_expires_at, sessions.id diff --git a/internal/control/state/controlstatedb/querier.go b/internal/control/state/controlstatedb/querier.go index 650d18504..0805cb2be 100644 --- a/internal/control/state/controlstatedb/querier.go +++ b/internal/control/state/controlstatedb/querier.go @@ -324,6 +324,7 @@ type Querier interface { InsertShareHandoff(ctx context.Context, arg InsertShareHandoffParams) ([]byte, error) InvalidatePublisherGroupRuntimeHealth(ctx context.Context, publisherIdentityID string) error LatestIngressRoutingEntryRevision(ctx context.Context, arg LatestIngressRoutingEntryRevisionParams) (int64, error) + LimitGuestPublishRunExpiry(ctx context.Context, arg LimitGuestPublishRunExpiryParams) (ControlPublishRun, error) ListACMEOrderAuthorizations(ctx context.Context, issuanceID string) ([]ControlAcmeAuthorization, error) ListActiveShareCookiesForPublicURL(ctx context.Context, arg ListActiveShareCookiesForPublicURLParams) ([]ListActiveShareCookiesForPublicURLRow, error) ListActiveSharesForPublicURL(ctx context.Context, arg ListActiveSharesForPublicURLParams) ([]ListActiveSharesForPublicURLRow, error) @@ -631,7 +632,9 @@ type Querier interface { UpdateACMEAccountRegistration(ctx context.Context, arg UpdateACMEAccountRegistrationParams) (ControlAcmeAccount, error) UpdateDeviceLogin(ctx context.Context, arg UpdateDeviceLoginParams) error UpdateFeedbackThreadState(ctx context.Context, arg UpdateFeedbackThreadStateParams) (ControlFeedbackThread, error) - UpdateGuestTransferredBytes(ctx context.Context, arg UpdateGuestTransferredBytesParams) (int64, error) + // retain the latest issued serving horizon, including after the run closes. + // a replacement cannot reuse this time while an offline owner may still serve. + UpdateGuestServingAuthority(ctx context.Context, publishRunID string) (pgtype.Timestamptz, error) UpdateLocalDomainForDNSAuthority(ctx context.Context, arg UpdateLocalDomainForDNSAuthorityParams) (UpdateLocalDomainForDNSAuthorityRow, error) UpdateMembershipRole(ctx context.Context, arg UpdateMembershipRoleParams) (int64, error) UpdateOIDCIdentity(ctx context.Context, arg UpdateOIDCIdentityParams) (ControlIdentity, error) diff --git a/internal/control/state/guest_cleanup.go b/internal/control/state/guest_cleanup.go index a1dde932e..012c02da2 100644 --- a/internal/control/state/guest_cleanup.go +++ b/internal/control/state/guest_cleanup.go @@ -43,12 +43,11 @@ func (d *Database) ForgetGuestPrivateState(ctx context.Context, now time.Time) ( } type GuestTrialStats struct { - Issued int64 - Allocated int64 - Ready int64 - Expired int64 - ReadyLimit int64 - TransferLimit int64 + Issued int64 + Allocated int64 + Ready int64 + Expired int64 + ReadyLimit int64 } // RecentGuestTrialStats returns service-wide counts from committed trial rows. @@ -62,6 +61,6 @@ func (d *Database) RecentGuestTrialStats(ctx context.Context, now time.Time) (Gu } return GuestTrialStats{ Issued: row.Issued, Allocated: row.Allocated, Ready: row.Ready, - Expired: row.Expired, ReadyLimit: row.ReadyLimit, TransferLimit: row.TransferLimit, + Expired: row.Expired, ReadyLimit: row.ReadyLimit, }, nil } diff --git a/internal/control/state/guests.go b/internal/control/state/guests.go index 1a35be8b2..79cbc8423 100644 --- a/internal/control/state/guests.go +++ b/internal/control/state/guests.go @@ -20,8 +20,7 @@ import ( ) const ( - GuestReadyAllowance = 15 * time.Minute - GuestByteAllowance = 5 << 20 + GuestReadyAllowance = 20 * time.Minute GuestLifetime = 72 * time.Hour ) @@ -43,7 +42,8 @@ type GuestTrial struct { SourceIPKeyID string ExpiresAt time.Time UsedReady time.Duration - UsedBytes int64 + ReadyDeadline time.Time + ServingUntil time.Time LastDemoNumber int64 } @@ -191,7 +191,8 @@ func (d *Database) GuestTrialByAccessToken(ctx context.Context, token credential guest := GuestTrial{ ID: row.ID, NamespaceLabel: row.NamespaceLabel, TeamID: row.TeamID, MembershipID: row.MembershipID, DomainID: row.DomainID, DNSAuthorityReference: row.DnsAuthorityReference, - UsedReady: time.Duration(row.UsedReadyNs), UsedBytes: row.UsedBytes, LastDemoNumber: row.LastDemoNumber, + UsedReady: time.Duration(row.UsedReadyNs), LastDemoNumber: row.LastDemoNumber, + ReadyDeadline: row.ReadyDeadline.Time, ServingUntil: row.ServingUntil.Time, ExpiresAt: row.ExpiresAt.Time, SourceIPDigest: row.SourceIpDigest.String, SourceIPKeyID: row.SourceIpKeyID.String, } return guest, nil @@ -245,14 +246,14 @@ func (d *Database) AllocateGuestDemoNumber(ctx context.Context, guestID string, if err != nil { return 0, err } - if !guest.ExpiresAt.Valid || !guest.ExpiresAt.Time.After(at) || guest.UsedBytes >= GuestByteAllowance || guest.UsedReadyNs >= int64(GuestReadyAllowance) { + if !guest.ExpiresAt.Valid || !guest.ExpiresAt.Time.After(at) || guest.UsedReadyNs >= int64(GuestReadyAllowance) { return 0, ErrGuestTrialSpent } count, err := queries.CountGuestCurrentPublicURLs(ctx, guestID) if err != nil { return 0, err } - if count != 0 || guest.ActivePublishRunID.Valid { + if count != 0 || guest.ActivePublishRunID.Valid || guest.ServingUntil.Valid && guest.ServingUntil.Time.After(at) { return 0, ErrPublishRunOpen } number, err = queries.AdvanceGuestDemoNumber(ctx, controlstatedb.AdvanceGuestDemoNumberParams{ @@ -269,3 +270,19 @@ func (d *Database) AllocateGuestDemoNumber(ctx context.Context, guestID string, } return number, nil } + +// boundGuestServingAuthority records the exact run's issued horizon before it +// leaves control. ready deadlines never move; heartbeats can only fill that span. +func boundGuestServingAuthority(ctx context.Context, queries *controlstatedb.Queries, run controlstatedb.ControlPublishRun) (controlstatedb.ControlPublishRun, error) { + deadline, err := queries.UpdateGuestServingAuthority(ctx, run.ID) + if errors.Is(err, pgx.ErrNoRows) { + return run, nil + } + if err != nil { + return run, fmt.Errorf("record guest serving authority: %w", err) + } + if deadline.Time.Before(run.PublisherExpiresAt.Time) { + run, err = queries.LimitGuestPublishRunExpiry(ctx, controlstatedb.LimitGuestPublishRunExpiryParams{PublishRunID: run.ID, Deadline: deadline}) + } + return run, err +} diff --git a/internal/control/state/guests_integration_test.go b/internal/control/state/guests_integration_test.go index 633f77ef5..19af9ea14 100644 --- a/internal/control/state/guests_integration_test.go +++ b/internal/control/state/guests_integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/tnldotdev/tnl/internal/control/state/controlstatedb" "github.com/tnldotdev/tnl/internal/naming/opaqueid" + "github.com/tnldotdev/tnl/internal/policy/visitorscope" ) func TestIntegrationGuestTrialCredentialAndOneCurrentPublicURL(t *testing.T) { @@ -173,8 +174,8 @@ func TestIntegrationGuestIssuanceSerializesOneNetwork(t *testing.T) { } } -func TestIntegrationGuestTrialMetersReadyTimeAndBothByteDirections(t *testing.T) { - database, now, request, _ := newPublishRunPrerequisites(t) +func TestIntegrationGuestReadyAuthoritySettlesOnceAndFencesRestarts(t *testing.T) { + database, now, request, leases := newPublishRunPrerequisites(t) guest, err := NewGuestTrialCredential(netip.MustParseAddr("192.0.2.7")) if err != nil { t.Fatal(err) @@ -189,49 +190,54 @@ func TestIntegrationGuestTrialMetersReadyTimeAndBothByteDirections(t *testing.T) if err != nil { t.Fatal(err) } - if _, err := database.pool.Exec(t.Context(), `UPDATE control.guest_trials SET active_ready_at = $2 WHERE id = $1`, guest.ID, now.Add(time.Second)); err != nil { - t.Fatal(err) - } - if _, err := database.pool.Exec(t.Context(), `INSERT INTO control.public_url_usage_buckets - (public_url_id, publish_run_number, team_id, acting_identity_id, bucket_start, bucket_end, - observed_through, ingress_bytes, egress_bytes, histogram_data, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $6, 2097152, 1048576, $7, $6)`, - request.PublicURLID, setup.PublishRunNumber, request.TeamID, request.ActingIdentityID, - now, now.Add(time.Minute), []byte{0}); err != nil { + f := publishRunFixture{database, now, request, setup, leases} + readyTestSession(t, f) + heartbeat, err := database.HeartbeatPublishRun(t.Context(), f.authentication(), now.Add(10*time.Second), 30*time.Second, time.Minute) + if err != nil { t.Fatal(err) } - queries := controlstatedb.New(database.pool) - update := controlstatedb.UpdateGuestTransferredBytesParams{ - GuestRouteID: request.PublicURLID, ObservedAt: timestamptz(now.Add(time.Minute)), - } for range 2 { - if _, err := queries.UpdateGuestTransferredBytes(t.Context(), update); err != nil { + if err := database.ClosePublishRun(t.Context(), setup.PublishRunID, setup.PublishRunToken, now.Add(11*time.Second)); err != nil { t.Fatal(err) } } - if err := database.ClosePublishRun(t.Context(), setup.PublishRunID, setup.PublishRunToken, now.Add(time.Minute)); err != nil { + stored, err := database.GuestTrialByAccessToken(t.Context(), guest.Token) + if err != nil || stored.UsedReady != 40*time.Second || !stored.ServingUntil.Equal(heartbeat.ExpiresAt) || !stored.ReadyDeadline.IsZero() { + t.Fatalf("settled guest authority = %+v, error = %v", stored, err) + } + request.IdempotencyKey = "restart" + if err := database.pool.QueryRow(t.Context(), `SELECT mutation_revision FROM control.public_urls WHERE id=$1`, request.PublicURLID).Scan(&request.ExpectedMutationRevision); err != nil { t.Fatal(err) } - stored, err := database.GuestTrialByAccessToken(t.Context(), guest.Token) - if err != nil || stored.UsedBytes != 3<<20 || stored.UsedReady != 59*time.Second { - t.Fatalf("cumulative guest usage = %+v, error = %v", stored, err) + if _, err := database.CreatePublishRun(t.Context(), request, now.Add(12*time.Second), 30*time.Second, time.Minute); !errors.Is(err, ErrPublishRunOpen) { + t.Fatalf("restart reused outstanding authority: %v", err) } - if count, err := database.FinalizePublicURLUsageBuckets(t.Context(), now.Add(time.Minute), now.Add(time.Minute)); err != nil || count != 0 { - t.Fatalf("guest-only external usage deliveries = %d, %v", count, err) + restarted, err := database.CreatePublishRun(t.Context(), request, now.Add(40*time.Second), 30*time.Second, time.Minute) + if err != nil { + t.Fatal(err) + } + queries := controlstatedb.New(database.pool) + if _, err := queries.MarkGuestRunReady(t.Context(), controlstatedb.MarkGuestRunReadyParams{PublishRunID: text(restarted.PublishRunID), ReadyAt: timestamptz(now.Add(41 * time.Second))}); err != nil { + t.Fatal(err) } - var finalized bool - if err := database.pool.QueryRow(t.Context(), `SELECT finalized FROM control.public_url_usage_buckets WHERE public_url_id = $1`, request.PublicURLID).Scan(&finalized); err != nil || !finalized { - t.Fatalf("guest usage was not finalized: %t, %v", finalized, err) + stored, err = database.GuestTrialByAccessToken(t.Context(), guest.Token) + if err != nil || stored.UsedReady != 40*time.Second || !stored.ReadyDeadline.Equal(now.Add(41*time.Second+GuestReadyAllowance-40*time.Second)) { + t.Fatalf("restart reset cumulative ready budget: %+v, %v", stored, err) } } -func TestIntegrationGuestHeartbeatClosesExhaustedTrial(t *testing.T) { +func TestIntegrationGuestHardDeadlineBoundsIssuedAuthority(t *testing.T) { database, now, request, _ := newPublishRunPrerequisites(t) + urlRequest := oidcRouteRequest(t, database, now) + url := createTestPublicURL(t, database, urlRequest, now) + request.PublicURLID, request.TeamID, request.MembershipID = url.ID, url.TeamID, url.MembershipID + request.ActingIdentityID, request.CertificateIdentifiers = urlRequest.ActingIdentityID, []string{url.CanonicalHostname} guest, err := NewGuestTrialCredential(netip.MustParseAddr("192.0.2.7")) if err != nil { t.Fatal(err) } - if _, err := database.CreateGuestTrial(t.Context(), guest, "routes.example.test", now); err != nil { + guest.TeamID, guest.MembershipID = request.TeamID, request.MembershipID + if _, err := database.CreateGuestTrial(t.Context(), guest, "tunnels.example.test", now); err != nil { t.Fatal(err) } if _, err := database.pool.Exec(t.Context(), `INSERT INTO control.guest_public_urls (public_url_id, guest_id, created_at) VALUES ($1, $2, $3)`, request.PublicURLID, guest.ID, now); err != nil { @@ -241,36 +247,55 @@ func TestIntegrationGuestHeartbeatClosesExhaustedTrial(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := database.pool.Exec(t.Context(), `INSERT INTO control.public_url_usage_buckets - (public_url_id, publish_run_number, team_id, acting_identity_id, bucket_start, bucket_end, - observed_through, ingress_bytes, egress_bytes, histogram_data, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $6, 3145728, 3145728, $7, $6)`, - request.PublicURLID, setup.PublishRunNumber, request.TeamID, request.ActingIdentityID, - now, now.Add(time.Minute), []byte{0}); err != nil { + if _, err := database.pool.Exec(t.Context(), `UPDATE control.guest_trials SET used_ready_ns=$2 WHERE id=$1`, guest.ID, int64(GuestReadyAllowance-5*time.Second)); err != nil { t.Fatal(err) } - if _, err := controlstatedb.New(database.pool).UpdateGuestTransferredBytes(t.Context(), controlstatedb.UpdateGuestTransferredBytesParams{ - GuestRouteID: request.PublicURLID, ObservedAt: timestamptz(now.Add(time.Minute)), - }); err != nil { + prepareVisitorTokenKeys(t, database, now) + ingress, err := database.RegisterIngress(t.Context(), IngressRegistration{IngressID: "guest-policy", IngressRunID: "guest-policy-run", ProtocolVersion: 1, ConnectionCapacity: 10, IngressPoolID: PrimaryIngressPoolID}, now, time.Minute) + if err != nil { t.Fatal(err) } - _, err = database.HeartbeatPublishRun(t.Context(), PublishRunAuthentication{ + scope := visitorscope.Scope{Kind: visitorscope.PublicURL, ID: setup.PublicURLID} + preparing, err := database.ReadIngressVisitorPolicy(t.Context(), ingress.IngressLeaseIdentity, scope, 0, now) + if err != nil || !preparing.Policy.ValidUntil.Equal(now.Add(5*time.Second)) { + t.Fatalf("pre-ready policy outlived remaining budget: %+v, %v", preparing.Policy, err) + } + queries := controlstatedb.New(database.pool) + if _, err := queries.MarkGuestRunReady(t.Context(), controlstatedb.MarkGuestRunReadyParams{PublishRunID: text(setup.PublishRunID), ReadyAt: timestamptz(now)}); err != nil { + t.Fatal(err) + } + authentication := PublishRunAuthentication{ PublishRunID: setup.PublishRunID, PublicURLID: setup.PublicURLID, PublishRunNumber: setup.PublishRunNumber, PublishRunToken: setup.PublishRunToken, - }, now.Add(2*time.Second), 30*time.Second, time.Minute) - if !errors.Is(err, ErrGuestTrialSpent) { - t.Fatalf("exhausted guest heartbeat = %v", err) } - var activeRunID *string - if err := database.pool.QueryRow(t.Context(), `SELECT active_publish_run_id FROM control.guest_trials WHERE id = $1`, guest.ID).Scan(&activeRunID); err != nil || activeRunID != nil { - t.Fatalf("guest retained active run = %v, %v", activeRunID, err) + for _, elapsed := range []time.Duration{time.Second, 3 * time.Second} { + renewed, err := database.HeartbeatPublishRun(t.Context(), authentication, now.Add(elapsed), 5*time.Minute, time.Minute) + if err != nil || !renewed.ExpiresAt.Equal(now.Add(5*time.Second)) { + t.Fatalf("heartbeat extended hard deadline: %+v, %v", renewed, err) + } + publisherPolicy, err := database.ReadPublisherVisitorPolicy(t.Context(), scope, setup.PublishRunID, int64(setup.PublishRunNumber), setup.PublishRunToken.String(), 0, nil, now.Add(elapsed)) + if err != nil || !publisherPolicy.Policy.ValidUntil.Equal(now.Add(5*time.Second)) { + t.Fatalf("publisher policy extended guest deadline: %+v, %v", publisherPolicy.Policy, err) + } + ingressPolicy, err := database.ReadIngressVisitorPolicy(t.Context(), ingress.IngressLeaseIdentity, scope, 0, now.Add(elapsed)) + if err != nil || !ingressPolicy.Policy.ValidUntil.Equal(now.Add(5*time.Second)) { + t.Fatalf("ingress policy extended guest deadline: %+v, %v", ingressPolicy.Policy, err) + } + } + if _, err := database.HeartbeatPublishRun(t.Context(), authentication, now.Add(5*time.Second), 5*time.Minute, time.Minute); !errors.Is(err, ErrPublishRunStale) { + t.Fatalf("expired run renewed authority: %v", err) } - var endReason string - if err := database.pool.QueryRow(t.Context(), `SELECT end_reason FROM control.guest_trials WHERE id=$1`, guest.ID).Scan(&endReason); err != nil || endReason != "transfer_limit" { - t.Fatalf("guest byte cutoff reason = %q, %v", endReason, err) + for _, elapsed := range []time.Duration{time.Minute, time.Hour} { + if _, err := database.ExpireSavedPublishRuns(t.Context(), now.Add(elapsed)); err != nil { + t.Fatal(err) + } + } + stored, err := database.GuestTrialByAccessToken(t.Context(), guest.Token) + if err != nil || stored.UsedReady != GuestReadyAllowance || !stored.ServingUntil.Equal(now.Add(5*time.Second)) { + t.Fatalf("hard deadline settlement = %+v, %v", stored, err) } - stats, err := database.RecentGuestTrialStats(t.Context(), now.Add(2*time.Second)) - if err != nil || stats.Issued != 1 || stats.TransferLimit != 1 || stats.Expired != 0 { + stats, err := database.RecentGuestTrialStats(t.Context(), now.Add(time.Hour)) + if err != nil || stats.Issued != 1 || stats.ReadyLimit != 1 || stats.Expired != 0 { t.Fatalf("committed guest totals = %+v, %v", stats, err) } } @@ -336,7 +361,7 @@ func TestIntegrationGuestExpiryClosesRunWithoutHeartbeat(t *testing.T) { t.Fatal(err) } if test.ready { - if _, err := database.pool.Exec(t.Context(), `UPDATE control.guest_trials SET active_ready_at=$2 WHERE id=$1`, guest.ID, now); err != nil { + if _, err := controlstatedb.New(database.pool).MarkGuestRunReady(t.Context(), controlstatedb.MarkGuestRunReadyParams{PublishRunID: text(setup.PublishRunID), ReadyAt: timestamptz(now)}); err != nil { t.Fatal(err) } } diff --git a/internal/control/state/migration_integration_test.go b/internal/control/state/migration_integration_test.go index 441a8c51d..651f343c3 100644 --- a/internal/control/state/migration_integration_test.go +++ b/internal/control/state/migration_integration_test.go @@ -119,6 +119,7 @@ func TestIntegrationMigrationRejectsSiblingWorkloadBaselines(t *testing.T) { t.Fatal(err) } for _, previous := range []string{ + "tnl-control-fresh-v1-visitors", "tnl-control-fresh-v1-services", "tnl-control-fresh-v1-services-oidc-company-service-http-dns-egress-deployment-workload-visitors", "tnl-control-fresh-v1-services-oidc-company-service-http-dns-egress-deployment-provider-workloads", diff --git a/internal/control/state/migrations/00001_baseline.sql b/internal/control/state/migrations/00001_baseline.sql index ce31f318a..9f858b8fe 100644 --- a/internal/control/state/migrations/00001_baseline.sql +++ b/internal/control/state/migrations/00001_baseline.sql @@ -5,9 +5,9 @@ CREATE SCHEMA IF NOT EXISTS control; -- mistaken for this coordinated fresh-database cutover. CREATE TABLE control.schema_identity ( id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), - baseline_id text NOT NULL CHECK (baseline_id = 'tnl-control-fresh-v1-visitors') + baseline_id text NOT NULL CHECK (baseline_id = 'tnl-control-fresh-v1-peer-paths') ); -INSERT INTO control.schema_identity (id, baseline_id) VALUES (1, 'tnl-control-fresh-v1-visitors'); +INSERT INTO control.schema_identity (id, baseline_id) VALUES (1, 'tnl-control-fresh-v1-peer-paths'); CREATE TABLE control.identities ( id text PRIMARY KEY CHECK (id <> ''), @@ -1404,18 +1404,22 @@ CREATE TABLE control.guest_trials ( source_ip_key_id text CHECK (source_ip_key_id <> ''), issuance_ip_digest text CHECK (issuance_ip_digest ~ '^[A-Za-z0-9_-]{43}$'), expires_at timestamptz NOT NULL, - used_ready_ns bigint NOT NULL DEFAULT 0 CHECK (used_ready_ns >= 0), - used_bytes bigint NOT NULL DEFAULT 0 CHECK (used_bytes >= 0), + used_ready_ns bigint NOT NULL DEFAULT 0 CHECK (used_ready_ns BETWEEN 0 AND 1200000000000), first_demo_allocated_at timestamptz, first_ready_at timestamptz, - end_reason text CHECK (end_reason IN ('expired', 'ready_limit', 'transfer_limit')), + end_reason text CHECK (end_reason IN ('expired', 'ready_limit')), ended_at timestamptz, last_demo_number bigint NOT NULL DEFAULT 0 CHECK (last_demo_number >= 0), active_publish_run_id text, active_ready_at timestamptz, + ready_deadline timestamptz, + serving_until timestamptz, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL CHECK (updated_at >= created_at), CHECK (active_ready_at IS NULL OR active_publish_run_id IS NOT NULL), + CHECK ((active_ready_at IS NULL) = (ready_deadline IS NULL)), + CHECK (ready_deadline IS NULL OR ready_deadline > active_ready_at AND ready_deadline <= expires_at), + CHECK (serving_until IS NULL OR serving_until <= expires_at), CHECK ((source_ip_digest IS NULL) = (source_ip_key_id IS NULL)), CHECK ((credential_id IS NULL) = (credential_hash IS NULL)), CHECK ((end_reason IS NULL) = (ended_at IS NULL)), @@ -1427,9 +1431,8 @@ CREATE INDEX guest_trials_created_at ON control.guest_trials(created_at); CREATE FUNCTION control.guest_share_live(guest_id text, at_time timestamptz) RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT EXISTS (SELECT 1 FROM control.guest_trials guest WHERE guest.id = guest_id AND guest.ended_at IS NULL AND guest.expires_at > at_time - AND guest.used_bytes < 5242880 AND guest.used_ready_ns < 900000000000 - AND (guest.active_ready_at IS NULL OR guest.used_ready_ns + GREATEST(0, - floor(extract(epoch FROM (at_time - guest.active_ready_at)) * 1000000000)::bigint) < 900000000000)) + AND guest.used_ready_ns < 1200000000000 + AND (guest.ready_deadline IS NULL OR guest.ready_deadline > at_time)) $$; CREATE TABLE control.guest_public_urls ( public_url_id text PRIMARY KEY REFERENCES control.public_urls(id) ON DELETE RESTRICT, @@ -2816,8 +2819,7 @@ CREATE TRIGGER visitor_policy_domain AFTER UPDATE OR DELETE ON control.domains FOR EACH ROW EXECUTE FUNCTION control.visitor_policy_fact_changed('team'); CREATE TRIGGER visitor_policy_guest AFTER UPDATE ON control.guest_trials FOR EACH ROW WHEN (NEW.ended_at IS DISTINCT FROM OLD.ended_at OR NEW.expires_at=5242880) - OR (OLD.used_ready_ns<900000000000 AND NEW.used_ready_ns>=900000000000)) + OR (OLD.used_ready_ns<1200000000000 AND NEW.used_ready_ns>=1200000000000)) EXECUTE FUNCTION control.visitor_policy_fact_changed('guest'); -- +goose StatementBegin diff --git a/internal/control/state/public_urls.go b/internal/control/state/public_urls.go index 6068a5d27..a7c4bc5b9 100644 --- a/internal/control/state/public_urls.go +++ b/internal/control/state/public_urls.go @@ -230,7 +230,7 @@ func (d *Database) CreatePublicURL(ctx context.Context, request CreatePublicURLR } // guest team IDs are trial-owned, not rows in control.teams. ingressPoolID = PrimaryIngressPoolID - if !guest.ExpiresAt.Time.After(now) || guest.UsedReadyNs >= int64(GuestReadyAllowance) || guest.UsedBytes >= GuestByteAllowance { + if !guest.ExpiresAt.Time.After(now) || guest.UsedReadyNs >= int64(GuestReadyAllowance) { return PublicURL{}, ErrGuestTrialSpent } if guest.LastDemoNumber < 1 || !strings.HasPrefix(request.CanonicalHostname, fmt.Sprintf("demo-%d.%s.", guest.LastDemoNumber, guest.NamespaceLabel)) || @@ -247,7 +247,7 @@ func (d *Database) CreatePublicURL(ctx context.Context, request CreatePublicURLR if err != nil { return PublicURL{}, err } - if count != 0 { + if count != 0 || guest.ServingUntil.Valid && guest.ServingUntil.Time.After(now) { return PublicURL{}, ErrPublishRunOpen } } diff --git a/internal/control/state/publish_run_closure.go b/internal/control/state/publish_run_closure.go index 2ed6decc5..f6e294659 100644 --- a/internal/control/state/publish_run_closure.go +++ b/internal/control/state/publish_run_closure.go @@ -135,21 +135,11 @@ func expireOpenPublishRun(ctx context.Context, queries *controlstatedb.Queries, if err != nil { return false, false, fmt.Errorf("controlstate: expire publish run: read guest trial: %w", err) } - if guest.ExpiresAt.Valid && guest.ExpiresAt.Time.Before(closedAt) { + if guest.ExpiresAt.Valid && !guest.ExpiresAt.Time.After(closedAt) { closedAt, reason = guest.ExpiresAt.Time, "guest_expired" } - if guest.ActiveReadyAt.Valid { - remaining := GuestReadyAllowance - time.Duration(guest.UsedReadyNs) - if remaining < 0 { - remaining = 0 - } - deadline := guest.ActiveReadyAt.Time.Add(remaining) - if deadline.Before(closedAt) { - closedAt, reason = deadline, "guest_ready_limit" - } - } - if guest.UsedBytes >= GuestByteAllowance && now.Before(closedAt) { - closedAt, reason = now, "guest_transfer_limit" + if guest.ReadyDeadline.Valid && !guest.ReadyDeadline.Time.After(closedAt) { + closedAt, reason = guest.ReadyDeadline.Time, "guest_ready_limit" } } if closedAt.After(now) { diff --git a/internal/control/state/publish_run_heartbeat.go b/internal/control/state/publish_run_heartbeat.go index 66c47bb23..a2b809f2d 100644 --- a/internal/control/state/publish_run_heartbeat.go +++ b/internal/control/state/publish_run_heartbeat.go @@ -94,6 +94,10 @@ func (d *Database) HeartbeatPublishRun( if err != nil { return PublishRunSetup{}, fmt.Errorf("controlstate: heartbeat publish run: update lease: %w", err) } + session, err = boundGuestServingAuthority(ctx, queries, session) + if err != nil { + return PublishRunSetup{}, err + } leaseExtended := session.PublisherExpiresAt.Time.After(previousExpiresAt) connections, err := validReadyPublisherConnections(ctx, queries, session.ID, now) if err != nil { diff --git a/internal/control/state/publish_run_readiness.go b/internal/control/state/publish_run_readiness.go index 8d2953527..d740a9403 100644 --- a/internal/control/state/publish_run_readiness.go +++ b/internal/control/state/publish_run_readiness.go @@ -74,6 +74,10 @@ func (d *Database) MarkPublishRunReady( }); err != nil { return PublishRunLifecycle{}, fmt.Errorf("controlstate: mark guest run ready: %w", err) } + session, err = boundGuestServingAuthority(ctx, queries, session) + if err != nil { + return PublishRunLifecycle{}, err + } publishedEvent, err = pendingEvents.addRouteEvent( ctx, queries, route, session, connections, "public_url_upsert", now, ) diff --git a/internal/control/state/publish_runs.go b/internal/control/state/publish_runs.go index 7541e00f1..d07acbdf5 100644 --- a/internal/control/state/publish_runs.go +++ b/internal/control/state/publish_runs.go @@ -359,7 +359,7 @@ func (d *Database) createPublishRun( if err != nil { return PublishRunSetup{}, fmt.Errorf("controlstate: create publish run: insert session: %w", err) } - if _, err := queries.GuestForPublicURL(ctx, request.PublicURLID); err == nil { + if guestID, err := queries.GuestForPublicURL(ctx, request.PublicURLID); err == nil { reserved, err := queries.BeginGuestPublishRun(ctx, controlstatedb.BeginGuestPublishRunParams{ PublishRunID: text(publishRunID), StartedAt: timestamptz(now), PublicURLID: request.PublicURLID, }) @@ -367,12 +367,24 @@ func (d *Database) createPublishRun( return PublishRunSetup{}, fmt.Errorf("controlstate: reserve guest publish run: %w", err) } if reserved != 1 { + guest, err := queries.GetGuestTrialByID(ctx, guestID) + if err != nil { + return PublishRunSetup{}, err + } + if guest.ActivePublishRunID.Valid || guest.ServingUntil.Valid && guest.ServingUntil.Time.After(now) { + return PublishRunSetup{}, ErrPublishRunOpen + } return PublishRunSetup{}, ErrGuestTrialSpent } } else if !errors.Is(err, pgx.ErrNoRows) { return PublishRunSetup{}, err } + session, err = boundGuestServingAuthority(ctx, queries, session) + if err != nil { + return PublishRunSetup{}, err + } + connections := controlstatedb.InsertPublishRunConnectionsParams{ PublishRunID: publishRunID, PublicURLID: request.PublicURLID, PublishRunNumber: session.PublishRunNumber, PublisherConnectionCredentialExpiresAt: timestamptz(now.Add(connectionCredentialDuration)), AssignedAt: timestamptz(now), diff --git a/internal/control/state/publisher_enrollment.go b/internal/control/state/publisher_enrollment.go index 07cc0d2f2..b58d4666e 100644 --- a/internal/control/state/publisher_enrollment.go +++ b/internal/control/state/publisher_enrollment.go @@ -204,7 +204,7 @@ func (d *Database) EnrollGuestPublisher(ctx context.Context, guestID, urlID, sou return result, err } match, err := d.GuestSourceMatches(GuestTrial{ID: trial.ID, SourceIPDigest: trial.SourceIpDigest.String, SourceIPKeyID: trial.SourceIpKeyID.String}, sourceIP) - if err != nil || !match || !trial.ExpiresAt.Time.After(now) || trial.EndedAt.Valid || trial.UsedReadyNs >= int64(GuestReadyAllowance) || trial.UsedBytes >= GuestByteAllowance { + if err != nil || !match || !trial.ExpiresAt.Time.After(now) || trial.EndedAt.Valid || trial.UsedReadyNs >= int64(GuestReadyAllowance) { return result, ErrPublisherIdentity } s := PublisherScope{TeamID: trial.TeamID, Kind: "saved_public_url", PublicURLID: urlID} diff --git a/internal/control/state/publisher_identities_integration_test.go b/internal/control/state/publisher_identities_integration_test.go index baf57bb30..23fc5049f 100644 --- a/internal/control/state/publisher_identities_integration_test.go +++ b/internal/control/state/publisher_identities_integration_test.go @@ -89,7 +89,7 @@ func TestIntegrationPublisherFreshBaseline(t *testing.T) { if err := d.pool.QueryRow(t.Context(), `SELECT MAX(version_id) FILTER (WHERE is_applied) FROM control.goose_db_version`).Scan(&actualVersion); err != nil || actualVersion != 1 { t.Fatal("fresh database did not initialize schema 1", actualVersion, err) } - if err := d.pool.QueryRow(t.Context(), `SELECT baseline_id FROM control.schema_identity WHERE id=1`).Scan(&actualBaseline); err != nil || actualBaseline != "tnl-control-fresh-v1-visitors" { + if err := d.pool.QueryRow(t.Context(), `SELECT baseline_id FROM control.schema_identity WHERE id=1`).Scan(&actualBaseline); err != nil || actualBaseline != baselineID { t.Fatal("fresh baseline identity changed", actualBaseline, err) } // an older database's larger migration counter cannot pass this baseline's @@ -444,7 +444,7 @@ func TestIntegrationGuestPublisherScopeSourceAndExpiry(t *testing.T) { if owns, err := d.PublisherMayStartRun(t.Context(), p, "some-other-demo"); err != nil || owns { t.Fatal("guest can publish an unassigned URL") } - for _, reason := range []string{"expires_at=$2", "used_ready_ns=900000000000", "used_bytes=5242880"} { + for _, reason := range []string{"expires_at=$2", "used_ready_ns=1200000000000"} { query := fmt.Sprintf(`UPDATE control.guest_trials SET %s WHERE id=$1`, reason) args := []any{guest.ID} if strings.Contains(reason, "$2") { @@ -456,7 +456,7 @@ func TestIntegrationGuestPublisherScopeSourceAndExpiry(t *testing.T) { if _, err := d.AuthenticatePublisherAccess(t.Context(), a.Token, "192.0.2.7/32", now.Add(2*time.Second)); !errors.Is(err, ErrPublisherIdentity) { t.Fatalf("guest exhaustion accepted: %s", reason) } - if _, err := d.pool.Exec(t.Context(), `UPDATE control.guest_trials SET expires_at=$2, used_ready_ns=0, used_bytes=0 WHERE id=$1`, guest.ID, now.Add(GuestLifetime)); err != nil { + if _, err := d.pool.Exec(t.Context(), `UPDATE control.guest_trials SET expires_at=$2, used_ready_ns=0 WHERE id=$1`, guest.ID, now.Add(GuestLifetime)); err != nil { t.Fatal(err) } } diff --git a/internal/control/state/publisher_identity_access.go b/internal/control/state/publisher_identity_access.go index e73c90a64..61929d418 100644 --- a/internal/control/state/publisher_identity_access.go +++ b/internal/control/state/publisher_identity_access.go @@ -50,7 +50,7 @@ func (d *Database) livePublisher(ctx context.Context, q *controlstatedb.Queries, if err != nil { return PublisherIdentity{}, g, publisherLookupError(err, ErrPublisherIdentity) } - if trial.TeamID != i.TeamID || g.Kind != "saved_public_url" || !trial.ExpiresAt.Time.After(now) || trial.EndedAt.Valid || trial.UsedReadyNs >= int64(GuestReadyAllowance) || trial.UsedBytes >= GuestByteAllowance || trial.ActiveReadyAt.Valid && time.Duration(trial.UsedReadyNs)+max(time.Duration(0), now.Sub(trial.ActiveReadyAt.Time)) >= GuestReadyAllowance { + if trial.TeamID != i.TeamID || g.Kind != "saved_public_url" || !trial.ExpiresAt.Time.After(now) || trial.EndedAt.Valid || trial.UsedReadyNs >= int64(GuestReadyAllowance) || trial.ReadyDeadline.Valid && !trial.ReadyDeadline.Time.After(now) { return PublisherIdentity{}, g, ErrPublisherIdentity } if sourceIP != "" { diff --git a/internal/control/state/queries/guest_trials.sql b/internal/control/state/queries/guest_trials.sql index 9db989c55..a60777881 100644 --- a/internal/control/state/queries/guest_trials.sql +++ b/internal/control/state/queries/guest_trials.sql @@ -133,74 +133,62 @@ WHERE guest.id = ( WHERE public_url_id = sqlc.arg(public_url_id) ) AND guest.active_publish_run_id IS NULL + AND (guest.serving_until IS NULL OR guest.serving_until <= sqlc.arg(started_at)) AND guest.expires_at > sqlc.arg(started_at) - AND guest.used_ready_ns < 900000000000 - AND guest.used_bytes < 5242880; + AND guest.used_ready_ns < 1200000000000; -- name: MarkGuestRunReady :execrows UPDATE control.guest_trials SET active_ready_at = sqlc.arg(ready_at), updated_at = sqlc.arg(ready_at), + ready_deadline = LEAST(expires_at, sqlc.arg(ready_at)::timestamptz + + ((1200000000000 - used_ready_ns)::double precision / 1000000000) * interval '1 second'), first_ready_at = COALESCE(first_ready_at, sqlc.arg(ready_at)) WHERE active_publish_run_id = sqlc.arg(publish_run_id) AND active_ready_at IS NULL - AND expires_at > sqlc.arg(ready_at); + AND expires_at > sqlc.arg(ready_at) + AND used_ready_ns < 1200000000000; + +-- name: UpdateGuestServingAuthority :one +-- retain the latest issued serving horizon, including after the run closes. +-- a replacement cannot reuse this time while an offline owner may still serve. +UPDATE control.guest_trials AS guest +SET serving_until = CASE WHEN guest.active_ready_at IS NULL THEN guest.serving_until + ELSE GREATEST(guest.serving_until, LEAST(guest.expires_at, guest.ready_deadline, run.publisher_expires_at)) END +FROM control.publish_runs AS run +WHERE run.id = sqlc.arg(publish_run_id) AND guest.active_publish_run_id = run.id +RETURNING LEAST(guest.expires_at, COALESCE(guest.ready_deadline, guest.expires_at))::timestamptz AS deadline; + +-- name: LimitGuestPublishRunExpiry :one +UPDATE control.publish_runs +SET publisher_expires_at = LEAST(publisher_expires_at, sqlc.arg(deadline)::timestamptz) +WHERE id = sqlc.arg(publish_run_id) +RETURNING *; -- name: FinishGuestPublishRun :execrows UPDATE control.guest_trials -SET used_ready_ns = used_ready_ns + CASE +SET used_ready_ns = LEAST(1200000000000, used_ready_ns + CASE WHEN active_ready_at IS NULL THEN 0 - ELSE GREATEST(0, (EXTRACT(EPOCH FROM (sqlc.arg(closed_at)::timestamptz - active_ready_at)) * 1000000000)::bigint) - END, + ELSE GREATEST(0, (EXTRACT(EPOCH FROM (LEAST(ready_deadline, + COALESCE(serving_until, sqlc.arg(closed_at)::timestamptz)) - active_ready_at)) * 1000000000)::bigint) + END), end_reason = COALESCE(end_reason, CASE WHEN sqlc.arg(close_reason)::text = 'guest_expired' OR expires_at <= sqlc.arg(closed_at)::timestamptz THEN 'expired' - WHEN sqlc.arg(close_reason)::text = 'guest_transfer_limit' OR used_bytes >= 5242880 THEN 'transfer_limit' - WHEN sqlc.arg(close_reason)::text = 'guest_ready_limit' OR used_ready_ns + CASE - WHEN active_ready_at IS NULL THEN 0 - ELSE GREATEST(0, (EXTRACT(EPOCH FROM (sqlc.arg(closed_at)::timestamptz - active_ready_at)) * 1000000000)::bigint) - END >= 900000000000 THEN 'ready_limit' + WHEN ready_deadline <= COALESCE(serving_until, sqlc.arg(closed_at)::timestamptz) THEN 'ready_limit' END), ended_at = COALESCE(ended_at, CASE WHEN sqlc.arg(close_reason)::text = 'guest_expired' OR expires_at <= sqlc.arg(closed_at)::timestamptz THEN expires_at - WHEN sqlc.arg(close_reason)::text IN ('guest_transfer_limit', 'guest_ready_limit') OR used_bytes >= 5242880 - OR used_ready_ns + CASE WHEN active_ready_at IS NULL THEN 0 ELSE - GREATEST(0, (EXTRACT(EPOCH FROM (sqlc.arg(closed_at)::timestamptz - active_ready_at)) * 1000000000)::bigint) - END >= 900000000000 THEN sqlc.arg(closed_at)::timestamptz + WHEN ready_deadline <= COALESCE(serving_until, sqlc.arg(closed_at)::timestamptz) THEN ready_deadline END), active_ready_at = NULL, + ready_deadline = NULL, active_publish_run_id = NULL, updated_at = sqlc.arg(closed_at) WHERE active_publish_run_id = sqlc.arg(publish_run_id); --- name: UpdateGuestTransferredBytes :execrows -WITH observed AS ( - SELECT routes.guest_id, COALESCE(SUM(buckets.ingress_bytes + buckets.egress_bytes), 0)::bigint AS bytes - FROM control.guest_public_urls AS routes - LEFT JOIN control.public_url_usage_buckets AS buckets ON buckets.public_url_id = routes.public_url_id - WHERE routes.guest_id = ( - SELECT owned.guest_id FROM control.guest_public_urls AS owned WHERE owned.public_url_id = sqlc.arg(guest_route_id) - ) - GROUP BY routes.guest_id -) -UPDATE control.guest_trials AS guest -SET used_bytes = GREATEST(guest.used_bytes, observed.bytes), - end_reason = COALESCE(guest.end_reason, CASE - WHEN guest.expires_at <= sqlc.arg(observed_at)::timestamptz THEN 'expired' - WHEN GREATEST(guest.used_bytes, observed.bytes) >= 5242880 THEN 'transfer_limit' - END), - ended_at = COALESCE(guest.ended_at, CASE - WHEN guest.expires_at <= sqlc.arg(observed_at)::timestamptz THEN guest.expires_at - WHEN GREATEST(guest.used_bytes, observed.bytes) >= 5242880 THEN sqlc.arg(observed_at)::timestamptz - END), - updated_at = GREATEST(guest.updated_at, sqlc.arg(observed_at)) -FROM observed WHERE guest.id = observed.guest_id; - -- name: GuestRunAllowanceSpent :one SELECT CASE WHEN expires_at <= sqlc.arg(now)::timestamptz THEN 'guest_expired' - WHEN used_bytes >= 5242880 THEN 'guest_transfer_limit' - WHEN used_ready_ns + CASE WHEN active_ready_at IS NULL THEN 0 - ELSE GREATEST(0, (EXTRACT(EPOCH FROM (sqlc.arg(now)::timestamptz - active_ready_at)) * 1000000000)::bigint) - END >= 900000000000 THEN 'guest_ready_limit' + WHEN used_ready_ns >= 1200000000000 OR ready_deadline <= sqlc.arg(now)::timestamptz THEN 'guest_ready_limit' ELSE '' END::text AS reason FROM control.guest_trials @@ -212,6 +200,5 @@ SELECT count(*) FILTER (WHERE first_demo_allocated_at >= sqlc.arg(since))::bigint AS allocated, count(*) FILTER (WHERE first_ready_at >= sqlc.arg(since))::bigint AS ready, count(*) FILTER (WHERE end_reason = 'expired' AND ended_at >= sqlc.arg(since))::bigint AS expired, - count(*) FILTER (WHERE end_reason = 'ready_limit' AND ended_at >= sqlc.arg(since))::bigint AS ready_limit, - count(*) FILTER (WHERE end_reason = 'transfer_limit' AND ended_at >= sqlc.arg(since))::bigint AS transfer_limit + count(*) FILTER (WHERE end_reason = 'ready_limit' AND ended_at >= sqlc.arg(since))::bigint AS ready_limit FROM control.guest_trials; diff --git a/internal/control/state/queries/publish_runs.sql b/internal/control/state/queries/publish_runs.sql index 790f0f078..601eb2968 100644 --- a/internal/control/state/queries/publish_runs.sql +++ b/internal/control/state/queries/publish_runs.sql @@ -36,10 +36,8 @@ WHERE sessions.closed_at IS NULL JOIN control.guest_trials AS guest ON guest.id = guest_url.guest_id WHERE guest_url.public_url_id = routes.id AND ( guest.expires_at <= sqlc.arg(now) - OR guest.used_bytes >= 5242880 - OR guest.used_ready_ns >= 900000000000 - OR guest.active_ready_at IS NOT NULL AND guest.used_ready_ns + - GREATEST(0, (EXTRACT(EPOCH FROM (sqlc.arg(now)::timestamptz - guest.active_ready_at)) * 1000000000)::bigint) >= 900000000000 + OR guest.used_ready_ns >= 1200000000000 + OR guest.ready_deadline <= sqlc.arg(now) ) )) ORDER BY sessions.publisher_expires_at, sessions.id diff --git a/internal/control/state/traffic.go b/internal/control/state/traffic.go index 4c4e7f8fb..e465ea626 100644 --- a/internal/control/state/traffic.go +++ b/internal/control/state/traffic.go @@ -43,6 +43,11 @@ func publishRunTrafficDigest(base [32]byte, selection *trafficlimits.Selection) } func resolvePublishRunTraffic(request PublishRunRequest) ([]byte, error) { + if request.GuestID != "" { + // the trial's limits are server-owned and cannot be increased by a + // publisher selection or wider operator defaults. + return json.Marshal(trafficlimits.Guest()) + } defaults, ceiling := request.TrafficDefaults, request.TrafficCeiling if defaults == (trafficlimits.Limits{}) { defaults = trafficlimits.Default() diff --git a/internal/control/state/traffic_test.go b/internal/control/state/traffic_test.go index a582599b4..24c6ec118 100644 --- a/internal/control/state/traffic_test.go +++ b/internal/control/state/traffic_test.go @@ -64,3 +64,17 @@ func TestPublishRunTrafficResolutionAndStoredBounds(t *testing.T) { } } } + +func TestGuestTrafficCannotRaiseServerLimits(t *testing.T) { + connections := trafficlimits.Ceiling().Connections + payload, err := resolvePublishRunTraffic(PublishRunRequest{GuestID: "guest", + TrafficDefaults: trafficlimits.Ceiling(), TrafficCeiling: trafficlimits.Ceiling(), + TrafficSelection: &trafficlimits.Selection{Connections: &connections}}) + if err != nil { + t.Fatal(err) + } + limits, err := decodePublishRunTraffic(payload) + if err != nil || limits != trafficlimits.Guest() || limits.TotalUploadBytes != 0 || limits.TotalDownloadBytes != 0 { + t.Fatalf("guest limits = %+v, %v", limits, err) + } +} diff --git a/internal/control/state/usage.go b/internal/control/state/usage.go index f7bb20d9c..227ef9b1d 100644 --- a/internal/control/state/usage.go +++ b/internal/control/state/usage.go @@ -128,11 +128,6 @@ func (d *Database) ReportIngressUsage( if err := applyIngressUsageReport(ctx, queries, identity, report, receivedAt, latest); err != nil { return err } - if _, err := queries.UpdateGuestTransferredBytes(ctx, controlstatedb.UpdateGuestTransferredBytesParams{ - GuestRouteID: report.PublicURLID, ObservedAt: timestamptz(receivedAt), - }); err != nil { - return fmt.Errorf("controlstate: update guest transfer usage: %w", err) - } } var observedThrough pgtype.Timestamptz if batch.ObservedThrough != nil { diff --git a/internal/control/state/visitor_policy.go b/internal/control/state/visitor_policy.go index 25430b5f9..0b5236511 100644 --- a/internal/control/state/visitor_policy.go +++ b/internal/control/state/visitor_policy.go @@ -168,9 +168,11 @@ func (d *Database) readVisitorPolicy(ctx context.Context, tx pgx.Tx, scope visit if scope.Kind == visitorscope.PublicURL { var guest bool var deadline time.Time - err := tx.QueryRow(ctx, `SELECT control.guest_share_live(id,$2),LEAST(expires_at, - COALESCE(active_ready_at + (GREATEST(0,900000000000-used_ready_ns)::double precision/1000000000)*interval '1 second',expires_at)) - FROM control.guest_trials WHERE team_id=$1`, team, now).Scan(&guest, &deadline) + err := tx.QueryRow(ctx, `SELECT control.guest_share_live(guest.id,$2),LEAST(guest.expires_at, run.publisher_expires_at, + COALESCE(guest.ready_deadline, $2::timestamptz + + ((1200000000000-guest.used_ready_ns)::double precision/1000000000)*interval '1 second')) + FROM control.guest_trials guest LEFT JOIN control.publish_runs run ON run.id=guest.active_publish_run_id + WHERE guest.team_id=$1`, team, now).Scan(&guest, &deadline) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return result, err } diff --git a/internal/control/state/visitor_tokens.go b/internal/control/state/visitor_tokens.go index 8c3e86e41..770e83fce 100644 --- a/internal/control/state/visitor_tokens.go +++ b/internal/control/state/visitor_tokens.go @@ -360,7 +360,7 @@ func (d *Database) visitorPermissionForSource(ctx context.Context, tx pgx.Tx, sc } } else { var deadline time.Time - if err := tx.QueryRow(ctx, `SELECT expires_at FROM control.guest_trials WHERE id=$1`, guestID).Scan(&deadline); err != nil { + if err := tx.QueryRow(ctx, `SELECT LEAST(expires_at, ready_deadline) FROM control.guest_trials WHERE id=$1`, guestID).Scan(&deadline); err != nil { return result, err } until = minTime(until, deadline) diff --git a/internal/daemon/guest_demo_integration_test.go b/internal/daemon/guest_demo_integration_test.go index 6ef5f81ca..8b737930b 100644 --- a/internal/daemon/guest_demo_integration_test.go +++ b/internal/daemon/guest_demo_integration_test.go @@ -1,6 +1,7 @@ package daemon import ( + "bufio" "context" "crypto/ed25519" "crypto/rand" @@ -11,12 +12,16 @@ import ( "encoding/json" "errors" "io" + "net" "net/http" "net/http/cookiejar" "net/http/httptest" "net/netip" + "net/url" + "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -26,6 +31,7 @@ import ( clientstate "github.com/tnldotdev/tnl/internal/client/state" controlpublic "github.com/tnldotdev/tnl/internal/control/api/public" controlstate "github.com/tnldotdev/tnl/internal/control/state" + "github.com/tnldotdev/tnl/internal/policy/trafficlimits" "github.com/tnldotdev/tnl/internal/publisher" "github.com/tnldotdev/tnl/internal/publisher/demo" publisheridentity "github.com/tnldotdev/tnl/internal/publisher/identity" @@ -209,6 +215,15 @@ func TestIntegrationGuestDemoIssuesAndAuthorizesOneRestrictedPublicURL(t *testin } func TestIntegrationGuestDemoPublishesWithoutSignIn(t *testing.T) { + testIntegrationGuestDemo(t, false) +} + +func TestIntegrationGuestDemoDeadlineClosesConnectionsWithoutControl(t *testing.T) { + testIntegrationGuestDemo(t, true) +} + +func testIntegrationGuestDemo(t *testing.T, deadlineProof bool) { + t.Helper() databaseURL, inspect := standaloneTestDatabase(t) publicListener := serviceRuntimeListener(t) publicAddress, relayUDPAddress := publicListener.Addr().String(), unusedUDPAddress(t) @@ -228,13 +243,31 @@ func TestIntegrationGuestDemoPublishesWithoutSignIn(t *testing.T) { } publicControl := unusedTCPAddress(t) startIntegrationControlProxy(t, owner, publicControl, cfg.ControlListen, controlTLS) + var controlOffline atomic.Bool + var rejectedControlCalls atomic.Int64 + withControlOutage := func(client *http.Client) *http.Client { + base := client.Transport + if base == nil { + base = http.DefaultTransport + } + client.Transport = splitACMERoundTripFunc(func(request *http.Request) (*http.Response, error) { + if controlOffline.Load() { + rejectedControlCalls.Add(1) + return nil, clientcontrol.ErrUnavailable + } + return base.RoundTrip(request) + }) + return client + } process := startIntegrationProcessWithOptions(t, cfg, integrationProcessOptions{ dns: dns, listeners: listeners, - acmeHTTPClient: pebble.httpClient, - relayClientTLS: &tls.Config{RootCAs: pebble.roots, MinVersion: tls.VersionTLS13}, owner: owner, + acmeHTTPClient: pebble.httpClient, + serviceHTTPClient: withControlOutage(&http.Client{Timeout: 2 * time.Second}), + relayClientTLS: &tls.Config{RootCAs: pebble.roots, MinVersion: tls.VersionTLS13}, owner: owner, }) waitForProcessReady(t, process) controlHTTP, _ := newIntegrationHTTPSClient(t, ca.roots, publicControl, false, owner) + withControlOutage(controlHTTP) serverURL := "https://" + cfg.ServerHostname() control, err := clientcontrol.New(serverURL, controlHTTP, "") if err != nil { @@ -244,6 +277,11 @@ func TestIntegrationGuestDemoPublishesWithoutSignIn(t *testing.T) { if err != nil { t.Fatalf("guest credential without sign-in: %v", err) } + if deadlineProof { + if _, err := inspect.Exec(`UPDATE control.guest_trials SET used_ready_ns=$2 WHERE id=$1`, issued.GuestId, int64(controlstate.GuestReadyAllowance-8*time.Second)); err != nil { + t.Fatal(err) + } + } clientstate, err := clientstate.Open(t.Context(), filepath.Join(t.TempDir(), "state")) if err != nil { t.Fatal(err) @@ -333,6 +371,14 @@ func TestIntegrationGuestDemoPublishesWithoutSignIn(t *testing.T) { QUICConnector: quic, TCPConnector: tcp, }, func() string { return integrationPublisherDiagnostics(inspect, pebble.logPath) }) ready := waitForPublisherReady(t, handle) + var trafficJSON []byte + if err := inspect.QueryRow(`SELECT traffic_limits FROM control.publish_runs WHERE public_url_id=$1 AND publish_run_number=$2`, ready.PublicURLID, ready.PublishRunNumber).Scan(&trafficJSON); err != nil { + t.Fatal(err) + } + var traffic trafficlimits.Limits + if err := json.Unmarshal(trafficJSON, &traffic); err != nil || traffic != trafficlimits.Guest() { + t.Fatalf("guest traffic limits = %+v, %v", traffic, err) + } var firstReadyAt time.Time if err := inspect.QueryRow(`SELECT first_ready_at FROM control.guest_trials WHERE id = $1`, issued.GuestId).Scan(&firstReadyAt); err != nil || firstReadyAt.IsZero() { t.Fatalf("guest trial did not record its first ready run: %v", err) @@ -392,6 +438,65 @@ func TestIntegrationGuestDemoPublishesWithoutSignIn(t *testing.T) { if err != nil || !strings.Contains(string(html), "/__tnl/feedback/toolbar.") || !strings.Contains(page.Header.Get("Content-Security-Policy"), "style-src 'unsafe-inline'") { t.Fatalf("demo toolbar or original styles missing: %v", err) } + for _, font := range []string{"fira-code-latin-wght-normal.woff2", "fira-code-symbols2-wght-normal.woff2"} { + response, err := visitor.client.Get(ready.PublicURL + "/fonts/" + font) + if err != nil { + t.Fatal(err) + } + bytes, err := io.Copy(io.Discard, response.Body) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK || bytes == 0 { + t.Fatalf("demo font under guest limits = %d, %d bytes, %v", response.StatusCode, bytes, err) + } + } + if deadlineProof { + var deadline time.Time + if err := inspect.QueryRow(`SELECT ready_deadline FROM control.guest_trials WHERE id=$1`, issued.GuestId).Scan(&deadline); err != nil { + t.Fatal(err) + } + held, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", publicAddress, + &tls.Config{RootCAs: pebble.roots, ServerName: hostname, MinVersion: tls.VersionTLS13}) + if err != nil { + t.Fatal(err) + } + defer held.Close() + if err := held.SetDeadline(deadline.Add(time.Second)); err != nil { + t.Fatal(err) + } + origin, err := url.Parse(ready.PublicURL) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, ready.PublicURL+"/state", nil) + if err != nil { + t.Fatal(err) + } + for _, cookie := range jar.Cookies(origin) { + request.AddCookie(cookie) + } + if err := request.Write(held); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(held) + response, err := http.ReadResponse(reader, request) + if err != nil { + t.Fatal(err) + } + _, readErr := io.Copy(io.Discard, response.Body) + response.Body.Close() + if readErr != nil || response.StatusCode != http.StatusOK || !time.Now().Before(deadline) { + t.Fatalf("held guest connection before deadline = %d, %v", response.StatusCode, readErr) + } + controlOffline.Store(true) + defer controlOffline.Store(false) + if _, err := reader.ReadByte(); err == nil || errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("guest connection survived its offline ready deadline: %v", err) + } + if time.Now().After(deadline.Add(time.Second)) || rejectedControlCalls.Load() == 0 { + t.Fatal("guest closure did not occur within the hard deadline during a control outage") + } + return + } reportRequest, err := http.NewRequest(http.MethodPost, ready.PublicURL+"/__tnl/feedback", strings.NewReader(`{"schema_version":1,"text":"Demo suggestion","display_name":"Sam","page_path":"/","evidence":{"schema_version":1,"actions":[],"failed_requests":[]}}`)) if err != nil { t.Fatal(err) diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 75d38c89b..f53d56ce2 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -206,7 +206,7 @@ func (metrics *Metrics) initInstruments(role Role) { if role == RoleControl || role == RoleStandalone { metrics.routingHistorySkipped.Add(0) metrics.gaugeFunc("tnl_control_routing_history_retained_after_revision", "Highest committed routing-history retention floor observed by this process.", func() float64 { return float64(metrics.routingHistoryFloor.Load()) }) - for _, stage := range []string{"issued", "allocated", "ready", "expired", "ready_limit", "transfer_limit"} { + for _, stage := range []string{"issued", "allocated", "ready", "expired", "ready_limit"} { metrics.guestTrialStats.With(eventDimension(stage)).Set(0) } for _, surface := range []string{"control", "authority", "private_ingress", "private_relay"} { @@ -287,10 +287,10 @@ func (m *Metrics) RegisterControlCertificate(source func() time.Time) { } // SetGuestTrialStats observes committed service-wide counts without identity labels. -func (m *Metrics) SetGuestTrialStats(issued, allocated, ready, expired, readyLimit, transferLimit int64) { +func (m *Metrics) SetGuestTrialStats(issued, allocated, ready, expired, readyLimit int64) { for stage, count := range map[string]int64{ "issued": issued, "allocated": allocated, "ready": ready, - "expired": expired, "ready_limit": readyLimit, "transfer_limit": transferLimit, + "expired": expired, "ready_limit": readyLimit, } { if count >= 0 { m.guestTrialStats.With(eventDimension(stage)).Set(float64(count)) diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go index e5a386f6b..9f4369527 100644 --- a/internal/observability/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -75,7 +75,7 @@ func TestReadinessAndRelayMetricsKeepFixedLabels(t *testing.T) { control.ObserveCertificateMilestone("public_url", "challenge_token_secret", time.Second) control.ObserveCertificateMilestone("public_url", "ready", 30*time.Second) control.ObserveCertificateMilestone("public_url", "cleanup", 45*time.Second) - control.SetGuestTrialStats(2, 1, 1, 0, 0, 1) + control.SetGuestTrialStats(2, 1, 1, 0, 1) ingress := New("ingress") ingress.ObserveRelayAttempt("relay_address_secret", "error_secret") ingress.IncInspectionFailure("challenge_token_secret") diff --git a/internal/observability/values.go b/internal/observability/values.go index e8390cf47..923b94eef 100644 --- a/internal/observability/values.go +++ b/internal/observability/values.go @@ -171,7 +171,6 @@ const ( EventReady EventExpired EventReadyLimit - EventTransferLimit EventCleanup EventOpened EventClosed @@ -184,7 +183,7 @@ const ( EventPartialFailed ) -var events = []string{"", "queued", "delivered", "retry", "dropped", "accepted", "duplicate", "connected", "expected_close", "unexpected_close", "operational_failure", "incomplete", "finalized", "retried", "rejected", "issued", "allocated", "ready", "expired", "ready_limit", "transfer_limit", "cleanup", "opened", "closed", "failed", "bound", "registered", "publication_ready", "busy", "completed", "partial_failed"} +var events = []string{"", "queued", "delivered", "retry", "dropped", "accepted", "duplicate", "connected", "expected_close", "unexpected_close", "operational_failure", "incomplete", "finalized", "retried", "rejected", "issued", "allocated", "ready", "expired", "ready_limit", "cleanup", "opened", "closed", "failed", "bound", "registered", "publication_ready", "busy", "completed", "partial_failed"} func (v LifecycleEvent) String() string { return enumString(events, int(v)) } func ParseEvent(value string) (LifecycleEvent, bool) { diff --git a/internal/policy/trafficlimits/limits.go b/internal/policy/trafficlimits/limits.go index 59c24edb4..dfb742c2a 100644 --- a/internal/policy/trafficlimits/limits.go +++ b/internal/policy/trafficlimits/limits.go @@ -32,6 +32,13 @@ func Default() Limits { UploadBytes: 8 << 20, DownloadBytes: 16 << 20} } +// Guest bounds a demo's concurrent connections and byte rates without a +// cumulative transfer allowance. idle ready time consumes the same time budget. +func Guest() Limits { + return Limits{Connections: 8, Rate: 4, RatePer: time.Second, Burst: 8, + UploadBytes: 256 << 10, DownloadBytes: 1 << 20} +} + func Ceiling() Limits { return Limits{Connections: 128, Rate: 100, RatePer: time.Second, Burst: 200, UploadBytes: 64 << 20, DownloadBytes: 128 << 20} diff --git a/readme.md b/readme.md index a5cf2c295..0467844a1 100644 --- a/readme.md +++ b/readme.md @@ -40,7 +40,7 @@ match the URL and count with the terminal running `tnl`. The browser shows the round-trip time and when your computer sent the pong. Guests can run one demo at a time. The link admits this demo without signing in; tnl prints it once instead of admitting every visitor from your IP. A guest credential lasts 72 hours -and allows 15 minutes ready or about 5 MiB of traffic across runs. Ctrl+C +and allows 20 cumulative minutes ready across runs, including idle time. Ctrl+C stops the demo and removes its public URL. Run `tnl auth login` to publish your own app. CLI telemetry is on by default, including for the demo. Run `tnl telemetry off` to disable it. From 16f23ea82d0817551a49e8aa70d93e3594672224 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Mon, 26 Oct 2026 10:49:51 -0700 Subject: [PATCH 2/3] test: sign certificate deadline reservation evidence --- .../state/connector_visitor_certificate_integration_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/control/state/connector_visitor_certificate_integration_test.go b/internal/control/state/connector_visitor_certificate_integration_test.go index 31befeec4..f7932e535 100644 --- a/internal/control/state/connector_visitor_certificate_integration_test.go +++ b/internal/control/state/connector_visitor_certificate_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/tnldotdev/tnl/internal/policy/visitorlease" "github.com/tnldotdev/tnl/internal/policy/visitorscope" + "github.com/tnldotdev/tnl/internal/security/visitortoken" "github.com/tnldotdev/tnl/pkg/protocol/tunnelv1" ) @@ -130,6 +131,9 @@ func TestIntegrationPrivateVisitorCertificateDeadline(t *testing.T) { t.Fatal(err) } header := tunnelv1.StreamReservation{ID: "sres_aaaaaaaaaaaaaaaaaaaaaa", AllocationID: initial.ID, Sequence: 1, ResourceKind: "service", ResourceID: run.ServiceID, RunID: run.RunID, RunNumber: uint64(run.RunNumber), VisitorConnectionID: "vc_aaaaaaaaaaaaaaaaaaaaaa", IngressID: ingress.IngressID, IngressRunID: ingress.IngressRunID, IngressLeaseRevision: ingress.IngressLeaseRevision} + if err := visitortoken.SignReservation(initial.ProofKey[:], &header, source); err != nil { + t.Fatal(err) + } if err := local.ValidateReservation(header); err != nil { t.Fatal("current prepared reservation denied", err) } From ce46b89cd301b5ecaed4711203ca19365747955b Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Mon, 26 Oct 2026 15:01:42 -0700 Subject: [PATCH 3/3] test: prepare signed authority in integration fixtures --- .../control/api/public/alias_browser_integration_test.go | 6 ++++++ internal/control/state/direct_visitors_integration_test.go | 3 +++ internal/control/state/visitor_tokens_integration_test.go | 1 + 3 files changed, 10 insertions(+) diff --git a/internal/control/api/public/alias_browser_integration_test.go b/internal/control/api/public/alias_browser_integration_test.go index 7ba654164..3fc820656 100644 --- a/internal/control/api/public/alias_browser_integration_test.go +++ b/internal/control/api/public/alias_browser_integration_test.go @@ -131,6 +131,9 @@ func TestIntegrationAliasBrowserHTTPAuthorizationAndLogout(t *testing.T) { func TestIntegrationShareReviewerSignInRetainsShareVisitorPermission(t *testing.T) { f := newCompanyHTTPFixture(t, "share_reviewer_browser") + if err := f.d.PrepareVisitorSigningKeys(t.Context(), f.server.URL, f.now); err != nil { + t.Fatal(err) + } owner, reviewer := f.basic("share-owner"), f.basic("outside-reviewer") m := owner.Identity.Memberships[0] p := f.principal(owner) @@ -191,6 +194,9 @@ func TestIntegrationShareReviewerSignInRetainsShareVisitorPermission(t *testing. if err != nil { t.Fatal(err) } + if _, err := f.d.IssueVisitorToken(ctx, f.server.URL, visitorscope.Scope{Kind: visitorscope.PublicURL, ID: u.ID}, controlstate.ShareSessionTokenSource{ShareID: share.ID, Secret: shareSession.CookieSecret}, time.Now()); err != nil { + t.Fatal("redeemed share session did not prepare visitor permission", err) + } handoff, err := f.d.IssueBrowserHandoff(ctx, controlstate.BrowserLoginAttempt{PreviewID: preview.ID, PublicURLID: u.ID, ReturnPath: "/"}, controlstate.BrowserAccessSession{ IdentityID: reviewer.Identity.Identity.Id, DisplayName: "Outside reviewer", AccessToken: reviewer.AccessToken, RefreshToken: reviewer.RefreshToken, AccessExpiresAt: f.now.Add(30 * time.Minute), ExpiresAt: f.now.Add(time.Hour)}, f.now) diff --git a/internal/control/state/direct_visitors_integration_test.go b/internal/control/state/direct_visitors_integration_test.go index e933c6a95..4317cb652 100644 --- a/internal/control/state/direct_visitors_integration_test.go +++ b/internal/control/state/direct_visitors_integration_test.go @@ -162,6 +162,9 @@ func testDirectManagedAdmission(t *testing.T, d *Database, request ApplyProjectC t.Fatal(err) } source := netip.MustParseAddr("192.0.2.44") + if err := visitortoken.SignReservation(allocation.ProofKey[:], &reservation, source); err != nil { + t.Fatal(err) + } guard, err := view.BindReservationFrom(reservation, source, func() {}) if err != nil { t.Fatal("exact ingress allocation did not reserve local capacity", err) diff --git a/internal/control/state/visitor_tokens_integration_test.go b/internal/control/state/visitor_tokens_integration_test.go index c7999aa8f..1fdb5f5e8 100644 --- a/internal/control/state/visitor_tokens_integration_test.go +++ b/internal/control/state/visitor_tokens_integration_test.go @@ -95,6 +95,7 @@ func TestIntegrationConcurrentVisitorTokenIssuanceRetriesSerialization(t *testin func TestIntegrationConcurrentVisitorGrantAndCertificatePreparation(t *testing.T) { d, now := newControlStateIntegrationDatabase(t, "visitor_grant_certificate_concurrent") + prepareVisitorTokenKeys(t, d, now) request := oidcRouteRequest(t, d, now) session := newOIDCSession(t, d, now) principal := ControlPrincipal{IdentityID: session.Identity.Identity.ID, SessionID: session.SessionID}