nodesecrets

package
v4.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package nodesecrets is the one writer of the node credential split (docs/architecture/node-ops-service.md, section 4).

The split moves node credentials and protocol secrets out of the legacy tables (v2_node, v2_authorized_key, v2_forward_node, v2_forward_clean_agent, v2_node_protocol, v2_wireguard_peer) into the protected kernel tables v4_kernel_node_credential and v4_kernel_protocol_secret. Existing tables are never altered.

Phase P1 (dual_write): every kernel writer of a moved column calls Sync in the transaction of its legacy write, and Sync derives the new rows from the legacy rows it just wrote, so the two forms cannot drift. Backfill copies the rows written before P1 and Verify compares the digests of both forms.

Phase P2 (dual_read): every kernel reader of a moved column reads through this package (read.go), which applies each table's phase. SetPhase moves a table's readers to dual_read once a recent verification matched, and back to dual_write. Writers dual-write in both phases.

Secret values are never logged, printed or put in an error.

Index

Constants

View Source
const (
	// PinUnconfirmed: the row's address differs from the pinned endpoint.
	PinUnconfirmed = "unconfirmed"
	// PinUnpinned: the token has no pin yet (no credential row; the
	// backfill has not run), so it was presented as before the split.
	PinUnpinned = "unpinned"
)

Pin reasons, for the counter.

View Source
const (
	// FallbackMissing: the new table holds no current row for the secret.
	FallbackMissing = "missing"
	// FallbackMismatch: the new table's row differs from the legacy column.
	FallbackMismatch = "mismatch"
	// FallbackError: the new table could not be read.
	FallbackError = "error"
)

Fallback reasons: why a dual_read reader used the legacy column.

View Source
const (
	TableNode          = "v2_node"
	TableAuthorizedKey = "v2_authorized_key"
	TableForwardNode   = "v2_forward_node"
	TableCleanAgent    = "v2_forward_clean_agent"
	TableNodeProtocol  = "v2_node_protocol"
	TableWireGuardPeer = "v2_wireguard_peer"
)

The legacy tables the split moves secrets out of.

View Source
const (
	PhaseLegacy    = "legacy"
	PhaseDualWrite = "dual_write"
	PhaseDualRead  = "dual_read"
	PhaseFinalized = "finalized"
)

The phases of a table (section 4.3).

View Source
const (
	SubjectProxy           = "proxy"
	SubjectForward         = "forward"
	SubjectCleanAgent      = "clean_agent"
	SubjectRegistrationKey = "registration_key"
	SubjectAgentEnrollment = "agent_enrollment"
)

Subject kinds of v4_kernel_node_credential.

View Source
const (
	KindNodeAPIKey       = "node_api_key"       // #nosec G101 -- this names a credential kind, not a hardcoded credential.
	KindNodeSharedSecret = "node_shared_secret" // #nosec G101 -- this names a credential kind, not a hardcoded credential.
	KindRegistrationKey  = "registration_key"
	KindForwardNodeToken = "forward_node_token"
	KindCleanAgentToken  = "clean_agent_token"
)

Credential kinds.

View Source
const (
	StatusActive  = "active"
	StatusRetired = "retired"
	StatusRevoked = "revoked"
)

Credential statuses.

View Source
const (
	SourceBackfill  = "backfill"
	SourceDualWrite = "dual_write"
	SourceIssued    = "issued"
)

Sources: who wrote a credential's current version.

View Source
const (
	ScopeNodeProtocol  = "node_protocol"
	ScopeNodeRawConfig = "node_raw_config"
	ScopeWireGuardPeer = "wireguard_peer"
)

Scopes of v4_kernel_protocol_secret.

View Source
const DefaultBatchSize = 500

DefaultBatchSize is how many legacy rows one backfill transaction copies.

View Source
const DefaultSamples = 20

DefaultSamples is how many mismatches a verification names per table.

