初版功能完成
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
+117
View File
@@ -0,0 +1,117 @@
package bootstrap
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
appconfig "remlink/internal/config"
)
const maxResponseBody = 1 << 20
// Client calls the public Bootstrap API before Overlay connectivity exists.
type Client struct {
baseURL string
http *http.Client
}
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if err := appconfig.ValidateServerURL(baseURL); err != nil {
return nil, fmt.Errorf("invalid Bootstrap base URL: %w", err)
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
return &Client{baseURL: baseURL, http: httpClient}, nil
}
func (c *Client) ServerInfo(ctx context.Context) (ServerInfo, error) {
var response ServerInfo
if err := c.do(ctx, http.MethodGet, "/api/v1/server/info", nil, &response); err != nil {
return ServerInfo{}, err
}
return response, nil
}
func (c *Client) Register(ctx context.Context, request RegisterRequest) (RegisterResponse, error) {
var response RegisterResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/bootstrap/register", request, &response); err != nil {
return RegisterResponse{}, err
}
return response, nil
}
func (c *Client) Config(ctx context.Context, request ConfigRequest) (ConfigResponse, error) {
var response ConfigResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/bootstrap/config", request, &response); err != nil {
return ConfigResponse{}, err
}
return response, nil
}
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("encode Bootstrap request: %w", err)
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("create Bootstrap request: %w", err)
}
request.Header.Set("Accept", "application/json")
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("call Bootstrap API: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBody+1)
raw, err := io.ReadAll(limited)
if err != nil {
return fmt.Errorf("read Bootstrap response: %w", err)
}
if len(raw) > maxResponseBody {
return errors.New("Bootstrap response exceeds 1 MiB")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
var envelope apiErrorEnvelope
if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Code != "" {
return &ClientError{Status: response.StatusCode, Code: envelope.Error.Code, Message: envelope.Error.Message}
}
return &ClientError{Status: response.StatusCode, Code: "HTTP_ERROR", Message: strings.TrimSpace(string(raw))}
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
return fmt.Errorf("decode Bootstrap response: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("Bootstrap response must contain one JSON value")
}
return nil
}
// ClientError preserves the API status and machine-readable code.
type ClientError struct {
Status int
Code string
Message string
}
func (e *ClientError) Error() string {
return fmt.Sprintf("Bootstrap API %s (HTTP %d): %s", e.Code, e.Status, e.Message)
}