feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
const maxControlResponse = 1 << 20
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
ClientCertFile string
|
||||
ClientKeyFile string
|
||||
CAFile string
|
||||
Timeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
base *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type RemoteError struct {
|
||||
Status int
|
||||
Body protocol.ServiceErrorPayload
|
||||
}
|
||||
|
||||
func (e *RemoteError) Error() string {
|
||||
if e.Body.Message != "" {
|
||||
return fmt.Sprintf("control plane %s: %s", e.Body.ErrorCode, e.Body.Message)
|
||||
}
|
||||
return fmt.Sprintf("control plane HTTP %d", e.Status)
|
||||
}
|
||||
|
||||
func exactBase(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("invalid control-plane origin")
|
||||
}
|
||||
isLoopback := parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1"
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && isLoopback) {
|
||||
return nil, fmt.Errorf("control-plane origin must use HTTPS")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return nil, fmt.Errorf("control-plane URL must be an origin")
|
||||
}
|
||||
parsed.Path = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func New(config Config) (*Client, error) {
|
||||
base, err := exactBase(config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := config.HTTPClient
|
||||
if client == nil {
|
||||
certificate, err := tls.LoadX509KeyPair(config.ClientCertFile, config.ClientKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load relay mTLS identity: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(config.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load control-plane CA: %w", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("control-plane CA contains no certificates")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
RootCAs: roots,
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
}
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
}
|
||||
clone := *client
|
||||
clone.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &Client{base: base, http: &clone}, nil
|
||||
}
|
||||
|
||||
func (c *Client) endpoint(path string) string {
|
||||
return c.base.Scheme + "://" + c.base.Host + path
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, path string, request any, response any, headers http.Header) error {
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, maxControlResponse+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > maxControlResponse {
|
||||
return fmt.Errorf("control-plane response exceeds limit")
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
var envelope protocol.ServiceErrorPayload
|
||||
if err := json.Unmarshal(data, &envelope); err != nil || envelope.ErrorCode == "" {
|
||||
return &RemoteError{Status: res.StatusCode, Body: protocol.ServiceErrorPayload{
|
||||
ErrorCode: "route_unavailable", Message: "Control plane rejected the request", Retryable: false,
|
||||
}}
|
||||
}
|
||||
return &RemoteError{Status: res.StatusCode, Body: envelope}
|
||||
}
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(response); err != nil {
|
||||
return fmt.Errorf("decode control-plane response: %w", err)
|
||||
}
|
||||
if decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return fmt.Errorf("control-plane response contains trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SHA256Hex(value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
type SnapshotResponse struct {
|
||||
Snapshot authsnapshot.Signed `json:"snapshot"`
|
||||
PublicKeyJWK json.RawMessage `json:"public_key_jwk"`
|
||||
}
|
||||
|
||||
type RouteResolution struct {
|
||||
RelayNodeID string `json:"relay_node_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
HomeRegion string `json:"home_region"`
|
||||
Local bool `json:"local"`
|
||||
EndpointRef string `json:"endpoint_ref,omitempty"`
|
||||
}
|
||||
|
||||
func (route RouteResolution) Validate() error {
|
||||
if route.RelayNodeID == "" || route.PlacementGeneration < 1 || route.HomeRegion == "" {
|
||||
return errors.New("control plane returned an incomplete route")
|
||||
}
|
||||
if route.Local && route.EndpointRef != "" {
|
||||
return errors.New("local route unexpectedly contains an endpoint reference")
|
||||
}
|
||||
if !route.Local && route.EndpointRef == "" {
|
||||
return errors.New("remote route contains no endpoint reference")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ResolveDeviceRoute(ctx context.Context, deviceID, tokenHash string) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-device-route", map[string]string{
|
||||
"device_id": deviceID, "token_hash": tokenHash,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) ResolvePhoneRoute(ctx context.Context, routeHandle, phoneTokenHash string) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-phone-route", map[string]string{
|
||||
"route_handle": routeHandle, "phone_token_hash": phoneTokenHash,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) ResolveTenantRoute(ctx context.Context, tenantID string, generation int64) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-tenant-route", map[string]any{
|
||||
"tenant_id": tenantID, "placement_generation": generation,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) ResolveHandoffRoute(ctx context.Context, ticket, pwaOrigin string) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-handoff-route", map[string]string{
|
||||
"ticket": ticket, "pwa_origin": pwaOrigin,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID, tokenHash string) (SnapshotResponse, error) {
|
||||
var response SnapshotResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorize-device", map[string]string{
|
||||
"device_id": deviceID, "token_hash": tokenHash,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizationSnapshot(ctx context.Context, tenantID string, generation int64) (SnapshotResponse, error) {
|
||||
var response SnapshotResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorization-snapshot", map[string]any{
|
||||
"tenant_id": tenantID, "placement_generation": generation,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
type DeltaResponse struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
AuthorizationRevision int64 `json:"authorization_revision"`
|
||||
TenantStatus string `json:"tenant_status"`
|
||||
Changed bool `json:"changed"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizationDelta(ctx context.Context, tenantID string, afterRevision int64) (DeltaResponse, error) {
|
||||
var response DeltaResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorization-delta", map[string]any{
|
||||
"tenant_id": tenantID, "after_revision": afterRevision,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
type MigrationAssignment struct {
|
||||
MigrationID string `json:"migration_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Role string `json:"role"`
|
||||
SourceNodeID string `json:"source_node_id"`
|
||||
TargetNodeID string `json:"target_node_id"`
|
||||
SourceGeneration int64 `json:"source_generation"`
|
||||
TargetGeneration int64 `json:"target_generation"`
|
||||
State string `json:"state"`
|
||||
BackupRef string `json:"backup_ref,omitempty"`
|
||||
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
|
||||
FinalizeAfter string `json:"finalize_after,omitempty"`
|
||||
}
|
||||
|
||||
type PurgeAssignment struct {
|
||||
PurgeID string `json:"purge_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
}
|
||||
|
||||
type HeartbeatAssignments struct {
|
||||
Migrations []MigrationAssignment
|
||||
Purges []PurgeAssignment
|
||||
}
|
||||
|
||||
func (c *Client) Heartbeat(ctx context.Context, generation int64, capacityTenants int) (HeartbeatAssignments, error) {
|
||||
var response struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Migrations []MigrationAssignment `json:"migrations"`
|
||||
Purges []PurgeAssignment `json:"purges"`
|
||||
}
|
||||
err := c.doJSON(ctx, "/api/internal/relay/heartbeat", map[string]any{
|
||||
"generation": generation, "capacity_tenants": capacityTenants,
|
||||
}, &response, nil)
|
||||
if err == nil && !response.Accepted {
|
||||
return HeartbeatAssignments{}, errors.New("control plane did not accept relay heartbeat")
|
||||
}
|
||||
return HeartbeatAssignments{Migrations: response.Migrations, Purges: response.Purges}, err
|
||||
}
|
||||
|
||||
type MigrationAdvance struct {
|
||||
MigrationID string `json:"migration_id"`
|
||||
Action string `json:"action"`
|
||||
BackupRef string `json:"backup_ref,omitempty"`
|
||||
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) AdvanceMigration(ctx context.Context, input MigrationAdvance) error {
|
||||
var response struct {
|
||||
MigrationID string `json:"migration_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
return c.doJSON(ctx, "/api/internal/relay/migrations/advance", input, &response, nil)
|
||||
}
|
||||
|
||||
type PurgeAdvance struct {
|
||||
PurgeID string `json:"purge_id"`
|
||||
Action string `json:"action"`
|
||||
EvidenceSHA256 string `json:"evidence_sha256,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) AdvancePurge(ctx context.Context, input PurgeAdvance) error {
|
||||
var response struct {
|
||||
PurgeID string `json:"purge_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
return c.doJSON(ctx, "/api/internal/relay/purges/advance", input, &response, nil)
|
||||
}
|
||||
|
||||
type PhoneAuthorization struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
HomeRegion string `json:"home_region"`
|
||||
RelayNodeID string `json:"relay_node_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
Phone struct {
|
||||
PhoneID string `json:"phone_id"`
|
||||
Name string `json:"name"`
|
||||
Ed25519Public string `json:"ed25519_public"`
|
||||
X25519Public string `json:"x25519_public"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
} `json:"phone"`
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizePhone(ctx context.Context, routeHandle, phoneTokenHash string) (PhoneAuthorization, error) {
|
||||
var response PhoneAuthorization
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorize-phone", map[string]string{
|
||||
"route_handle": routeHandle, "phone_token_hash": phoneTokenHash,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
type ConsumedHandoff struct {
|
||||
HandoffID string `json:"handoff_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
PhoneEd25519Public string `json:"phone_ed25519_public"`
|
||||
PhoneX25519Public string `json:"phone_x25519_public"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
}
|
||||
|
||||
func (c *Client) ConsumePhoneHandoff(ctx context.Context, request any) (ConsumedHandoff, error) {
|
||||
var response ConsumedHandoff
|
||||
err := c.doJSON(ctx, "/api/internal/relay/consume-phone-handoff", request, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) CompletePhoneHandoff(ctx context.Context, handoffID, phoneID, phoneTokenHash, routeHandleHash string) error {
|
||||
var response struct {
|
||||
Completed bool `json:"completed"`
|
||||
}
|
||||
err := c.doJSON(ctx, "/api/internal/relay/complete-phone-handoff", map[string]string{
|
||||
"handoff_id": handoffID,
|
||||
"phone_id": phoneID,
|
||||
"phone_token_hash": phoneTokenHash,
|
||||
"route_handle_hash": routeHandleHash,
|
||||
}, &response, nil)
|
||||
if err == nil && !response.Completed {
|
||||
return errors.New("control plane did not complete phone handoff")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) RevokePhone(ctx context.Context, tenantID, phoneID, reason string) error {
|
||||
var response struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
err := c.doJSON(ctx, "/api/internal/relay/revoke-phone", map[string]string{
|
||||
"tenant_id": tenantID, "phone_id": phoneID, "reason": reason,
|
||||
}, &response, nil)
|
||||
if err == nil && !response.Revoked {
|
||||
return errors.New("control plane did not revoke phone")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) RegisterDevice(ctx context.Context, bootstrap, sourceHash string, request json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(request, &decoded); err != nil {
|
||||
return protocol.DeviceRegistrationResponse{}, errors.New("invalid registration JSON")
|
||||
}
|
||||
decoded["bootstrap_token"] = bootstrap
|
||||
decoded["source_hash"] = sourceHash
|
||||
var response protocol.DeviceRegistrationResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/register-device", decoded, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
Reference in New Issue
Block a user