View Source
const PhaseCacheTTL = 5 * time.Second

PhaseCacheTTL bounds how long a process keeps the phases it read. A phase change made by the node-secrets command, in another process, reaches every Control process within it.

Placeholder stands for a node secret in administrators' answers (service.NodeSecretPlaceholder). A finalized JSON column keeps it at every secret position.

View Source
const VerifyMaxAge = time.Hour

VerifyMaxAge is how recent a table's last verification must be for its readers to move to dual_read: the verification that matched must be the latest one, with no mismatch, and have run within this hour. Dual-write keeps both forms equal after it, so the gate only has to rule out a verification that is stale or was followed by a failing one.

Variables

View Source
var ErrEndpointUnconfirmed = errors.New("the forward node's address is not the one its token is pinned to; an administrator's forward node update confirms it")

ErrEndpointUnconfirmed is a forward node whose address no longer matches the endpoint its token is pinned to: the kernel does not present the token there until an administrator's own forward node update confirms the address (ENDPOINT_UNCONFIRMED). Its text names no address and no value.

View Source
var ErrFinalizeRefused = errors.New("nodesecrets: finalize refused")

ErrFinalizeRefused is returned by Finalize when a table may not be finalized; nothing is changed.

View Source
var ErrMismatch = errors.New("nodesecrets: the old and new forms of the node secrets differ")

ErrMismatch is returned by Verify when a table's forms differ.

View Source
var ErrPhaseRefused = errors.New("nodesecrets: phase change refused")

ErrPhaseRefused is returned by SetPhase when a table may not move to the requested phase. Nothing is changed.

View Source
var ErrTombstoneCollision = errors.New("nodesecrets: a tombstone collides with another row's value on a unique column")

ErrTombstoneCollision is returned when a tombstone would collide, on a unique legacy column, with a value another row already holds: a row that holds "!moved:<id>" of another row. Nothing of the batch is written. Its text names tables, columns and row ids, never a value.

View Source
var ErrUnsplitRefused = errors.New("nodesecrets: unsplit refused")

ErrUnsplitRefused is returned by Unsplit when a table may not be unsplit; nothing is changed.

Functions

func AdoptionAllowed

func AdoptionAllowed(db *gorm.DB, table string) error

AdoptionAllowed reports whether a package may adopt table in place (kernel.storage.adopt:<table>, section 4.6): only a table whose split is finalized, finalized_at recorded, may be. A table outside the split is not this rule's: it answers nil. The phase is read uncached.

func CleanAgentByToken

func CleanAgentByToken(db *gorm.DB, token string) (*model.ForwardCleanAgent, error)

CleanAgentByToken loads the clean agent whose token token is: clean agent registration. token is trimmed by the caller. A revoked agent is loaded; revocation stays the caller's check, on the row. It answers gorm.ErrRecordNotFound when no agent has the token, and for an unusable token.

func CleanAgentWithToken

func CleanAgentWithToken(db *gorm.DB, agentID uint, token string) (*model.ForwardCleanAgent, error)

CleanAgentWithToken loads clean agent agentID when token is its token: the heartbeat and report calls. It answers gorm.ErrRecordNotFound otherwise, and for an unusable token.

func EnsureSchema

func EnsureSchema(db *gorm.DB) error

EnsureSchema creates the split tables and gives every split table its state row, in phase dual_write: the release that ships the writer starts P1. It never changes the phase of an existing row, and never alters a legacy table.

func FallbackCount

func FallbackCount(table, kind, reason string) uint64

FallbackCount answers how many reads of table's secrets of kind fell back to the legacy column in this process; an empty reason counts every reason.

func FinalizedTables

func FinalizedTables(db *gorm.DB) ([]string, error)

FinalizedTables answers the split tables whose finalize completed (finalized, with finalized_at recorded), read uncached.

func ForgetLogged

func ForgetLogged()

