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
- Variables
- func AdoptionAllowed(db *gorm.DB, table string) error
- func CleanAgentByToken(db *gorm.DB, token string) (*model.ForwardCleanAgent, error)
- func CleanAgentWithToken(db *gorm.DB, agentID uint, token string) (*model.ForwardCleanAgent, error)
- func EnsureSchema(db *gorm.DB) error
- func FallbackCount(table, kind, reason string) uint64
- func FinalizedTables(db *gorm.DB) ([]string, error)
- func ForgetLogged()
- func ForgetPhases(db *gorm.DB)
- func ForwardNodeEndpoint(node *model.ForwardNode) string
- func ForwardNodeToken(db *gorm.DB, node *model.ForwardNode) string
- func ForwardNodeTokenAbsent(db *gorm.DB) (string, []any)
- func ForwardNodeTokenAt(db *gorm.DB, node *model.ForwardNode) (string, error)
- func ForwardNodeTokenMatches(db *gorm.DB, node *model.ForwardNode, token string) bool
- func ForwardNodeTokenPresent(db *gorm.DB) (string, []any)
- func InvalidCount(table, reason string) uint64
- func IsSecretKey(key string) bool
- func IsTombstone(value string) bool
- func LockRegistrationKey(tx *gorm.DB, key string) (*model.AuthorizedKey, error)
- func NodeAPIKey(db *gorm.DB, node *model.Node) string
- func NodeAPIKeyMatches(db *gorm.DB, node *model.Node, apiKey string) bool
- func NodeByAPIKey(db *gorm.DB, apiKey string, plain bool) (*model.Node, error)
- func NodeSharedSecret(db *gorm.DB, node *model.Node) string
- func PinCount(reason string) uint64
- func Prune(tx *gorm.DB, tables ...string) (int64, error)
- func ReadPhase(db *gorm.DB, table string) string
- func RecordInvalid(table, protocolType, reason, subject string)
- func Redact(document string) string
- func RegistrationKeyExists(db *gorm.DB, key string) (bool, error)
- func ReplacePositions(document string, replacements map[string]string) (string, error)
- func ResolveNodeRawConfig(db *gorm.DB, node *model.Node)
- func ResolveProtocol(db *gorm.DB, protocol *model.NodeProtocol)
- func ResolveProtocols(db *gorm.DB, protocols []model.NodeProtocol)
- func ResolveWireGuardPeers(db *gorm.DB, peers ...*model.WireGuardPeer)
- func SplitInstalled(db *gorm.DB) bool
- func Status(ctx context.Context, db *gorm.DB) ([]model.NodeSecretSplit, error)
- func Sync(tx *gorm.DB, table string, ids ...uint) error
- func Tables() []string
- func Tombstone(id uint64) string
- func Unusable(value string) bool
- func WritePrometheus(body *strings.Builder)
- type BackfillOptions
- type FinalizeOptions
- type Mismatch
- type PhaseChange
- type PhaseOptions
- type Position
- type Retired
- type TableBackfill
- type TableFinalize
- type TableUnsplit
- type TableVerify
- type UnpinnedForwardNode
- type UnsplitOptions
- type VerifyOptions
Constants ¶
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.
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.
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.
const ( PhaseLegacy = "legacy" PhaseDualWrite = "dual_write" PhaseDualRead = "dual_read" PhaseFinalized = "finalized" )
The phases of a table (section 4.3).
const ( SubjectProxy = "proxy" SubjectForward = "forward" SubjectCleanAgent = "clean_agent" SubjectRegistrationKey = "registration_key" SubjectAgentEnrollment = "agent_enrollment" )
Subject kinds of v4_kernel_node_credential.
const ( KindNodeAPIKey = "node_api_key" // #nosec G101 -- this names a credential kind, not a hardcoded credential. KindRegistrationKey = "registration_key" KindForwardNodeToken = "forward_node_token" KindCleanAgentToken = "clean_agent_token" )
Credential kinds.
const ( StatusActive = "active" StatusRetired = "retired" StatusRevoked = "revoked" )
Credential statuses.
const ( SourceBackfill = "backfill" SourceDualWrite = "dual_write" SourceIssued = "issued" )
Sources: who wrote a credential's current version.
const ( ScopeNodeProtocol = "node_protocol" ScopeNodeRawConfig = "node_raw_config" ScopeWireGuardPeer = "wireguard_peer" )
Scopes of v4_kernel_protocol_secret.
const DefaultBatchSize = 500
DefaultBatchSize is how many legacy rows one backfill transaction copies.
const DefaultSamples = 20
DefaultSamples is how many mismatches a verification names per table.
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.
const Placeholder = "********"
Placeholder stands for a node secret in administrators' answers (service.NodeSecretPlaceholder). A finalized JSON column keeps it at every secret position.
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 ¶
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.
var ErrFinalizeRefused = errors.New("nodesecrets: finalize refused")
ErrFinalizeRefused is returned by Finalize when a table may not be finalized; nothing is changed.
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.
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.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
ForwardNodeTokenAbsent is the negation of ForwardNodeTokenPresent.
func ForwardNodeTokenAt ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
NodeAPIKey answers the API key of node, where the kernel shows it (the administrators' credentials route).
func NodeAPIKeyMatches ¶
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 ¶
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 ¶
NodeSharedSecret answers the shared signing secret of node: the request signature check and the administrators' credentials route.
func PinCount ¶
PinCount answers how many forward node token reads ended for reason in this process; an empty reason counts every reason.
func Prune ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 Sync ¶
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 Tombstone ¶
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 ¶
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 ¶
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 ¶
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 ¶
Retired counts what Retire removed.
func Retire ¶
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 ¶
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 ¶
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.