初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
// Package identity persists the Windows Node identity without plaintext secrets.
package identity
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"remlink/internal/config"
"remlink/internal/model"
)
const diskVersion = 1
// Protector is implemented by the Windows DPAPI adapter.
type Protector interface {
Protect([]byte) ([]byte, error)
Unprotect([]byte) ([]byte, error)
}
// Identity is the durable Node identity used by Bootstrap and reconciliation.
type Identity struct {
NodeID string
NodeType model.NodeType
NodeName string
PrivateKey wgtypes.Key
NodeToken string
ServerURL string
ConfigVersion uint64
OwnedRoutes []string
}
// PublicKey derives the shareable WireGuard key from the protected private key.
func (i Identity) PublicKey() string { return i.PrivateKey.PublicKey().String() }
// New generates a UUID and independent WireGuard key pair.
func New(nodeType model.NodeType, nodeName, serverURL string) (Identity, error) {
privateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return Identity{}, fmt.Errorf("generate Node WireGuard key: %w", err)
}
identity := Identity{
NodeID: uuid.NewString(), NodeType: nodeType, NodeName: strings.TrimSpace(nodeName),
PrivateKey: privateKey, ServerURL: strings.TrimRight(strings.TrimSpace(serverURL), "/"),
}
if err := identity.Validate(false); err != nil {
return Identity{}, err
}
return identity, nil
}
// Validate checks persistent identity invariants. requireToken is true after enrollment.
func (i Identity) Validate(requireToken bool) error {
if _, err := uuid.Parse(i.NodeID); err != nil {
return fmt.Errorf("Node ID must be a UUID: %w", err)
}
if !i.NodeType.Valid() {
return fmt.Errorf("invalid Node type %q", i.NodeType)
}
if i.NodeName == "" || len(i.NodeName) > 128 {
return errors.New("Node name must contain 1 to 128 bytes")
}
if err := config.ValidateServerURL(i.ServerURL); err != nil {
return fmt.Errorf("invalid Server URL: %w", err)
}
if i.PrivateKey == (wgtypes.Key{}) {
return errors.New("WireGuard private key must not be zero")
}
if requireToken && i.NodeToken == "" {
return errors.New("Node Token is required after registration")
}
return nil
}
// Store saves one role-specific identity JSON file.
type Store struct {
path string
protector Protector
}
func NewStore(path string, protector Protector) (*Store, error) {
if strings.TrimSpace(path) == "" || protector == nil {
return nil, errors.New("identity path and Protector are required")
}
return &Store{path: path, protector: protector}, nil
}
// Path returns the exact identity artifact location.
func (s *Store) Path() string { return s.path }
// LoadOwnedRoutes implements RouteManager's persistence boundary.
func (s *Store) LoadOwnedRoutes() ([]netip.Prefix, error) {
current, err := s.Load()
if err != nil {
return nil, err
}
routes := make([]netip.Prefix, 0, len(current.OwnedRoutes))
for _, raw := range current.OwnedRoutes {
prefix, err := netip.ParsePrefix(raw)
if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() {
return nil, fmt.Errorf("invalid owned Remote route %q", raw)
}
routes = append(routes, prefix)
}
return routes, nil
}
// SaveOwnedRoutes atomically updates RouteManager ownership metadata.
func (s *Store) SaveOwnedRoutes(routes []netip.Prefix) error {
current, err := s.Load()
if err != nil {
return err
}
current.OwnedRoutes = make([]string, 0, len(routes))
for _, prefix := range routes {
if !prefix.Addr().Is4() || prefix != prefix.Masked() {
return fmt.Errorf("owned Remote route must be canonical IPv4: %s", prefix)
}
current.OwnedRoutes = append(current.OwnedRoutes, prefix.String())
}
return s.Save(current)
}
// Load decrypts and validates a persisted identity.
func (s *Store) Load() (Identity, error) {
raw, err := os.ReadFile(s.path)
if err != nil {
return Identity{}, fmt.Errorf("read Node identity: %w", err)
}
var stored diskIdentity
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&stored); err != nil {
return Identity{}, fmt.Errorf("decode Node identity: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return Identity{}, errors.New("Node identity must contain one JSON object")
}
if stored.Version != diskVersion {
return Identity{}, fmt.Errorf("unsupported Node identity version %d", stored.Version)
}
privateKeyRaw, err := s.unprotect(stored.ProtectedPrivateKey)
if err != nil {
return Identity{}, fmt.Errorf("decrypt WireGuard private key: %w", err)
}
privateKey, err := wgtypes.ParseKey(string(privateKeyRaw))
clear(privateKeyRaw)
if err != nil {
return Identity{}, fmt.Errorf("parse decrypted WireGuard private key: %w", err)
}
nodeToken := ""
if stored.ProtectedNodeToken != "" {
nodeTokenRaw, err := s.unprotect(stored.ProtectedNodeToken)
if err != nil {
return Identity{}, fmt.Errorf("decrypt Node Token: %w", err)
}
nodeToken = string(nodeTokenRaw)
clear(nodeTokenRaw)
}
identity := Identity{
NodeID: stored.NodeID, NodeType: stored.NodeType, NodeName: stored.NodeName,
PrivateKey: privateKey, NodeToken: nodeToken, ServerURL: stored.ServerURL,
ConfigVersion: stored.ConfigVersion, OwnedRoutes: append([]string(nil), stored.OwnedRoutes...),
}
if err := identity.Validate(false); err != nil {
return Identity{}, fmt.Errorf("validate Node identity: %w", err)
}
return identity, nil
}
// Save protects all secrets and atomically replaces the identity artifact.
func (s *Store) Save(identity Identity) error {
if err := identity.Validate(false); err != nil {
return err
}
privateKeyRaw := []byte(identity.PrivateKey.String())
protectedPrivateKey, err := s.protect(privateKeyRaw)
clear(privateKeyRaw)
if err != nil {
return fmt.Errorf("encrypt WireGuard private key: %w", err)
}
protectedNodeToken := ""
if identity.NodeToken != "" {
protectedNodeToken, err = s.protect([]byte(identity.NodeToken))
if err != nil {
return fmt.Errorf("encrypt Node Token: %w", err)
}
}
stored := diskIdentity{
Version: diskVersion, NodeID: identity.NodeID, NodeType: identity.NodeType,
NodeName: identity.NodeName, ProtectedPrivateKey: protectedPrivateKey,
ProtectedNodeToken: protectedNodeToken, ServerURL: identity.ServerURL,
ConfigVersion: identity.ConfigVersion, OwnedRoutes: identity.OwnedRoutes,
}
encoded, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("encode Node identity: %w", err)
}
encoded = append(encoded, '\n')
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("create Node identity directory: %w", err)
}
temporary, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*.tmp")
if err != nil {
return fmt.Errorf("create temporary Node identity: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("restrict temporary Node identity: %w", err)
}
if _, err := temporary.Write(encoded); err != nil {
temporary.Close()
return fmt.Errorf("write temporary Node identity: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("flush temporary Node identity: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close temporary Node identity: %w", err)
}
if err := replaceFile(temporaryPath, s.path); err != nil {
return fmt.Errorf("replace Node identity: %w", err)
}
return nil
}
func (s *Store) protect(value []byte) (string, error) {
protected, err := s.protector.Protect(value)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(protected), nil
}
func (s *Store) unprotect(value string) ([]byte, error) {
protected, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return nil, err
}
return s.protector.Unprotect(protected)
}
type diskIdentity struct {
Version int `json:"version"`
NodeID string `json:"node_id"`
NodeType model.NodeType `json:"node_type"`
NodeName string `json:"node_name"`
ProtectedPrivateKey string `json:"protected_wg_private_key"`
ProtectedNodeToken string `json:"protected_node_token,omitempty"`
ServerURL string `json:"server_url"`
ConfigVersion uint64 `json:"config_version"`
OwnedRoutes []string `json:"owned_routes,omitempty"`
}