ForgetLogged forgets which subjects this process has logged, so the next fallback or finding of each is logged again. Control never calls it; tests that reuse row ids across databases do.

func ForgetPhases

func ForgetPhases(db *gorm.DB)

ForgetPhases drops the phases this process cached for db, so the next read loads them again: for a test, or a tool, that changed the split's state table directly.

func ForwardNodeEndpoint

func ForwardNodeEndpoint(node *model.ForwardNode) string

ForwardNodeEndpoint is the address a forward node's token is pinned to: its management API, host:api_port. A node without a host or an API port has no endpoint, so its token is pinned to nothing and never presented.

func ForwardNodeToken

func ForwardNodeToken(db *gorm.DB, node *model.ForwardNode) string

ForwardNodeToken answers the API token of the forward node node, where the kernel presents it: the gost API and the NodeX payloads.

func ForwardNodeTokenAbsent

func ForwardNodeTokenAbsent(db *gorm.DB) (string, []any)

ForwardNodeTokenAbsent is the negation of ForwardNodeTokenPresent.

func ForwardNodeTokenAt

func ForwardNodeTokenAt(db *gorm.DB, node *model.ForwardNode) (string, error)

ForwardNodeTokenAt answers the API token of the forward node node for a call to the node's management API at the node's current address, or ErrEndpointUnconfirmed when the kernel must not present it there.

The rule: the token is presented only when the row's current host:api_port is the endpoint its credential row is pinned to, and both are set. A node whose API port was removed, or added after the pin, or whose host changed, is unconfirmed. The pin moves only through the kernel's own forward node writers, which an administrator's PUT /admin/forward/nodes/:id drives (Sync re-derives it from the row).

Before the backfill a node has no credential row and so no pin, and a database without the split tables has none: the token is presented as before the split, and the read is counted (anixops_node_secrets_pin_total{reason="unpinned"}). The rule applies in every phase: the pin is the kernel's record, not a secret.

func ForwardNodeTokenMatches

func ForwardNodeTokenMatches(db *gorm.DB, node *model.ForwardNode, token string) bool

ForwardNodeTokenMatches reports whether token is the API token of the forward node node: its agent's WebSocket and REST calls. An unusable token never matches.

func ForwardNodeTokenPresent

func ForwardNodeTokenPresent(db *gorm.DB) (string, []any)

ForwardNodeTokenPresent answers a condition, with its arguments, for a query on v2_forward_node that holds for the forward nodes that have a token: the forward node inventory and the status command ask whether a node has one, not what it is.

  • In phase dual_read and finalized, a node has a token when it has a live credential row (forward_node_token, active, with a value): the legacy column holds a tombstone once finalized.
  • Before, the legacy column holds a usable value: neither empty, nor a tombstone, nor the placeholder.

func InvalidCount

func InvalidCount(table, reason string) uint64

InvalidCount answers how many builds of table's rows used a secret that fails validation for reason, in this process; an empty reason counts every reason.

func IsSecretKey

func IsSecretKey(key string) bool

IsSecretKey reports whether a key of a node protocol's settings, or of a node's raw configuration, names a secret: Reality's and TLS's private_key (privateKey), WireGuard's server_private_key and preshared_key, Shadowsocks' server_key, psk and password, the Hysteria2 obfs-password and auth, and any other *_key, password, passwd, pass, secret, token, credential or seed. Public keys, Reality's short_id and the paths of key files (key_file, wss_key_file) are not secrets.

func IsTombstone

func IsTombstone(value string) bool

IsTombstone reports whether a legacy credential value is a tombstone. The writer never copies one, and never removes the new row it stands for.

func LockRegistrationKey

func LockRegistrationKey(tx *gorm.DB, key string) (*model.AuthorizedKey, error)

LockRegistrationKey loads the registration key key is, with its row lock, in tx: node registration. It answers gorm.ErrRecordNotFound when no key matches, and for an unusable key. Expiry stays the caller's check, on the row.

