初版功能完成
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
// Package bootstrap implements the public Node enrollment and configuration API.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/ipam"
|
||||
"remlink/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrJoinTokenInvalid = errors.New("Join Token invalid")
|
||||
ErrNodeAuthFailed = errors.New("Node authentication failed")
|
||||
ErrNodeConflict = errors.New("Node identity conflicts with existing registration")
|
||||
)
|
||||
|
||||
// PeerManager is the narrow kernel-WireGuard boundary used by Bootstrap.
|
||||
type PeerManager interface {
|
||||
EnsurePeer(context.Context, string, netip.Addr) error
|
||||
}
|
||||
|
||||
// ServiceConfig describes the Server values returned to Windows nodes.
|
||||
type ServiceConfig struct {
|
||||
ServerID string
|
||||
Version string
|
||||
WGPublicKey string
|
||||
WGEndpoint string
|
||||
OverlayCIDR netip.Prefix
|
||||
ServerOverlayIP netip.Addr
|
||||
ControlURL string
|
||||
SessionUDPPort int
|
||||
MTU int
|
||||
ConfigVersion uint64
|
||||
}
|
||||
|
||||
// Service coordinates registration, IPAM, token rotation, and peer creation.
|
||||
type Service struct {
|
||||
mu sync.RWMutex
|
||||
store *database.Store
|
||||
ipam *ipam.Manager
|
||||
joins *JoinTokens
|
||||
peers PeerManager
|
||||
config ServiceConfig
|
||||
onNodeBootstrap func(context.Context, string) error
|
||||
}
|
||||
|
||||
// SetNodeBootstrapHandler installs the Server runtime reconciliation boundary.
|
||||
// A Node calls the public Config endpoint only when constructing a fresh local
|
||||
// runtime; any old in-memory Session for that Node can no longer be resumed.
|
||||
func (s *Service) SetNodeBootstrapHandler(handler func(context.Context, string) error) {
|
||||
s.mu.Lock()
|
||||
s.onNodeBootstrap = handler
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewService(store *database.Store, ipamManager *ipam.Manager, joins *JoinTokens, peers PeerManager, config ServiceConfig) (*Service, error) {
|
||||
if store == nil || ipamManager == nil || joins == nil || peers == nil {
|
||||
return nil, errors.New("Bootstrap dependencies must not be nil")
|
||||
}
|
||||
if err := validateServiceConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{store: store, ipam: ipamManager, joins: joins, peers: peers, config: config}, nil
|
||||
}
|
||||
|
||||
func usableOverlayAddress(prefix netip.Prefix, address netip.Addr) bool {
|
||||
if !address.Is4() || !prefix.Contains(address) || address == prefix.Addr() {
|
||||
return false
|
||||
}
|
||||
base := prefix.Masked().Addr().As4()
|
||||
value := uint32(base[0])<<24 | uint32(base[1])<<16 | uint32(base[2])<<8 | uint32(base[3])
|
||||
value |= ^uint32(0) >> prefix.Bits()
|
||||
broadcast := netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)})
|
||||
return address != broadcast
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
JoinToken string `json:"join_token"`
|
||||
NodeID string `json:"node_id"`
|
||||
NodeType model.NodeType `json:"node_type"`
|
||||
NodeName string `json:"node_name"`
|
||||
WGPublicKey string `json:"wg_public_key"`
|
||||
Version string `json:"version"`
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
|
||||
type ConfigRequest struct {
|
||||
NodeID string `json:"node_id"`
|
||||
NodeToken string `json:"node_token"`
|
||||
}
|
||||
|
||||
type ServerInfo struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Version string `json:"version"`
|
||||
APIVersion int `json:"api_version"`
|
||||
WGEndpoint string `json:"wg_endpoint"`
|
||||
RegistrationURL string `json:"registration_url"`
|
||||
}
|
||||
|
||||
type NetworkConfig struct {
|
||||
ConfigVersion uint64 `json:"config_version"`
|
||||
OverlayCIDR string `json:"overlay_cidr"`
|
||||
OverlayIP string `json:"overlay_ip"`
|
||||
ServerOverlayIP string `json:"server_overlay_ip"`
|
||||
ServerWGPublicKey string `json:"server_wg_public_key"`
|
||||
ServerWGEndpoint string `json:"server_wg_endpoint"`
|
||||
ControlURL string `json:"control_url"`
|
||||
SessionUDPPort int `json:"session_udp_port"`
|
||||
MTU int `json:"mtu"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
NodeToken string `json:"node_token"`
|
||||
Network NetworkConfig `json:"network_config"`
|
||||
}
|
||||
|
||||
type ConfigResponse struct {
|
||||
Network NetworkConfig `json:"network_config"`
|
||||
}
|
||||
|
||||
func (s *Service) ServerInfo() ServerInfo {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return ServerInfo{
|
||||
ServerID: s.config.ServerID, Version: s.config.Version, APIVersion: 1,
|
||||
WGEndpoint: s.config.WGEndpoint, RegistrationURL: "/api/v1/bootstrap/register",
|
||||
}
|
||||
}
|
||||
|
||||
// Register enrolls a new Node or safely rotates credentials for an identical identity.
|
||||
func (s *Service) Register(ctx context.Context, request RegisterRequest) (RegisterResponse, error) {
|
||||
validJoin, err := s.joins.Verify(ctx, request.JoinToken)
|
||||
if err != nil {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
if !validJoin {
|
||||
return RegisterResponse{}, ErrJoinTokenInvalid
|
||||
}
|
||||
request.NodeName = strings.TrimSpace(request.NodeName)
|
||||
if err := validateRegistration(request); err != nil {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
key, _ := wgtypes.ParseKey(request.WGPublicKey)
|
||||
canonicalKey := key.String()
|
||||
plainToken, tokenHash, err := newNodeToken()
|
||||
if err != nil {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
|
||||
existing, err := s.store.GetNode(ctx, request.NodeID)
|
||||
if err == nil {
|
||||
if existing.Type != request.NodeType || existing.WGPublicKey != canonicalKey {
|
||||
return RegisterResponse{}, ErrNodeConflict
|
||||
}
|
||||
if err := s.reconcileNodeBootstrap(ctx, existing.ID); err != nil {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
if err := s.peers.EnsurePeer(ctx, canonicalKey, existing.OverlayIP); err != nil {
|
||||
return RegisterResponse{}, fmt.Errorf("ensure existing WireGuard peer: %w", err)
|
||||
}
|
||||
existing.Name = request.NodeName
|
||||
existing.NodeTokenHash = tokenHash
|
||||
existing.Version = request.Version
|
||||
existing.OSVersion = request.OSVersion
|
||||
if err := s.store.UpdateNodeRegistration(ctx, existing); err != nil {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
return RegisterResponse{NodeToken: plainToken, Network: s.network(existing.OverlayIP)}, nil
|
||||
}
|
||||
if !errors.Is(err, database.ErrNodeNotFound) {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ipamManager := s.ipam
|
||||
s.mu.RUnlock()
|
||||
node, created, err := ipamManager.ReserveNode(ctx, model.Node{
|
||||
ID: request.NodeID, Type: request.NodeType, Name: request.NodeName,
|
||||
WGPublicKey: canonicalKey, NodeTokenHash: tokenHash, Status: model.NodeOffline,
|
||||
Version: request.Version, OSVersion: request.OSVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return RegisterResponse{}, err
|
||||
}
|
||||
if !created {
|
||||
return RegisterResponse{}, ErrNodeConflict
|
||||
}
|
||||
if err := s.peers.EnsurePeer(ctx, canonicalKey, node.OverlayIP); err != nil {
|
||||
if rollbackErr := ipamManager.ReleaseNode(ctx, node.ID); rollbackErr != nil {
|
||||
return RegisterResponse{}, fmt.Errorf("ensure WireGuard peer: %w (registration rollback failed: %v)", err, rollbackErr)
|
||||
}
|
||||
return RegisterResponse{}, fmt.Errorf("ensure WireGuard peer: %w", err)
|
||||
}
|
||||
return RegisterResponse{NodeToken: plainToken, Network: s.network(node.OverlayIP)}, nil
|
||||
}
|
||||
|
||||
// Config authenticates an enrolled Node and returns the authoritative latest configuration.
|
||||
func (s *Service) Config(ctx context.Context, request ConfigRequest) (ConfigResponse, error) {
|
||||
node, err := s.AuthenticateNode(ctx, request.NodeID, request.NodeToken)
|
||||
if err != nil {
|
||||
return ConfigResponse{}, err
|
||||
}
|
||||
if err := s.reconcileNodeBootstrap(ctx, node.ID); err != nil {
|
||||
return ConfigResponse{}, err
|
||||
}
|
||||
if err := s.peers.EnsurePeer(ctx, node.WGPublicKey, node.OverlayIP); err != nil {
|
||||
return ConfigResponse{}, fmt.Errorf("ensure WireGuard peer: %w", err)
|
||||
}
|
||||
return ConfigResponse{Network: s.network(node.OverlayIP)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) reconcileNodeBootstrap(ctx context.Context, nodeID string) error {
|
||||
s.mu.RLock()
|
||||
handler := s.onNodeBootstrap
|
||||
s.mu.RUnlock()
|
||||
if handler == nil {
|
||||
return nil
|
||||
}
|
||||
if err := handler(ctx, nodeID); err != nil {
|
||||
return fmt.Errorf("reconcile Node runtime before Bootstrap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateNode verifies application identity for Bootstrap and Control.
|
||||
func (s *Service) AuthenticateNode(ctx context.Context, nodeID, nodeToken string) (model.Node, error) {
|
||||
node, err := s.store.GetNode(ctx, nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNodeNotFound) {
|
||||
return model.Node{}, ErrNodeAuthFailed
|
||||
}
|
||||
return model.Node{}, err
|
||||
}
|
||||
if !nodeTokenMatches(node.NodeTokenHash, nodeToken) {
|
||||
return model.Node{}, ErrNodeAuthFailed
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func validateRegistration(request RegisterRequest) error {
|
||||
if _, err := uuid.Parse(request.NodeID); err != nil {
|
||||
return fmt.Errorf("node_id must be a UUID: %w", err)
|
||||
}
|
||||
if !request.NodeType.Valid() {
|
||||
return fmt.Errorf("invalid node_type %q", request.NodeType)
|
||||
}
|
||||
if request.NodeName == "" || len(request.NodeName) > 128 {
|
||||
return errors.New("node_name must contain 1 to 128 bytes")
|
||||
}
|
||||
if _, err := wgtypes.ParseKey(request.WGPublicKey); err != nil {
|
||||
return fmt.Errorf("wg_public_key must be a WireGuard key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) network(nodeIP netip.Addr) NetworkConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.networkLocked(nodeIP)
|
||||
}
|
||||
|
||||
func (s *Service) networkLocked(nodeIP netip.Addr) NetworkConfig {
|
||||
return NetworkConfig{
|
||||
ConfigVersion: s.config.ConfigVersion, OverlayCIDR: s.config.OverlayCIDR.String(),
|
||||
OverlayIP: nodeIP.String(), ServerOverlayIP: s.config.ServerOverlayIP.String(),
|
||||
ServerWGPublicKey: s.config.WGPublicKey, ServerWGEndpoint: s.config.WGEndpoint,
|
||||
ControlURL: s.config.ControlURL, SessionUDPPort: s.config.SessionUDPPort, MTU: s.config.MTU,
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkSnapshot returns the current public configuration without a Node IP.
|
||||
func (s *Service) NetworkSnapshot() ServiceConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.config
|
||||
}
|
||||
|
||||
// UpdateNetwork publishes a completed Server network migration to Bootstrap.
|
||||
func (s *Service) UpdateNetwork(config ServiceConfig) error {
|
||||
if err := validateServiceConfig(config); err != nil {
|
||||
return fmt.Errorf("invalid updated Bootstrap network: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.config = config
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateServiceConfig(config ServiceConfig) error {
|
||||
if _, err := uuid.Parse(config.ServerID); err != nil {
|
||||
return fmt.Errorf("invalid Server ID: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(config.Version) == "" {
|
||||
return errors.New("Bootstrap Server version is required")
|
||||
}
|
||||
if _, err := wgtypes.ParseKey(config.WGPublicKey); err != nil {
|
||||
return fmt.Errorf("invalid Server WireGuard public key: %w", err)
|
||||
}
|
||||
if !config.OverlayCIDR.IsValid() || !config.OverlayCIDR.Addr().Is4() || config.OverlayCIDR != config.OverlayCIDR.Masked() || config.OverlayCIDR.Bits() == 0 || config.OverlayCIDR.Bits() > 30 {
|
||||
return errors.New("Bootstrap overlay CIDR must be a canonical IPv4 prefix with usable hosts")
|
||||
}
|
||||
if !usableOverlayAddress(config.OverlayCIDR, config.ServerOverlayIP) {
|
||||
return errors.New("Bootstrap Server overlay address must be usable inside overlay CIDR")
|
||||
}
|
||||
wgHost, wgPortText, err := net.SplitHostPort(config.WGEndpoint)
|
||||
if err != nil || strings.TrimSpace(wgHost) == "" {
|
||||
return errors.New("Bootstrap WireGuard endpoint must be host:port")
|
||||
}
|
||||
wgPort, err := strconv.Atoi(wgPortText)
|
||||
if err != nil || wgPort < 1 || wgPort > 65535 {
|
||||
return errors.New("Bootstrap WireGuard endpoint port is invalid")
|
||||
}
|
||||
controlURL, err := url.Parse(config.ControlURL)
|
||||
if err != nil || (controlURL.Scheme != "ws" && controlURL.Scheme != "wss") || controlURL.Path != "/control" ||
|
||||
controlURL.Opaque != "" || controlURL.User != nil || controlURL.RawQuery != "" || controlURL.ForceQuery || controlURL.Fragment != "" {
|
||||
return errors.New("Bootstrap Control URL must be an absolute ws(s) /control URL")
|
||||
}
|
||||
controlHost, controlPortText, err := net.SplitHostPort(controlURL.Host)
|
||||
if err != nil || controlHost != config.ServerOverlayIP.String() {
|
||||
return errors.New("Bootstrap Control URL must target the Server overlay IP")
|
||||
}
|
||||
controlPort, err := strconv.Atoi(controlPortText)
|
||||
if err != nil || controlPort < 1 || controlPort > 65535 {
|
||||
return errors.New("Bootstrap Control URL port is invalid")
|
||||
}
|
||||
if config.SessionUDPPort < 1 || config.SessionUDPPort > 65535 || config.MTU < 576 || config.MTU > 65535 || config.ConfigVersion == 0 {
|
||||
return errors.New("Bootstrap Session UDP port, MTU, and config version must be valid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user