git init
This commit is contained in:
149
control-plane/backend/agent.go
Normal file
149
control-plane/backend/agent.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxAgentResponse = 2 << 20
|
||||
|
||||
type AgentClient struct {
|
||||
http *http.Client
|
||||
credentialsDir string
|
||||
}
|
||||
|
||||
func NewAgentClient(credentialsDir string) *AgentClient {
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
MaxConnsPerHost: 8,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
return &AgentClient{
|
||||
http: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 7 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("agent redirects are disabled")
|
||||
},
|
||||
},
|
||||
credentialsDir: credentialsDir,
|
||||
}
|
||||
}
|
||||
|
||||
func validateAgentEndpoint(endpoint string) error {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return errors.New("invalid URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return errors.New("scheme must be http or https")
|
||||
}
|
||||
if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return errors.New("endpoint must contain only scheme, private IP, and port")
|
||||
}
|
||||
if parsed.Port() == "" {
|
||||
return errors.New("endpoint must include a port")
|
||||
}
|
||||
ip := net.ParseIP(parsed.Hostname())
|
||||
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
|
||||
return errors.New("endpoint must use a private literal IP address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCredentialRef(reference string) bool {
|
||||
if len(reference) < 1 || len(reference) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range reference {
|
||||
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
|
||||
!(char >= '0' && char <= '9') && char != '_' && char != '-' && char != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return reference != "." && reference != ".."
|
||||
}
|
||||
|
||||
func (client *AgentClient) credential(reference string) (string, error) {
|
||||
if !validCredentialRef(reference) {
|
||||
return "", errors.New("invalid credential reference")
|
||||
}
|
||||
path := filepath.Join(client.credentialsDir, reference)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return "", errors.New("credential must be a regular file")
|
||||
}
|
||||
// Docker Compose file-backed secrets are mounted read-only as 0444 on some
|
||||
// engines. Accept read-only mounts, but never a group/world-writable secret.
|
||||
if info.Mode().Perm()&0o022 != 0 {
|
||||
return "", errors.New("credential file must not be group/world writable")
|
||||
}
|
||||
if info.Size() > 4096 {
|
||||
return "", errors.New("credential file is too large")
|
||||
}
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := strings.TrimSpace(string(value))
|
||||
if len(token) < 32 {
|
||||
return "", errors.New("credential is too short")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *AgentClient) Do(ctx context.Context, server Server, method, path string, body []byte) (int, []byte, error) {
|
||||
if err := validateAgentEndpoint(server.AgentEndpoint); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
return 0, nil, errors.New("invalid agent API path")
|
||||
}
|
||||
token, err := client.credential(server.CredentialRef)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("load agent credential: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method,
|
||||
strings.TrimRight(server.AgentEndpoint, "/")+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := client.http.Do(request)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
limited := io.LimitReader(response.Body, maxAgentResponse+1)
|
||||
payload, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(payload) > maxAgentResponse {
|
||||
return 0, nil, errors.New("agent response is too large")
|
||||
}
|
||||
return response.StatusCode, payload, nil
|
||||
}
|
||||
Reference in New Issue
Block a user