func NodeAPIKey

func NodeAPIKey(db *gorm.DB, node *model.Node) string

NodeAPIKey answers the API key of node, where the kernel shows it (the administrators' credentials route).

func NodeAPIKeyMatches

func NodeAPIKeyMatches(db *gorm.DB, node *model.Node, apiKey string) bool

NodeAPIKeyMatches reports whether apiKey is the API key of node, a row of v2_node the caller loaded by id: UniProxy's node authentication and the agent channels. An unusable key never matches.

func NodeByAPIKey

func NodeByAPIKey(db *gorm.DB, apiKey string, plain bool) (*model.Node, error)

NodeByAPIKey answers the proxy node whose API key apiKey is: the gRPC node listener, the Agent Control stream and the node API look nodes up by key. The legacy lookup matches the stored hash; with plain it also matches the key of an old row by the key itself, as the node API always did. It answers gorm.ErrRecordNotFound when no node has the key, and for an unusable key.

func NodeSharedSecret

func NodeSharedSecret(db *gorm.DB, node *model.Node) string

NodeSharedSecret answers the shared signing secret of node: the request signature check and the administrators' credentials route.

func PinCount

func PinCount(reason string) uint64

PinCount answers how many forward node token reads ended for reason in this process; an empty reason counts every reason.

func Prune

func Prune(tx *gorm.DB, tables ...string) (int64, error)

Prune removes the new-table rows of the given split tables whose legacy row no longer exists, in the caller's transaction: for a tool that deletes legacy rows without the writers (a re-import). It does nothing when the split tables do not exist.

func ReadPhase

func ReadPhase(db *gorm.DB, table string) string

ReadPhase answers the phase whose read rule table's readers apply. A database without the split tables, a table without a state row, an unknown phase and a failed read all answer dual_write: the legacy columns. The phases are cached for PhaseCacheTTL.

func RecordInvalid

func RecordInvalid(table, protocolType, reason, subject string)

RecordInvalid counts a node configuration build that used a row whose secret fails validation (validate on build, report-only: decision D7), and logs it once per subject. The row is not left out. subject names the node, protocol and field, never a value; protocolType and reason are bounded label values.

func Redact

func Redact(document string) string

Redact replaces the secret values of a node protocol setting or a raw configuration (JSON text) with Placeholder: a string or array under a secret key is replaced whole, an object's own keys are checked, and so are the objects in arrays. A document without a secret is returned unchanged, and one that is not JSON is replaced whole, since it cannot be told apart. These are exactly the positions SecretPositions answers. It is the administrators' masked answer (service.RedactNodeSecretsJSON) and the document a finalized JSON column keeps (section 4.4).

func RegistrationKeyExists

func RegistrationKeyExists(db *gorm.DB, key string) (bool, error)

RegistrationKeyExists reports whether key is a registration key, active or not, expired or not: the default key Control creates from its environment exists once. An unusable key exists nowhere.

func ReplacePositions

func ReplacePositions(document string, replacements map[string]string) (string, error)

ReplacePositions returns document with each pointer set to its JSON-encoded value (an empty pointer stands for the whole text). A document that is not JSON, a value that is not, or a pointer whose parent is not in the document is an error.

func ResolveNodeRawConfig

func ResolveNodeRawConfig(db *gorm.DB, node *model.Node)

ResolveNodeRawConfig gives the secret positions of node's raw configuration their values under the dual-read rule, in place.

func ResolveProtocol

func ResolveProtocol(db *gorm.DB, protocol *model.NodeProtocol)

ResolveProtocol is ResolveProtocols for one protocol.

func ResolveProtocols

func ResolveProtocols(db *gorm.DB, protocols []model.NodeProtocol)

ResolveProtocols gives the secret positions of the protocols' JSON columns their values under the dual-read rule, in place, where the kernel builds a node's or a user's configuration from them. In phase dual_write it changes nothing. A column is rewritten only where a position takes a value its legacy document does not hold (the placeholder in it); otherwise it stays byte for byte as stored.

func ResolveWireGuardPeers

func ResolveWireGuardPeers(db *gorm.DB, peers ...*model.WireGuardPeer)

ResolveWireGuardPeers gives the peers' private and preshared keys their values under the dual-read rule, in place: a user's subscription and the node's runtime user list.

func SplitInstalled

func SplitInstalled(db *gorm.DB) bool

SplitInstalled reports whether db holds the split tables (EnsureSchema ran): a database without them (a tool's, or a test's that never created the kernel schema) has no credential rows and no endpoint pins. It is cached with the phases.

func Status

func Status(ctx context.Context, db *gorm.DB) ([]model.NodeSecretSplit, error)

Status answers the state of every split table.

func Sync

func Sync(tx *gorm.DB, table string, ids ...uint) error

Sync is the dual-write of a kernel writer. Call it with the transaction of the legacy write, after that write, with the ids of the legacy rows it created, changed or deleted: the new tables are brought in line with those rows as they now stand, so a failure of either rolls back both.

  • A legacy row that holds a secret gets its current row in the new table; a changed value retires that row (its value cleared) and adds the next version.
  • A deleted row, or a secret that is gone, takes its rows away.
  • A tombstone or the placeholder in a legacy column leaves the new row as it is: the secret has moved there.
  • In a finalized table (P3), the legacy columns of the rows then get their tombstones and redacted documents (section 4.4): a writer's secret reaches the new tables only. The phase is read in tx, uncached.

Sync does nothing when the split tables do not exist.

func Tables

func Tables() []string

Tables lists the split tables in backfill order.

func Tombstone

func Tombstone(id uint64) string

Tombstone is the value of a legacy unique credential column once its secret has moved (v2_node.api_key, v2_authorized_key.key, v2_forward_clean_agent.token): unique per row, non-empty, and never a credential.

func Unusable

func Unusable(value string) bool

Unusable reports whether a credential value, presented or stored, can never authenticate: empty, a tombstone, or the placeholder. P3 writes tombstones and the placeholder into the legacy columns (section 4.4), so a legacy comparison must never accept them.

func WritePrometheus

func WritePrometheus(body *strings.Builder)

WritePrometheus writes the dual_read fallback and the validate-on-build counters of this process.

Types

type BackfillOptions

type BackfillOptions struct {
	// Tables limits the backfill to these split tables; empty is all.
	Tables []string
	// BatchSize is the number of legacy rows per transaction.
	BatchSize int
	// Restart starts a new pass even where an earlier one was interrupted.
	Restart bool
}

BackfillOptions selects the tables and the batch size of a backfill.

type FinalizeOptions

type FinalizeOptions struct {
	// Tables are the split tables to finalize; empty is all of them.
	Tables []string
	// Confirm must be set: finalize writes tombstones over the legacy
	// columns, and only unsplit (or a backup) writes them back.
	Confirm bool
	// Actor names who asked, for the audit entries and finalized_by.
	Actor string
	// BatchSize is the number of legacy rows per transaction.
	BatchSize int
	// Now is the time the verification's age is measured at; zero is the
	// current time.
	Now time.Time
	// contains filtered or unexported fields
}

FinalizeOptions selects a finalize.

type Mismatch

type Mismatch struct {
	// Problem is missing (in the legacy table only), extra (in the new
	// table only) or different.
	Problem string `json:"problem"`
	// Secret is the subject and kind ("proxy 12 node_api_key"), or the
	// owner, column and JSON pointer ("node_protocol 5 reality_settings
	// /private_key").
	Secret string `json:"secret"`
}

Mismatch names one secret whose old and new forms differ: where it is, never its value or hash.

type PhaseChange

type PhaseChange struct {
	Table   string `json:"table"`
	From    string `json:"from"`
	To      string `json:"to"`
	Changed bool   `json:"changed"`
	// Refused says why the table may not move; empty when it may.
	Refused string `json:"refused,omitempty"`
	// VerifiedAt and Digest are the verification the move to dual_read
	// relied on.
	VerifiedAt *time.Time `json:"verified_at,omitempty"`
	Digest     string     `json:"digest,omitempty"`
}

PhaseChange is the outcome of SetPhase for one table.

func SetPhase

func SetPhase(ctx context.Context, db *gorm.DB, options PhaseOptions) ([]PhaseChange, error)

SetPhase moves the readers of the given tables to dual_read, or back to dual_write (section 4.3, P2). Writers dual-write in both phases, so the legacy columns keep every value and an older binary keeps working.

  • dual_read requires each table's latest verification to have matched, with no mismatch, within VerifyMaxAge (`node-secrets verify`).
  • dual_write, the rollback, is always allowed from dual_read.
  • A finalized table, and any other phase, is refused: Finalize and Unsplit move a table into and out of finalized.

Either every table moves or none does: one refusal refuses the whole change, with ErrPhaseRefused and the reasons. Each table that changes gets an audit entry (v2_operation_log, module node_secrets) in the same transaction. The phases this process reads are refreshed at once; other Control processes follow within PhaseCacheTTL.

type PhaseOptions

type PhaseOptions struct {
	// Tables are the split tables to move; empty is all of them.
	Tables []string
	// Phase is dual_read (the readers read the new tables) or dual_write
	// (the way back: the readers read the legacy columns again).
	Phase string
	// Actor names who asked, for the audit entry.
	Actor string
	// Now is the time the verification's age is measured at; zero is the
	// current time.
	Now time.Time
}

PhaseOptions selects a phase change.

type Position

type Position struct {
	Pointer string
	Value   string
	// Moved is set when the column holds the placeholder there: the secret
	// is kept in the new table only, so the writer leaves its row as it is.
	Moved bool
}

Position is one secret of a column: its RFC 6901 JSON pointer and the JSON encoding of its value, or, with an empty pointer, the whole text of a column that is not JSON.

func SecretPositions

func SecretPositions(document string) []Position

SecretPositions returns the secrets of a node protocol setting or a raw configuration, ordered by pointer. They are exactly the values that service.RedactNodeSecretsJSON replaces with the placeholder: a non-empty string or array under a secret key (objects under one are searched instead), in objects at any depth and in arrays; and the whole text when it is not JSON. A blank column has none.

type Retired

type Retired struct {
	Credentials int64
	Secrets     int64
}

Retired counts what Retire removed.

func Retire

func Retire(tx *gorm.DB, table string, ids ...uint) (Retired, error)

Retire removes the new-table rows of legacy rows that are about to be deleted by their owner: every credential version of the table's subject kind and every secret of its scope, for the given ids. It is the kernel's part of a retirement (node-ops-service.md section 3.3, RetireNode and RetireProtocol): the package deletes the legacy row afterwards, and Sync on a deleted row removes nothing more. Deleting the legacy row first and calling Sync ends in the same state. Retire does nothing when the split tables do not exist.

type TableBackfill

type TableBackfill struct {
	Table string `json:"table"`
	// Rows counts the legacy rows of the pass, including those an
	// interrupted pass copied before (Resumed).
	Rows int64 `json:"rows"`
	// Changed counts the new-table rows this run inserted, changed or
	// deleted; a repeated backfill changes none.
	Changed int64 `json:"changed"`
	// Pruned counts the new-table rows removed because their legacy row no
	// longer exists.
	Pruned  int64 `json:"pruned"`
	Resumed bool  `json:"resumed"`
}

TableBackfill is the outcome of one table's backfill. It holds counts only, never a secret.

func Backfill

func Backfill(ctx context.Context, db *gorm.DB, options BackfillOptions) ([]TableBackfill, error)

Backfill copies the secrets of the legacy rows into the new tables, table by table, in batches by id. Each batch is one transaction that locks its legacy rows, so it serializes with the writers of those rows, and records its last id in v4_kernel_node_secret_split: an interrupted pass resumes after it. A completed pass is followed by a full new one. Rows already equal are left as they are, so backfill is idempotent.

type TableFinalize

type TableFinalize struct {
	Table string `json:"table"`
	From  string `json:"from"`
	// Refused says why the table may not be finalized; empty when it may.
	Refused string `json:"refused,omitempty"`
	// Resumed: an interrupted finalize of the table was carried on.
	Resumed bool `json:"resumed,omitempty"`
	// Rows counts the legacy rows of the last pass; Tombstoned the rows
	// whose columns were rewritten, over every pass.
	Rows        int64      `json:"rows"`
	Tombstoned  int64      `json:"tombstoned"`
	Passes      int        `json:"passes"`
	FinalizedAt *time.Time `json:"finalized_at,omitempty"`
	// VerifiedAt and Digest are the verification the finalize relied on.
	VerifiedAt *time.Time `json:"verified_at,omitempty"`
	Digest     string     `json:"digest,omitempty"`
}

TableFinalize is the outcome of one table's finalize. Counts only.

func Finalize

func Finalize(ctx context.Context, db *gorm.DB, options FinalizeOptions) ([]TableFinalize, error)

Finalize moves the given tables to phase finalized (section 4.3, P3).

  • It needs Confirm, and each table in phase dual_read with a recent clean verification (the dual_read gate: the latest verify matched, with no mismatch, within VerifyMaxAge). A table already finalized is left as it is. Either every table may be finalized or none is: ErrFinalizeRefused, with the reasons.
  • The phase moves to finalized first, with finalized_at empty: from then on the readers read the new tables only, and every writer's Sync writes tombstones into the legacy columns it writes.
  • Then the legacy rows are rewritten in batches by id, one transaction each, which locks its rows, brings the new tables in line with them (as backfill does) and writes the tombstones: unique per row, so no unique index is violated; a value that would collide is refused (ErrTombstoneCollision) and the batch rolled back.
  • finalized_at is recorded once a pass finds nothing left to rewrite. An interrupted finalize (finalized, finalized_at empty) is resumed by running it again, without a new verification.

Each step is audited (v2_operation_log, module node_secrets). No secret is logged or answered.

type TableUnsplit

type TableUnsplit struct {
	Table   string `json:"table"`
	From    string `json:"from"`
	Refused string `json:"refused,omitempty"`
	// Rows counts the legacy rows read; Restored those written back.
	Rows     int64 `json:"rows"`
	Restored int64 `json:"restored"`
	// Unresolved counts the tombstones and placeholders the new tables
	// held no value for: they stay, and never authenticate.
	Unresolved int64 `json:"unresolved"`
	// Verify is the verification run after the secrets were written back.
	Verify *TableVerify `json:"verify,omitempty"`
}

TableUnsplit is the outcome of one table's unsplit. Counts only.

func Unsplit

func Unsplit(ctx context.Context, db *gorm.DB, options UnsplitOptions) ([]TableUnsplit, error)

Unsplit is the way back from P3 (section 4.3): it writes the secrets of the new tables back into the legacy columns and returns the table to phase dual_read, where an older binary reads the legacy columns again.

  • It needs Confirm, and each table finalized or in dual_read (an interrupted unsplit is resumed by running it again). A table a package's storage lease adopted is refused unless AllowAdopted. Either every table may be unsplit or none is: ErrUnsplitRefused.
  • The phase moves to dual_read first, and DropViews removes the views that exist only while the table is finalized, in that transaction; the writers then write secrets, not tombstones, again.
  • The legacy rows are restored in batches by id: a credential column takes the new table's value and a key hash column its hash; a JSON column takes back its original document, byte for byte, where the column is still the redacted form of it, or else the redacted document with the new table's values at its placeholders.
  • Each table is verified afterwards; a mismatch is ErrMismatch, with the results, and the table stays in dual_read.

type TableVerify

type TableVerify struct {
	Table string `json:"table"`
	Phase string `json:"phase"`
	// LegacyRows counts the legacy rows read; LegacySecrets the secrets
	// they hold, and NewSecrets the current rows of the new tables.
	LegacyRows    int64 `json:"legacy_rows"`
	LegacySecrets int64 `json:"legacy_secrets"`
	NewSecrets    int64 `json:"new_secrets"`
	// LegacyDigest and NewDigest are the digests of every secret of each
	// form. They are equal when the forms match.
	LegacyDigest string     `json:"legacy_digest"`
	NewDigest    string     `json:"new_digest"`
	Match        bool       `json:"match"`
	Missing      int64      `json:"missing"`
	Extra        int64      `json:"extra"`
	Different    int64      `json:"different"`
	Samples      []Mismatch `json:"samples,omitempty"`
}

TableVerify is the outcome of one table's verification.

func Verify

func Verify(ctx context.Context, db *gorm.DB, options VerifyOptions) ([]TableVerify, error)

Verify compares every secret of the legacy tables with the new tables (section 4.3). Each form is reduced to one entry per secret: the subject or owner, the kind or position, and a SHA-256 over the hash of the value and the credential's metadata (key hash, endpoint, status, expiry and revocation). A table's digest is the SHA-256 of its sorted entries. Both forms are read in one transaction (a repeatable-read snapshot on PostgreSQL), so concurrent writers cannot make it report a difference.

The outcome is recorded in v4_kernel_node_secret_split: a match records the digest and verified_at. The phase is left as it is: SetPhase moves the readers to dual_read, and requires a recent verification that matched. Verify answers ErrMismatch, with the results, when a table differs.

type UnpinnedForwardNode

type UnpinnedForwardNode struct {
	ID   uint   `json:"id"`
	Name string `json:"name"`
}

UnpinnedForwardNode is a forward node that holds a token but no API port: it has no endpoint, so its token is presented nowhere until an administrator sets the port (node-ops-service.md section 3.8, decided by the owner on 2026-10-01). Id and name only, never a value.

func ForwardNodesWithoutAPIPort

func ForwardNodesWithoutAPIPort(ctx context.Context, db *gorm.DB) ([]UnpinnedForwardNode, error)

ForwardNodesWithoutAPIPort lists the forward nodes that hold a token and have no API port, by id, for the node-secrets status command: the nodes whose gost backend changes and legacy rules fail ENDPOINT_UNCONFIRMED after the backfill.

type UnsplitOptions

type UnsplitOptions struct {
	// Tables are the split tables to unsplit; empty is all of them.
	Tables []string
	// Confirm must be set.
	Confirm bool
	// AllowAdopted unsplits a table a package's storage lease adopted: the
	// package would read the secrets written back. Set it only once the
	// package no longer holds the table (uninstalled, or a release without
	// the grant was leased since).
	AllowAdopted bool
	// Actor names who asked, for the audit entries.
	Actor string
	// BatchSize is the number of legacy rows per transaction.
	BatchSize int
	// DropViews drops the kernel API views that may exist only while table
	// is finalized (packagestore.DropFinalizedViews), in the transaction
	// that leaves the phase, before any secret is written back.
	DropViews func(tx *gorm.DB, table string) error
	// Now stamps the audit entries; zero is the current time.
	Now time.Time
	// contains filtered or unexported fields
}

UnsplitOptions selects an unsplit.

type VerifyOptions

type VerifyOptions struct {
	Tables []string
	// Samples is how many mismatching secrets to name per table.
	Samples int
}

VerifyOptions selects the tables of a verification.

Jump to

Keyboard shortcuts

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