HH-803: add stable network exit orchestration (#19)
This commit is contained in:
+1151
-119
File diff suppressed because it is too large
Load Diff
+2596
-47
File diff suppressed because it is too large
Load Diff
@@ -66,15 +66,39 @@ func newCommand() *cobra.Command {
|
||||
}
|
||||
defer hubStore.Close()
|
||||
logStartup(cfg)
|
||||
return newHandlerWithStores(cfg.webDir, phaseAStore, hubStore).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||
heartbeatContext, stopHeartbeat := context.WithCancel(command.Context())
|
||||
heartbeatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(heartbeatDone)
|
||||
runtimeLeaseHeartbeat(heartbeatContext, hubStore)
|
||||
}()
|
||||
listenErr := newHandlerWithStores(cfg.webDir, phaseAStore, hubStore).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||
GracefulContext: command.Context(),
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
stopHeartbeat()
|
||||
<-heartbeatDone
|
||||
return listenErr
|
||||
},
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func runtimeLeaseHeartbeat(ctx context.Context, store hubStore) {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := reconcileRuntimeLeases(ctx, store, defaultNetworkExitProbe(), resolveExitCredential); err != nil && ctx.Err() == nil {
|
||||
logrus.WithField("service", "control-plane").WithError(err).Warn("runtime lease reconciliation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logStartup(cfg config) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"service": "control-plane",
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
)
|
||||
|
||||
const networkExitObservationURL = "https://ipinfo.io/json"
|
||||
|
||||
type networkExitProbe interface {
|
||||
Check(context.Context, hub.NetworkExitAccess) (hub.ExitObservation, string)
|
||||
}
|
||||
|
||||
type httpNetworkExitProbe struct {
|
||||
endpoint string
|
||||
client *http.Client
|
||||
resolve func(hub.NetworkExitAccess) (string, error)
|
||||
}
|
||||
|
||||
func defaultNetworkExitProbe() networkExitProbe {
|
||||
return httpNetworkExitProbe{endpoint: networkExitObservationURL, client: &http.Client{Timeout: 20 * time.Second}, resolve: resolveExitCredential}
|
||||
}
|
||||
|
||||
func (probe httpNetworkExitProbe) Check(ctx context.Context, exit hub.NetworkExitAccess) (hub.ExitObservation, string) {
|
||||
proxyURL := &url.URL{Scheme: exit.Protocol, Host: net.JoinHostPort(exit.Host, fmt.Sprint(exit.Port))}
|
||||
proxyUsername := ""
|
||||
if exit.CredentialReference != nil {
|
||||
secret, err := probe.resolve(exit)
|
||||
if err != nil {
|
||||
return hub.ExitObservation{}, "credential_unavailable"
|
||||
}
|
||||
username, password, found := strings.Cut(secret, ":")
|
||||
if !found || username == "" {
|
||||
return hub.ExitObservation{}, "credential_invalid"
|
||||
}
|
||||
proxyUsername = username
|
||||
proxyURL.User = url.UserPassword(username, password)
|
||||
}
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
if exit.Protocol == "socks4" {
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = socks4DialContext(proxyURL.Host, proxyUsername)
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := *probe.client
|
||||
client.Transport = transport
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, probe.endpoint, nil)
|
||||
if err != nil {
|
||||
return hub.ExitObservation{}, "proxy_check_failed"
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "auth") {
|
||||
return hub.ExitObservation{}, "proxy_auth_failed"
|
||||
}
|
||||
return hub.ExitObservation{}, "proxy_check_failed"
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusProxyAuthRequired {
|
||||
return hub.ExitObservation{}, "proxy_auth_failed"
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return hub.ExitObservation{}, "proxy_check_failed"
|
||||
}
|
||||
var observed struct {
|
||||
IP string `json:"ip"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, 64<<10))
|
||||
if err := decoder.Decode(&observed); err != nil || net.ParseIP(observed.IP) == nil || len(observed.Region) > 64 {
|
||||
return hub.ExitObservation{}, "exit_observation_invalid"
|
||||
}
|
||||
return hub.ExitObservation{PublicIP: observed.IP, Region: observed.Region}, ""
|
||||
}
|
||||
|
||||
func socks4DialContext(proxyAddress, userID string) func(context.Context, string, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, network, proxyAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
failed := true
|
||||
defer func() {
|
||||
if failed {
|
||||
_ = connection.Close()
|
||||
}
|
||||
}()
|
||||
host, portText, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid SOCKS4 destination")
|
||||
}
|
||||
port, err := net.LookupPort("tcp", portText)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid SOCKS4 destination port")
|
||||
}
|
||||
request := []byte{4, 1, 0, 0, 0, 0, 0, 1}
|
||||
binary.BigEndian.PutUint16(request[2:4], uint16(port))
|
||||
if ip := net.ParseIP(host).To4(); ip != nil {
|
||||
copy(request[4:8], ip)
|
||||
}
|
||||
request = append(request, userID...)
|
||||
request = append(request, 0)
|
||||
if net.ParseIP(host).To4() == nil {
|
||||
request = append(request, host...)
|
||||
request = append(request, 0)
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := connection.Write(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := make([]byte, 8)
|
||||
if _, err := io.ReadFull(connection, response); err != nil || response[1] != 90 {
|
||||
return nil, errors.New("SOCKS4 proxy rejected connection")
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
failed = false
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Secret managers and keyring bridges inject the referenced value at process start.
|
||||
// Only the resolved username:password value is kept in the request-local call stack.
|
||||
func resolveExitCredential(exit hub.NetworkExitAccess) (string, error) {
|
||||
if exit.CredentialReference == nil || exit.CredentialKey == "" {
|
||||
return "", errors.New("credential reference unavailable")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(exit.CredentialKey))
|
||||
name := "CREATORHUB_CREDENTIAL_" + strings.ToUpper(hex.EncodeToString(digest[:]))
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok || value == "" {
|
||||
return "", errors.New("credential value unavailable")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type gatewayNetworkExit struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func gatewayNetworkExitFor(exit hub.NetworkExitAccess, resolve func(hub.NetworkExitAccess) (string, error)) (gatewayNetworkExit, error) {
|
||||
result := gatewayNetworkExit{Protocol: exit.Protocol, Host: exit.Host, Port: exit.Port}
|
||||
if exit.CredentialReference == nil {
|
||||
return result, nil
|
||||
}
|
||||
secret, err := resolve(exit)
|
||||
if err != nil {
|
||||
return gatewayNetworkExit{}, errors.New("credential unavailable")
|
||||
}
|
||||
username, password, found := strings.Cut(secret, ":")
|
||||
if !found || username == "" {
|
||||
return gatewayNetworkExit{}, errors.New("credential invalid")
|
||||
}
|
||||
result.Username, result.Password = username, password
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
)
|
||||
|
||||
func TestSOCKS4DialerUsesBoundProxy(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
header := make([]byte, 8)
|
||||
if _, err := io.ReadFull(connection, header); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
user := make([]byte, 0, 16)
|
||||
for {
|
||||
var value [1]byte
|
||||
if _, err := io.ReadFull(connection, value[:]); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
if value[0] == 0 {
|
||||
break
|
||||
}
|
||||
user = append(user, value[0])
|
||||
}
|
||||
if header[0] != 4 || header[1] != 1 || binary.BigEndian.Uint16(header[2:4]) != 443 ||
|
||||
net.IP(header[4:8]).String() != "203.0.113.1" || string(user) != "operator" {
|
||||
done <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
_, err = connection.Write([]byte{0, 90, 0, 0, 0, 0, 0, 0})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
connection, err := socks4DialContext(listener.Addr().String(), "operator")(context.Background(), "tcp", "203.0.113.1:443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = connection.Close()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayNetworkExitResolvesCredentialWithoutPersistingIt(t *testing.T) {
|
||||
exit := hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{
|
||||
Protocol: "socks5", Host: "proxy.example", Port: 1080,
|
||||
CredentialReference: &hub.CredentialReference{ID: "credential-a", Provider: "os_keyring"},
|
||||
}}
|
||||
gatewayExit, err := gatewayNetworkExitFor(exit, func(hub.NetworkExitAccess) (string, error) {
|
||||
return "operator:ephemeral-value", nil
|
||||
})
|
||||
if err != nil || gatewayExit.Username != "operator" || gatewayExit.Password != "ephemeral-value" || gatewayExit.Host != "proxy.example" {
|
||||
t.Fatalf("credential was not resolved into the request-local gateway payload: %#v err=%v", gatewayExit, err)
|
||||
}
|
||||
encoded := string(mustJSON(t, exit.NetworkExit))
|
||||
if strings.Contains(encoded, "ephemeral-value") {
|
||||
t.Fatalf("network exit persistence model contains resolved credential: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
+301
-85
@@ -27,29 +27,33 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
browserUser = "1000:1000"
|
||||
browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh"
|
||||
managedLabel = "io.creatorhub.managed"
|
||||
idLabel = "io.creatorhub.runtime-id"
|
||||
nameLabel = "io.creatorhub.display-name"
|
||||
networkRoleLabel = "io.creatorhub.network-role"
|
||||
browserNetworkRole = "browser"
|
||||
controlNetworkName = "creatorhub_control"
|
||||
namePrefix = "creatorhub-browser-"
|
||||
pullTimeout = 10 * time.Minute
|
||||
browserUser = "1000:1000"
|
||||
browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh"
|
||||
managedLabel = "io.creatorhub.managed"
|
||||
idLabel = "io.creatorhub.runtime-id"
|
||||
nameLabel = "io.creatorhub.display-name"
|
||||
bindingVersionLabel = "io.creatorhub.binding-version"
|
||||
networkExitLabel = "io.creatorhub.network-exit-id"
|
||||
proxyPortLabel = "io.creatorhub.proxy-port"
|
||||
networkRoleLabel = "io.creatorhub.network-role"
|
||||
browserNetworkRole = "browser"
|
||||
controlNetworkName = "creatorhub_control"
|
||||
namePrefix = "creatorhub-browser-"
|
||||
pullTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`)
|
||||
volumePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`)
|
||||
volumePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidRuntimeID = errors.New("invalid runtime id")
|
||||
errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub")
|
||||
errUnauthorized = errors.New("gateway token rejected")
|
||||
errInvalidRuntimeID = errors.New("invalid runtime id")
|
||||
errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub")
|
||||
errUnauthorized = errors.New("gateway token rejected")
|
||||
)
|
||||
|
||||
type serviceConfig struct {
|
||||
@@ -69,25 +73,42 @@ type dockerClient struct {
|
||||
type gateway struct {
|
||||
docker dockerClient
|
||||
network string
|
||||
self string
|
||||
token string
|
||||
proxies *memoryProxyRegistry
|
||||
}
|
||||
|
||||
// createRequest 全量字段由平台下发;网关不做业务决策,只做输入合法性校验。
|
||||
type createRequest struct {
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Cmd []string `json:"cmd"`
|
||||
Volume string `json:"volume"`
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Cmd []string `json:"cmd"`
|
||||
Volume string `json:"volume"`
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
NetworkExit gatewayProxyExit `json:"network_exit"`
|
||||
Stopped bool `json:"stopped,omitempty"`
|
||||
}
|
||||
|
||||
type gatewayProxyExit struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type browser struct {
|
||||
ID string `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ID string `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
ProxyReady bool `json:"proxy_ready"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -184,9 +205,6 @@ func run(command *cobra.Command, cfg serviceConfig) error {
|
||||
client: &http.Client{Transport: transport, Timeout: 30 * time.Second},
|
||||
slow: &http.Client{Transport: transport},
|
||||
}
|
||||
if err := docker.ensureBrowserNetwork(cfg.network); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"service": "docker-gateway",
|
||||
"listen_addr": cfg.listenAddr,
|
||||
@@ -199,7 +217,12 @@ func run(command *cobra.Command, cfg serviceConfig) error {
|
||||
}
|
||||
|
||||
func newGateway(client dockerClient, network, token string) *fiber.App {
|
||||
api := gateway{docker: client, network: network, token: token}
|
||||
self, _ := os.Hostname()
|
||||
return newGatewayWithSelf(client, network, token, self)
|
||||
}
|
||||
|
||||
func newGatewayWithSelf(client dockerClient, network, token, self string) *fiber.App {
|
||||
api := gateway{docker: client, network: network, self: self, token: token, proxies: newMemoryProxyRegistry()}
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "CreatorHub Docker gateway",
|
||||
BodyLimit: 1 << 20,
|
||||
@@ -221,6 +244,7 @@ func newGateway(client dockerClient, network, token string) *fiber.App {
|
||||
app.Use("/v1", api.authorize)
|
||||
app.Get("/v1/browsers", api.list)
|
||||
app.Post("/v1/browsers", api.create)
|
||||
app.Post("/v1/browsers/:id/proxy", api.restoreProxy)
|
||||
app.Post("/v1/browsers/:id/:action", api.changeState)
|
||||
app.Delete("/v1/browsers/:id", api.remove)
|
||||
return app
|
||||
@@ -265,13 +289,18 @@ func (api gateway) list(c fiber.Ctx) error {
|
||||
if name == "" {
|
||||
name = alias
|
||||
}
|
||||
bindingVersion, _ := strconv.ParseInt(container.Labels[bindingVersionLabel], 10, 64)
|
||||
proxyPort, _ := strconv.Atoi(container.Labels[proxyPortLabel])
|
||||
browsers = append(browsers, browser{
|
||||
ID: container.ID,
|
||||
Alias: alias,
|
||||
Name: name,
|
||||
State: container.State,
|
||||
Status: container.Status,
|
||||
Endpoint: "http://" + namePrefix + alias + ":9222",
|
||||
ID: container.ID,
|
||||
Alias: alias,
|
||||
Name: name,
|
||||
State: container.State,
|
||||
Status: container.Status,
|
||||
Endpoint: "http://" + namePrefix + alias + ":9222",
|
||||
BindingVersion: bindingVersion,
|
||||
NetworkExitID: container.Labels[networkExitLabel],
|
||||
ProxyReady: api.proxies.ready(alias, proxyPort),
|
||||
})
|
||||
}
|
||||
return writeJSON(c, http.StatusOK, browsers)
|
||||
@@ -282,7 +311,7 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, errors.New("body must contain only alias, name, image, cmd and volume"))
|
||||
return writeError(c, http.StatusBadRequest, errors.New("body must contain only alias, name, image, cmd, volume, binding_version, network_exit_id, network_exit and stopped"))
|
||||
}
|
||||
if err := validateCreate(input); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, err)
|
||||
@@ -290,22 +319,48 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
if err := api.docker.pullIfMissing(c.Context(), input.Image); err != nil {
|
||||
return writeError(c, http.StatusBadGateway, err)
|
||||
}
|
||||
network, proxyServer, undoProxy := "none", "", func() {}
|
||||
if !input.Stopped {
|
||||
var err error
|
||||
var bindHost string
|
||||
network, bindHost, err = api.docker.ensureTenantNetwork(api.network, input.Alias, api.self)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("configure isolated browser network"))
|
||||
}
|
||||
proxyServer, undoProxy, err = api.proxies.configure(input.Alias, bindHost, 0, input.NetworkExit)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("configure in-memory proxy"))
|
||||
}
|
||||
}
|
||||
keepProxy := false
|
||||
defer func() {
|
||||
if !keepProxy {
|
||||
undoProxy()
|
||||
}
|
||||
}()
|
||||
|
||||
pidsLimit := int64(512)
|
||||
cmd := append([]string{}, input.Cmd...)
|
||||
if !input.Stopped {
|
||||
cmd = append(cmd[:len(cmd)-1], "--proxy-server="+proxyServer, "--disable-non-proxied-udp", cmd[len(cmd)-1])
|
||||
}
|
||||
payload := map[string]any{
|
||||
"Image": input.Image,
|
||||
"User": browserUser,
|
||||
"Entrypoint": []string{browserEntrypoint},
|
||||
"Cmd": input.Cmd,
|
||||
"Cmd": cmd,
|
||||
"Env": []string{"REMOTE_DEBUGGING_PORT=9222"},
|
||||
"Labels": map[string]string{
|
||||
managedLabel: "true",
|
||||
idLabel: input.Alias,
|
||||
nameLabel: input.Name,
|
||||
managedLabel: "true",
|
||||
idLabel: input.Alias,
|
||||
nameLabel: input.Name,
|
||||
bindingVersionLabel: strconv.FormatInt(input.BindingVersion, 10),
|
||||
networkExitLabel: input.NetworkExitID,
|
||||
proxyPortLabel: strconv.Itoa(proxyPort(proxyServer)),
|
||||
},
|
||||
"ExposedPorts": map[string]any{"9222/tcp": map[string]any{}},
|
||||
"HostConfig": map[string]any{
|
||||
"NetworkMode": api.network,
|
||||
"NetworkMode": network,
|
||||
"ReadonlyRootfs": true,
|
||||
"CapDrop": []string{"ALL"},
|
||||
"SecurityOpt": []string{"no-new-privileges"},
|
||||
@@ -331,7 +386,11 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
}
|
||||
defer result.Body.Close()
|
||||
if result.StatusCode != http.StatusCreated {
|
||||
return forwardDockerError(c, result)
|
||||
status := http.StatusBadGateway
|
||||
if result.StatusCode == http.StatusConflict {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
return writeError(c, status, errors.New("Docker container creation failed"))
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"Id"`
|
||||
@@ -343,13 +402,16 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
}
|
||||
return writeError(c, http.StatusBadGateway, errors.New("Docker returned an invalid container id; container was removed while preserving its Profile volume"))
|
||||
}
|
||||
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
||||
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
||||
if cleanupErr != nil {
|
||||
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr))
|
||||
if !input.Stopped {
|
||||
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
||||
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
||||
if cleanupErr != nil {
|
||||
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr))
|
||||
}
|
||||
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err))
|
||||
}
|
||||
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err))
|
||||
}
|
||||
keepProxy = !input.Stopped
|
||||
return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias})
|
||||
}
|
||||
|
||||
@@ -366,7 +428,11 @@ func validateCreate(input createRequest) error {
|
||||
if !volumePattern.MatchString(input.Volume) {
|
||||
return errors.New("volume must be a valid volume name")
|
||||
}
|
||||
if len(input.Cmd) == 0 || len(input.Cmd) > 64 {
|
||||
if input.BindingVersion < 1 || (!input.Stopped && !exitIDPattern.MatchString(input.NetworkExitID)) ||
|
||||
(input.Stopped && (input.NetworkExitID != "" || input.NetworkExit != (gatewayProxyExit{}))) {
|
||||
return errors.New("binding_version and network_exit_id must identify the current binding")
|
||||
}
|
||||
if len(input.Cmd) == 0 || len(input.Cmd) > 64 || input.Cmd[len(input.Cmd)-1] != "about:blank" {
|
||||
return errors.New("cmd must contain 1..64 arguments")
|
||||
}
|
||||
total := 0
|
||||
@@ -374,14 +440,34 @@ func validateCreate(input createRequest) error {
|
||||
if arg == "" || hasControlRunes(arg) {
|
||||
return errors.New("cmd arguments must be non-empty visible strings")
|
||||
}
|
||||
if strings.HasPrefix(arg, "--proxy-server") || arg == "--disable-non-proxied-udp" {
|
||||
return errors.New("proxy arguments are platform-controlled")
|
||||
}
|
||||
total += len(arg)
|
||||
}
|
||||
if total > 4096 {
|
||||
return errors.New("cmd arguments exceed 4096 characters")
|
||||
}
|
||||
if input.Stopped {
|
||||
return nil
|
||||
}
|
||||
proxy := input.NetworkExit
|
||||
if (proxy.Protocol != "http" && proxy.Protocol != "https" && proxy.Protocol != "socks4" && proxy.Protocol != "socks5") ||
|
||||
proxy.Host == "" || len(proxy.Host) > 253 || strings.ContainsAny(proxy.Host, "@/[]?# \t\r\n") ||
|
||||
proxy.Port < 1 || proxy.Port > 65535 || (proxy.Username == "" && proxy.Password != "") ||
|
||||
len(proxy.Username) > 255 || len(proxy.Password) > 255 ||
|
||||
hasControlRunes(proxy.Username) || hasControlRunes(proxy.Password) {
|
||||
return errors.New("network_exit must contain a valid proxy endpoint and optional credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func proxyPort(proxyServer string) int {
|
||||
parsed, _ := url.Parse(proxyServer)
|
||||
port, _ := strconv.Atoi(parsed.Port())
|
||||
return port
|
||||
}
|
||||
|
||||
func hasControlRunes(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
@@ -415,31 +501,77 @@ func (api gateway) changeState(c fiber.Ctx) error {
|
||||
|
||||
func (api gateway) remove(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := api.requireManaged(id); err != nil {
|
||||
err := api.requireManaged(id)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
|
||||
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil {
|
||||
return writeError(c, http.StatusBadGateway, err)
|
||||
if err == nil {
|
||||
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
|
||||
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
|
||||
return writeError(c, http.StatusBadGateway, err)
|
||||
}
|
||||
}
|
||||
api.proxies.remove(id)
|
||||
if err := api.docker.removeTenantNetwork(api.network, id, api.self); err != nil {
|
||||
return c.Status(http.StatusAccepted).JSON(map[string]string{
|
||||
"status": "container_removed_network_cleanup_pending",
|
||||
})
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api gateway) restoreProxy(c fiber.Ctx) error {
|
||||
input := struct {
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
NetworkExit gatewayProxyExit `json:"network_exit"`
|
||||
}{}
|
||||
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil || input.BindingVersion < 1 || !exitIDPattern.MatchString(input.NetworkExitID) ||
|
||||
validateCreate(createRequest{Alias: c.Params("id"), Name: "x", Image: "x", Cmd: []string{"about:blank"}, Volume: "x",
|
||||
BindingVersion: input.BindingVersion, NetworkExitID: input.NetworkExitID, NetworkExit: input.NetworkExit}) != nil {
|
||||
return writeError(c, http.StatusBadRequest, errors.New("invalid proxy recovery request"))
|
||||
}
|
||||
labels, err := api.managedLabels(c.Params("id"))
|
||||
if err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
|
||||
port, _ := strconv.Atoi(labels[proxyPortLabel])
|
||||
if version != input.BindingVersion || labels[networkExitLabel] != input.NetworkExitID || port < 1 {
|
||||
return writeError(c, http.StatusConflict, errors.New("container binding does not match recovery request"))
|
||||
}
|
||||
_, bindHost, err := api.docker.ensureTenantNetwork(api.network, c.Params("id"), api.self)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("restore isolated browser network"))
|
||||
}
|
||||
if _, _, err := api.proxies.configure(c.Params("id"), bindHost, port, input.NetworkExit); err != nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("restore in-memory proxy"))
|
||||
}
|
||||
return c.SendStatus(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api gateway) requireManaged(id string) error {
|
||||
_, err := api.managedLabels(id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (api gateway) managedLabels(id string) (map[string]string, error) {
|
||||
if !runtimeIDPattern.MatchString(id) {
|
||||
return errInvalidRuntimeID
|
||||
return nil, errInvalidRuntimeID
|
||||
}
|
||||
result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer result.Body.Close()
|
||||
if result.StatusCode == http.StatusNotFound {
|
||||
return os.ErrNotExist
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if result.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Docker inspect returned %s", result.Status)
|
||||
return nil, fmt.Errorf("Docker inspect returned %s", result.Status)
|
||||
}
|
||||
var inspected struct {
|
||||
Config struct {
|
||||
@@ -447,12 +579,12 @@ func (api gateway) requireManaged(id string) error {
|
||||
} `json:"Config"`
|
||||
}
|
||||
if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil {
|
||||
return fmt.Errorf("decode Docker inspect: %w", err)
|
||||
return nil, fmt.Errorf("decode Docker inspect: %w", err)
|
||||
}
|
||||
if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
|
||||
return errUnmanagedContainer
|
||||
return nil, errUnmanagedContainer
|
||||
}
|
||||
return nil
|
||||
return inspected.Config.Labels, nil
|
||||
}
|
||||
|
||||
// pullIfMissing 在镜像不在本地时从远端仓库拉取;镜像缺失属于可恢复错误,调用方可直接重试。
|
||||
@@ -541,16 +673,34 @@ func (docker dockerClient) expect(method, path string, payload any, allowed ...i
|
||||
return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message)))
|
||||
}
|
||||
|
||||
func (docker dockerClient) ensureBrowserNetwork(name string) error {
|
||||
if err := validateBrowserNetwork(name); err != nil {
|
||||
return err
|
||||
func tenantNetworkName(base, alias string) (string, error) {
|
||||
name := base + "-" + alias
|
||||
if !networkNamePattern.MatchString(name) {
|
||||
return "", errors.New("isolated browser network name is invalid")
|
||||
}
|
||||
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect browser network: %w", err)
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (docker dockerClient) ensureTenantNetwork(base, alias, self string) (string, string, error) {
|
||||
name, err := tenantNetworkName(base, alias)
|
||||
if err != nil || self == "" {
|
||||
return "", "", errors.New("isolated browser network identity is invalid")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK {
|
||||
inspect := func() (map[string]struct {
|
||||
Name string `json:"Name"`
|
||||
IPv4Address string `json:"IPv4Address"`
|
||||
}, error) {
|
||||
response, requestErr := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
|
||||
if requestErr != nil {
|
||||
return nil, requestErr
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("inspect isolated browser network returned %s", response.Status)
|
||||
}
|
||||
var network struct {
|
||||
Name string `json:"Name"`
|
||||
Driver string `json:"Driver"`
|
||||
@@ -558,33 +708,99 @@ func (docker dockerClient) ensureBrowserNetwork(name string) error {
|
||||
Attachable bool `json:"Attachable"`
|
||||
Ingress bool `json:"Ingress"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Containers map[string]struct {
|
||||
Name string `json:"Name"`
|
||||
IPv4Address string `json:"IPv4Address"`
|
||||
} `json:"Containers"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&network); err != nil {
|
||||
return fmt.Errorf("decode browser network: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if network.Name != name || network.Driver != "bridge" || network.Internal || network.Attachable || network.Ingress ||
|
||||
network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole {
|
||||
return fmt.Errorf("browser network %q is not a CreatorHub bridge", name)
|
||||
network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole || network.Labels[idLabel] != alias {
|
||||
return nil, errors.New("isolated browser network is not owned by this runtime")
|
||||
}
|
||||
return network.Containers, nil
|
||||
}
|
||||
containers, err := inspect()
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := docker.expect(http.MethodPost, "/networks/create", map[string]any{
|
||||
"Name": name, "CheckDuplicate": true, "Driver": "bridge",
|
||||
"Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias},
|
||||
}, http.StatusCreated); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
containers = map[string]struct {
|
||||
Name string `json:"Name"`
|
||||
IPv4Address string `json:"IPv4Address"`
|
||||
}{}
|
||||
} else if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
findIP := func() string {
|
||||
for id, container := range containers {
|
||||
if id == self || strings.HasPrefix(id, self) || strings.HasPrefix(self, id) || container.Name == self {
|
||||
host, _, _ := net.ParseCIDR(container.IPv4Address)
|
||||
if host != nil {
|
||||
return host.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if bindHost := findIP(); bindHost != "" {
|
||||
return name, bindHost, nil
|
||||
}
|
||||
if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(name)+"/connect", map[string]any{
|
||||
"Container": self, "EndpointConfig": map[string]any{"Aliases": []string{browserProxyHost}},
|
||||
}, http.StatusOK); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
containers, err = inspect()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if bindHost := findIP(); bindHost != "" {
|
||||
return name, bindHost, nil
|
||||
}
|
||||
return "", "", errors.New("Docker did not assign the gateway an isolated network address")
|
||||
}
|
||||
|
||||
func (docker dockerClient) removeTenantNetwork(base, alias, self string) error {
|
||||
name, err := tenantNetworkName(base, alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
return fmt.Errorf("inspect browser network returned %s", response.Status)
|
||||
var network struct {
|
||||
Name string `json:"Name"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
return docker.expect(http.MethodPost, "/networks/create", map[string]any{
|
||||
"Name": name,
|
||||
"CheckDuplicate": true,
|
||||
"Driver": "bridge",
|
||||
"Labels": map[string]string{
|
||||
managedLabel: "true",
|
||||
networkRoleLabel: browserNetworkRole,
|
||||
},
|
||||
}, http.StatusCreated)
|
||||
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&network) != nil || network.Name != name ||
|
||||
network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole || network.Labels[idLabel] != alias {
|
||||
return errors.New("refusing to remove an unowned browser network")
|
||||
}
|
||||
if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(name)+"/disconnect", map[string]any{
|
||||
"Container": self, "Force": true,
|
||||
}, http.StatusOK, http.StatusNotFound); err != nil {
|
||||
return fmt.Errorf("disconnect isolated browser network: %w", err)
|
||||
}
|
||||
if err := docker.expect(http.MethodDelete, "/networks/"+url.PathEscape(name), nil, http.StatusNoContent, http.StatusNotFound); err != nil {
|
||||
return fmt.Errorf("remove isolated browser network: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBrowserNetwork(name string) error {
|
||||
if !networkNamePattern.MatchString(name) {
|
||||
return errors.New("BROWSER_NETWORK is invalid")
|
||||
if !networkNamePattern.MatchString(name) || len(name) > 31 {
|
||||
return errors.New("BROWSER_NETWORK must be a valid network prefix of at most 31 characters")
|
||||
}
|
||||
if name == controlNetworkName {
|
||||
return errors.New("BROWSER_NETWORK must not reuse the control network")
|
||||
|
||||
+251
-92
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,7 +25,27 @@ func authed(method, target string, body io.Reader) *http.Request {
|
||||
}
|
||||
|
||||
func testDocker(handler http.HandlerFunc) (dockerClient, *httptest.Server) {
|
||||
server := httptest.NewServer(handler)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if strings.HasPrefix(request.URL.Path, "/networks/creatorhub_browser-") {
|
||||
if request.Method != http.MethodGet {
|
||||
if request.Method == http.MethodDelete {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
} else {
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return
|
||||
}
|
||||
alias := strings.TrimPrefix(request.URL.Path, "/networks/creatorhub_browser-")
|
||||
self, _ := os.Hostname()
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{
|
||||
"Name": "creatorhub_browser-" + alias, "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
|
||||
"Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias},
|
||||
"Containers": map[string]any{self: map[string]string{"Name": self, "IPv4Address": "127.0.0.1/8"}},
|
||||
})
|
||||
return
|
||||
}
|
||||
handler(response, request)
|
||||
}))
|
||||
return dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, server
|
||||
}
|
||||
|
||||
@@ -35,7 +59,43 @@ func decodeJSONBody(t *testing.T, response *http.Response) map[string]any {
|
||||
}
|
||||
|
||||
const testCreateBody = `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` +
|
||||
`"cmd":["--fingerprint=1000","--lang=zh-CN","about:blank"],"volume":"creatorhub-profile-account-a"}`
|
||||
`"cmd":["--fingerprint=1000","--lang=zh-CN","about:blank"],"volume":"creatorhub-profile-account-a",` +
|
||||
`"binding_version":1,"network_exit_id":"exit-1",` +
|
||||
`"network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
|
||||
|
||||
func TestGatewayCreatesNetworkDisabledStoppedRecoveryContainer(t *testing.T) {
|
||||
created := false
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
|
||||
_, _ = response.Write([]byte(`{}`))
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||
host := payload["HostConfig"].(map[string]any)
|
||||
labels := payload["Labels"].(map[string]any)
|
||||
encoded, _ := json.Marshal(payload["Cmd"])
|
||||
if host["NetworkMode"] != "none" || labels[networkExitLabel] != "" || strings.Contains(string(encoded), "proxy") {
|
||||
t.Fatalf("unsafe stopped recovery payload: %#v", payload)
|
||||
}
|
||||
created = true
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
_, _ = response.Write([]byte(`{"Id":"stopped-container"}`))
|
||||
default:
|
||||
t.Fatalf("stopped recovery unexpectedly called Docker %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
handler := newGateway(docker, "creatorhub_browser", testToken)
|
||||
body := `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` +
|
||||
`"cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` +
|
||||
`"binding_version":1,"network_exit_id":"","network_exit":{},"stopped":true}`
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
|
||||
if response.Code != http.StatusCreated || !created {
|
||||
t.Fatalf("stopped recovery create failed: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) {
|
||||
var created map[string]any
|
||||
@@ -75,11 +135,12 @@ func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) {
|
||||
t.Fatalf("runtime identity is not fixed: user=%#v entrypoint=%#v", created["User"], created["Entrypoint"])
|
||||
}
|
||||
cmd := created["Cmd"].([]any)
|
||||
if len(cmd) != 3 || cmd[0] != "--fingerprint=1000" || cmd[2] != "about:blank" {
|
||||
if len(cmd) != 5 || cmd[0] != "--fingerprint=1000" || !strings.HasPrefix(cmd[2].(string), "--proxy-server=http://docker-gateway:") ||
|
||||
cmd[3] != "--disable-non-proxied-udp" || cmd[4] != "about:blank" {
|
||||
t.Fatalf("cmd must be passed through verbatim: %#v", created["Cmd"])
|
||||
}
|
||||
host := created["HostConfig"].(map[string]any)
|
||||
if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true {
|
||||
if host["NetworkMode"] != "creatorhub_browser-account-a" || host["ReadonlyRootfs"] != true {
|
||||
t.Fatalf("missing container isolation: %#v", host)
|
||||
}
|
||||
tmpfs := host["Tmpfs"].(map[string]any)
|
||||
@@ -96,12 +157,51 @@ func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDockerInspectContainsNoProxyCredentials(t *testing.T) {
|
||||
var created map[string]any
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
|
||||
response.WriteHeader(http.StatusOK)
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
|
||||
if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
_, _ = response.Write([]byte(`{"Id":"container-id"}`))
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"):
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
handler := newGateway(docker, "creatorhub_browser", testToken)
|
||||
body := strings.Replace(testCreateBody, `"protocol":"socks5","host":"proxy.example","port":1080`,
|
||||
`"protocol":"socks5","host":"proxy.example","port":1080,"username":"operator","password":"ephemeral"`, 1)
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
inspect, _ := json.Marshal(created)
|
||||
for _, secret := range []string{"operator", "ephemeral", "operator:ephemeral@", "proxy.example"} {
|
||||
if bytes.Contains(inspect, []byte(secret)) {
|
||||
t.Fatalf("Docker inspect leaked proxy credential %q: %s", secret, inspect)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(inspect, []byte("--proxy-server=http://docker-gateway:")) {
|
||||
t.Fatalf("Docker inspect is missing the secret-free proxy configuration: %s", inspect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayPullsMissingImageOnCreate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ref string
|
||||
fromImage string
|
||||
tag string
|
||||
name string
|
||||
ref string
|
||||
fromImage string
|
||||
tag string
|
||||
}{{
|
||||
name: "tagged ref splits repository and tag",
|
||||
ref: "registry.example/browser:2.0.0",
|
||||
@@ -139,7 +239,9 @@ func TestGatewayPullsMissingImageOnCreate(t *testing.T) {
|
||||
|
||||
handler := newGateway(docker, "creatorhub_browser", testToken)
|
||||
body := `{"alias":"account-a","name":"账号甲","image":"` + test.ref +
|
||||
`","cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a"}`
|
||||
`","cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` +
|
||||
`"binding_version":1,"network_exit_id":"exit-1",` +
|
||||
`"network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
|
||||
|
||||
@@ -187,6 +289,7 @@ func TestGatewayRejectsInvalidCreateRequest(t *testing.T) {
|
||||
"invalid image": `{"alias":"account-a","name":"甲","image":"","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`,
|
||||
"empty cmd": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":[],"volume":"creatorhub-profile-account-a"}`,
|
||||
"invalid volume": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"bad volume!"}`,
|
||||
"proxy override": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1","--proxy-server=http://direct:8080","about:blank"],"volume":"creatorhub-profile-account-a","network_exit":{"protocol":"socks5","host":"proxy","port":1080}}`,
|
||||
}
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -281,6 +384,31 @@ func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDoesNotEchoProxyCredentialsFromDockerErrors(t *testing.T) {
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/") {
|
||||
response.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create") {
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = response.Write([]byte(`invalid cmd --proxy-server=http://operator:ephemeral@proxy.example:8080`))
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
handler := newGateway(docker, "creatorhub_browser", testToken)
|
||||
body := strings.Replace(testCreateBody, `"protocol":"socks5","host":"proxy.example","port":1080`,
|
||||
`"protocol":"http","host":"proxy.example","port":8080,"username":"operator","password":"ephemeral"`, 1)
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
|
||||
if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "operator") ||
|
||||
strings.Contains(response.Body.String(), "ephemeral") || strings.Contains(response.Body.String(), "proxy.example") {
|
||||
t.Fatalf("gateway leaked proxy material: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayListsBrowsers(t *testing.T) {
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet || request.URL.Path != "/containers/json" {
|
||||
@@ -303,6 +431,43 @@ func TestGatewayListsBrowsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRestartRestoresExistingProxyListener(t *testing.T) {
|
||||
reserved, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := reserved.Addr().(*net.TCPAddr).Port
|
||||
_ = reserved.Close()
|
||||
labels := map[string]string{
|
||||
managedLabel: "true", idLabel: "account-a", nameLabel: "账号甲",
|
||||
bindingVersionLabel: "3", networkExitLabel: "exit-1", proxyPortLabel: strconv.Itoa(port),
|
||||
}
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/containers/creatorhub-browser-account-a/json"):
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"Config": map[string]any{"Labels": labels}})
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/containers/json":
|
||||
_ = json.NewEncoder(response).Encode([]map[string]any{{"Id": "container-id", "State": "running", "Status": "Up", "Labels": labels}})
|
||||
default:
|
||||
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
handler := newGateway(docker, "creatorhub_browser", testToken)
|
||||
recovery := `{"binding_version":3,"network_exit_id":"exit-1","network_exit":{"protocol":"http","host":"127.0.0.1","port":1}}`
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers/account-a/proxy", strings.NewReader(recovery)))
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("proxy recovery failed: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodGet, "/v1/browsers", nil))
|
||||
var browsers []browser
|
||||
if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || len(browsers) != 1 || !browsers[0].ProxyReady {
|
||||
t.Fatalf("restarted gateway did not report restored proxy: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLifecycle(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
@@ -337,6 +502,55 @@ func TestGatewayLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDeleteDistinguishesContainerRemovalFromNetworkCleanup(t *testing.T) {
|
||||
containerExists, cleanupFails, containerDeletes := true, true, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"):
|
||||
if !containerExists {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write([]byte(`{"Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a"}}}`))
|
||||
case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
|
||||
containerExists = false
|
||||
containerDeletes++
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{
|
||||
"Name": "creatorhub_browser-account-a",
|
||||
"Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a"},
|
||||
})
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
|
||||
if cleanupFails {
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"):
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
|
||||
"creatorhub_browser", testToken, "gateway-self")
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", nil))
|
||||
if response.Code != http.StatusAccepted || containerExists || containerDeletes != 1 {
|
||||
t.Fatalf("expected definite container removal with pending cleanup, status=%d exists=%v deletes=%d body=%s",
|
||||
response.Code, containerExists, containerDeletes, response.Body.String())
|
||||
}
|
||||
cleanupFails = false
|
||||
response = httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", nil))
|
||||
if response.Code != http.StatusNoContent || containerDeletes != 1 {
|
||||
t.Fatalf("idempotent cleanup retry failed: status=%d deletes=%d body=%s", response.Code, containerDeletes, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) {
|
||||
docker, server := testDocker(func(response http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(response, "daemon unavailable", http.StatusInternalServerError)
|
||||
@@ -374,94 +588,39 @@ func TestGatewayRefusesUnmanagedContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBrowserNetwork(t *testing.T) {
|
||||
var created struct {
|
||||
Name string `json:"Name"`
|
||||
Driver string `json:"Driver"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch request.Method {
|
||||
case http.MethodGet:
|
||||
func TestEnsureTenantNetworkConnectsGatewayOnlyToRuntimeNetwork(t *testing.T) {
|
||||
created, connected := false, false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && !created:
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodPost:
|
||||
if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
|
||||
t.Fatal(err)
|
||||
case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&body)
|
||||
labels := body["Labels"].(map[string]any)
|
||||
if body["Name"] != "creatorhub_browser-account-a" || labels[idLabel] != "account-a" {
|
||||
t.Fatalf("unexpected isolated network create: %#v", body)
|
||||
}
|
||||
created = true
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := docker.ensureBrowserNetwork("creatorhub_browser"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Name != "creatorhub_browser" || created.Driver != "bridge" ||
|
||||
created.Labels[managedLabel] != "true" || created.Labels[networkRoleLabel] != browserNetworkRole {
|
||||
t.Fatalf("network is not a CreatorHub bridge: %#v", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBrowserNetworkRejectsUnsafeExistingNetwork(t *testing.T) {
|
||||
valid := map[string]any{
|
||||
"Name": "creatorhub_browser",
|
||||
"Driver": "bridge",
|
||||
"Internal": false,
|
||||
"Attachable": false,
|
||||
"Ingress": false,
|
||||
"Labels": map[string]string{
|
||||
managedLabel: "true",
|
||||
networkRoleLabel: browserNetworkRole,
|
||||
},
|
||||
}
|
||||
validDocker, validServer := testDocker(func(response http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(response).Encode(valid)
|
||||
})
|
||||
defer validServer.Close()
|
||||
if err := validDocker.ensureBrowserNetwork("creatorhub_browser"); err != nil {
|
||||
t.Fatalf("expected owned bridge network to be accepted: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]func(map[string]any){
|
||||
"wrong name": func(network map[string]any) { network["Name"] = "other" },
|
||||
"wrong driver": func(network map[string]any) { network["Driver"] = "overlay" },
|
||||
"internal": func(network map[string]any) { network["Internal"] = true },
|
||||
"attachable": func(network map[string]any) { network["Attachable"] = true },
|
||||
"ingress": func(network map[string]any) { network["Ingress"] = true },
|
||||
"missing ownership": func(network map[string]any) {
|
||||
network["Labels"] = map[string]string{networkRoleLabel: browserNetworkRole}
|
||||
},
|
||||
"wrong role": func(network map[string]any) {
|
||||
network["Labels"] = map[string]string{managedLabel: "true", networkRoleLabel: "control"}
|
||||
},
|
||||
}
|
||||
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
network := make(map[string]any, len(valid))
|
||||
for key, value := range valid {
|
||||
network[key] = value
|
||||
}
|
||||
mutate(network)
|
||||
docker, server := testDocker(func(response http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(response).Encode(network)
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"):
|
||||
connected = true
|
||||
response.WriteHeader(http.StatusOK)
|
||||
case request.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{
|
||||
"Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
|
||||
"Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a"},
|
||||
"Containers": map[string]any{"gateway-id": map[string]string{"Name": "gateway-id", "IPv4Address": "127.0.0.3/8"}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := docker.ensureBrowserNetwork("creatorhub_browser"); err == nil {
|
||||
t.Fatal("expected unsafe existing network to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBrowserNetworkRejectsControlNetwork(t *testing.T) {
|
||||
requested := false
|
||||
docker, server := testDocker(func(http.ResponseWriter, *http.Request) { requested = true })
|
||||
default:
|
||||
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := docker.ensureBrowserNetwork(controlNetworkName); err == nil || requested {
|
||||
t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err)
|
||||
docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
|
||||
name, bindHost, err := docker.ensureTenantNetwork("creatorhub_browser", "account-a", "gateway-id")
|
||||
if err != nil || !created || !connected || name != "creatorhub_browser-account-a" || bindHost != "127.0.0.3" {
|
||||
t.Fatalf("isolated network was not created and connected: name=%q host=%q created=%v connected=%v err=%v", name, bindHost, created, connected, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const browserProxyHost = "docker-gateway"
|
||||
|
||||
type memoryProxyRegistry struct {
|
||||
mu sync.Mutex
|
||||
proxies map[string]*memoryProxy
|
||||
}
|
||||
|
||||
type memoryProxy struct {
|
||||
mu sync.RWMutex
|
||||
exit gatewayProxyExit
|
||||
bindHost string
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
url string
|
||||
}
|
||||
|
||||
func newMemoryProxyRegistry() *memoryProxyRegistry {
|
||||
return &memoryProxyRegistry{proxies: map[string]*memoryProxy{}}
|
||||
}
|
||||
|
||||
func (registry *memoryProxyRegistry) configure(alias, bindHost string, port int, exit gatewayProxyExit) (string, func(), error) {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
if proxy := registry.proxies[alias]; proxy != nil {
|
||||
if proxy.bindHost == bindHost && (port == 0 || proxy.listener.Addr().(*net.TCPAddr).Port == port) && proxy.exit == exit {
|
||||
return proxy.url, func() {}, nil
|
||||
}
|
||||
delete(registry.proxies, alias)
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
listener, err := net.Listen("tcp4", net.JoinHostPort(bindHost, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
actualPort := listener.Addr().(*net.TCPAddr).Port
|
||||
proxy := &memoryProxy{exit: exit, bindHost: bindHost, listener: listener, url: "http://" + net.JoinHostPort(browserProxyHost, strconv.Itoa(actualPort))}
|
||||
proxy.server = &http.Server{Handler: proxy, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
registry.proxies[alias] = proxy
|
||||
go func() { _ = proxy.server.Serve(listener) }()
|
||||
undo := func() {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
if registry.proxies[alias] == proxy {
|
||||
delete(registry.proxies, alias)
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
}
|
||||
return proxy.url, undo, nil
|
||||
}
|
||||
|
||||
func (registry *memoryProxyRegistry) ready(alias string, port int) bool {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
proxy := registry.proxies[alias]
|
||||
return proxy != nil && proxy.listener.Addr().(*net.TCPAddr).Port == port
|
||||
}
|
||||
|
||||
func (registry *memoryProxyRegistry) remove(alias string) {
|
||||
registry.mu.Lock()
|
||||
proxy := registry.proxies[alias]
|
||||
delete(registry.proxies, alias)
|
||||
registry.mu.Unlock()
|
||||
if proxy != nil {
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (proxy *memoryProxy) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodConnect {
|
||||
proxy.tunnel(response, request)
|
||||
return
|
||||
}
|
||||
proxy.mu.RLock()
|
||||
exit := proxy.exit
|
||||
proxy.mu.RUnlock()
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
if exit.Protocol == "http" || exit.Protocol == "https" {
|
||||
upstream := &url.URL{Scheme: exit.Protocol, Host: net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port))}
|
||||
if exit.Username != "" {
|
||||
upstream.User = url.UserPassword(exit.Username, exit.Password)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(upstream)
|
||||
} else {
|
||||
transport.DialContext = proxy.dialContext
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
outbound := request.Clone(request.Context())
|
||||
outbound.RequestURI = ""
|
||||
outbound.Header.Del("Proxy-Authorization")
|
||||
result, err := transport.RoundTrip(outbound)
|
||||
if err != nil {
|
||||
http.Error(response, "proxy connection failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer result.Body.Close()
|
||||
for key, values := range result.Header {
|
||||
for _, value := range values {
|
||||
response.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
response.WriteHeader(result.StatusCode)
|
||||
_, _ = io.Copy(response, result.Body)
|
||||
}
|
||||
|
||||
func (proxy *memoryProxy) tunnel(response http.ResponseWriter, request *http.Request) {
|
||||
upstream, err := proxy.dialContext(request.Context(), "tcp", request.Host)
|
||||
if err != nil {
|
||||
http.Error(response, "proxy connection failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
client, buffered, err := http.NewResponseController(response).Hijack()
|
||||
if err != nil {
|
||||
_ = upstream.Close()
|
||||
http.Error(response, "proxy tunnel unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil || buffered.Flush() != nil {
|
||||
_ = client.Close()
|
||||
_ = upstream.Close()
|
||||
return
|
||||
}
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(upstream, client); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(client, upstream); done <- struct{}{} }()
|
||||
<-done
|
||||
_ = client.Close()
|
||||
_ = upstream.Close()
|
||||
}
|
||||
|
||||
func (proxy *memoryProxy) dialContext(ctx context.Context, _, target string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
proxy.mu.RLock()
|
||||
exit := proxy.exit
|
||||
proxy.mu.RUnlock()
|
||||
switch exit.Protocol {
|
||||
case "http", "https":
|
||||
return dialHTTPProxy(ctx, exit, target)
|
||||
case "socks4":
|
||||
return dialSOCKS4Proxy(ctx, exit, target)
|
||||
case "socks5":
|
||||
return dialSOCKS5Proxy(ctx, exit, target)
|
||||
default:
|
||||
return nil, errors.New("unsupported proxy protocol")
|
||||
}
|
||||
}
|
||||
|
||||
func dialHTTPProxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
|
||||
address := net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port))
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exit.Protocol == "https" {
|
||||
tlsConnection := tls.Client(connection, &tls.Config{ServerName: exit.Host, MinVersion: tls.VersionTLS12})
|
||||
if err := tlsConnection.HandshakeContext(ctx); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
connection = tlsConnection
|
||||
}
|
||||
request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)}
|
||||
if exit.Username != "" {
|
||||
request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(exit.Username+":"+exit.Password)))
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if err := request.Write(connection); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
result, err := http.ReadResponse(bufio.NewReader(connection), request)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if result.StatusCode != http.StatusOK {
|
||||
_ = result.Body.Close()
|
||||
_ = connection.Close()
|
||||
return nil, fmt.Errorf("upstream proxy returned %s", result.Status)
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func dialSOCKS4Proxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("invalid SOCKS4 target")
|
||||
}
|
||||
payload := []byte{4, 1, byte(port >> 8), byte(port), 0, 0, 0, 1}
|
||||
if ip := net.ParseIP(host).To4(); ip != nil {
|
||||
copy(payload[4:8], ip)
|
||||
}
|
||||
payload = append(payload, exit.Username...)
|
||||
payload = append(payload, 0)
|
||||
if net.ParseIP(host).To4() == nil {
|
||||
payload = append(payload, host...)
|
||||
payload = append(payload, 0)
|
||||
}
|
||||
if err := exchangeSOCKS(ctx, connection, payload, 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func dialSOCKS5Proxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methods := []byte{5, 1, 0}
|
||||
if exit.Username != "" {
|
||||
methods = []byte{5, 1, 2}
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := connection.Write(methods); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
selection := make([]byte, 2)
|
||||
if _, err := io.ReadFull(connection, selection); err != nil || selection[0] != 5 || selection[1] == 0xff {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 authentication method rejected")
|
||||
}
|
||||
if selection[1] == 2 {
|
||||
if len(exit.Username) > 255 || len(exit.Password) > 255 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 credentials too long")
|
||||
}
|
||||
auth := append([]byte{1, byte(len(exit.Username))}, exit.Username...)
|
||||
auth = append(auth, byte(len(exit.Password)))
|
||||
auth = append(auth, exit.Password...)
|
||||
if _, err := connection.Write(auth); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
result := make([]byte, 2)
|
||||
if _, err := io.ReadFull(connection, result); err != nil || result[1] != 0 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 authentication rejected")
|
||||
}
|
||||
} else if exit.Username != "" {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 proxy skipped required authentication")
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("invalid SOCKS5 target")
|
||||
}
|
||||
request := []byte{5, 1, 0}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
||||
request = append(request, 1)
|
||||
request = append(request, ip.To4()...)
|
||||
} else if ip != nil {
|
||||
request = append(request, 4)
|
||||
request = append(request, ip.To16()...)
|
||||
} else {
|
||||
if len(host) > 255 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 target too long")
|
||||
}
|
||||
request = append(request, 3, byte(len(host)))
|
||||
request = append(request, host...)
|
||||
}
|
||||
portBytes := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(portBytes, uint16(port))
|
||||
request = append(request, portBytes...)
|
||||
if _, err := connection.Write(request); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 0 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 proxy rejected connection")
|
||||
}
|
||||
addressLength := 0
|
||||
switch header[3] {
|
||||
case 1:
|
||||
addressLength = 4
|
||||
case 4:
|
||||
addressLength = 16
|
||||
case 3:
|
||||
var length [1]byte
|
||||
if _, err := io.ReadFull(connection, length[:]); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
addressLength = int(length[0])
|
||||
default:
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("invalid SOCKS5 response")
|
||||
}
|
||||
if _, err := io.CopyN(io.Discard, connection, int64(addressLength+2)); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func exchangeSOCKS(ctx context.Context, connection net.Conn, request []byte, responseBytes int) error {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := connection.Write(request); err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
if responseBytes > 0 {
|
||||
response := make([]byte, responseBytes)
|
||||
if _, err := io.ReadFull(connection, response); err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
if responseBytes == 8 && response[1] != 90 {
|
||||
_ = connection.Close()
|
||||
return errors.New("SOCKS4 proxy rejected connection")
|
||||
}
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryProxyUsesSOCKS5Credentials(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
greeting := make([]byte, 3)
|
||||
if _, err := io.ReadFull(connection, greeting); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
_, _ = connection.Write([]byte{5, 2})
|
||||
authHeader := make([]byte, 2)
|
||||
_, _ = io.ReadFull(connection, authHeader)
|
||||
username := make([]byte, int(authHeader[1]))
|
||||
_, _ = io.ReadFull(connection, username)
|
||||
var passwordLength [1]byte
|
||||
_, _ = io.ReadFull(connection, passwordLength[:])
|
||||
password := make([]byte, int(passwordLength[0]))
|
||||
_, _ = io.ReadFull(connection, password)
|
||||
if string(username) != "operator" || string(password) != "ephemeral" {
|
||||
done <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
_, _ = connection.Write([]byte{1, 0})
|
||||
requestHeader := make([]byte, 5)
|
||||
_, _ = io.ReadFull(connection, requestHeader)
|
||||
host := make([]byte, int(requestHeader[4]))
|
||||
_, _ = io.ReadFull(connection, host)
|
||||
port := make([]byte, 2)
|
||||
_, _ = io.ReadFull(connection, port)
|
||||
if string(host) != "example.com" || binary.BigEndian.Uint16(port) != 443 {
|
||||
done <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if _, err = connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
var tunneled [1]byte
|
||||
_, err = io.ReadFull(connection, tunneled[:])
|
||||
if err == nil && tunneled[0] != 'x' {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
|
||||
host, portText, _ := net.SplitHostPort(listener.Addr().String())
|
||||
port, _ := net.LookupPort("tcp", portText)
|
||||
registry := newMemoryProxyRegistry()
|
||||
proxyURL, cleanup, err := registry.configure("account-a", "127.0.0.1", 0, gatewayProxyExit{
|
||||
Protocol: "socks5", Host: host, Port: port, Username: "operator", Password: "ephemeral",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
parsed, _ := url.Parse(proxyURL)
|
||||
connection, err := net.Dial("tcp", strings.Replace(parsed.Host, browserProxyHost, "127.0.0.1", 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fmt.Fprint(connection, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.ReadResponse(bufio.NewReader(connection), &http.Request{Method: http.MethodConnect})
|
||||
if err != nil || response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("memory proxy CONNECT failed: response=%v err=%v", response, err)
|
||||
}
|
||||
if _, err := connection.Write([]byte{'x'}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = connection.Close()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryProxyUsesAbsoluteFormForHTTPUpstream(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodConnect {
|
||||
http.Error(response, "CONNECT forbidden", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !request.URL.IsAbs() || request.URL.String() != "http://example.com/plain" {
|
||||
t.Fatalf("expected absolute-form request, got %q", request.URL.String())
|
||||
}
|
||||
if request.Header.Get("Proxy-Authorization") == "" {
|
||||
t.Fatal("upstream proxy credentials were not applied")
|
||||
}
|
||||
_, _ = response.Write([]byte("forwarded"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
address, _ := url.Parse(upstream.URL)
|
||||
port, _ := strconv.Atoi(address.Port())
|
||||
registry := newMemoryProxyRegistry()
|
||||
proxyURL, cleanup, err := registry.configure("account-a", "127.0.0.1", 0, gatewayProxyExit{
|
||||
Protocol: "http", Host: address.Hostname(), Port: port, Username: "operator", Password: "ephemeral",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
proxyAddress := strings.Replace(strings.TrimPrefix(proxyURL, "http://"), browserProxyHost, "127.0.0.1", 1)
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: proxyAddress})}}
|
||||
response, err := client.Get("http://example.com/plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode != http.StatusOK || string(body) != "forwarded" {
|
||||
t.Fatalf("plain HTTP was not forwarded: status=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryProxyRejectsCrossAliasAddress(t *testing.T) {
|
||||
registry := newMemoryProxyRegistry()
|
||||
proxyURL, cleanup, err := registry.configure("account-a", "127.0.0.1", 0, gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
parsed, _ := url.Parse(proxyURL)
|
||||
if connection, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.2", parsed.Port()), 100*time.Millisecond); err == nil {
|
||||
_ = connection.Close()
|
||||
t.Fatal("another tenant address could reach account-a proxy")
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,5 @@ services:
|
||||
networks:
|
||||
control:
|
||||
name: creatorhub_control
|
||||
|
||||
volumes:
|
||||
creatorhub_postgres:
|
||||
|
||||
@@ -30,7 +30,7 @@ React ──> control-plane ── /api/browsers ──(Bearer token)──> doc
|
||||
|
||||
将 socket 以只读文件挂载**不会**限制 Docker API 的写操作;拥有 socket 等价于拥有宿主机 root 权限。因此:
|
||||
|
||||
- 只有 `docker-gateway` 挂载 socket,控制面和浏览器容器均不可见;网关只加入 control 网络,浏览器不能连接网关;
|
||||
- 只有 `docker-gateway` 挂载 socket,控制面和浏览器容器均不可见;网关加入 control 与 browser 网络,浏览器只拿到无凭据的内存转发代理地址,`/v1` 仍必须通过容器内不可见的网关令牌;
|
||||
- 网关只暴露面向领域的路由,不提供通用 Docker 代理;`/v1` 全部接口校验 `Authorization: Bearer <GATEWAY_TOKEN>`(常数时间比较),令牌由部署者在网关环境变量与平台注册表中保持一致;
|
||||
- 网关固定命令、网络、挂载和资源限制;外部输入是受校验的别名,以及平台下发的镜像引用、启动参数和卷名——镜像引用来自平台维护的版本表,新增/变更由人工在页面审核启用,不再写死在代码中;
|
||||
- 启停和删除前必须同时匹配固定名称前缀及 `io.creatorhub.managed`、`io.creatorhub.runtime-id` 标签;
|
||||
@@ -59,4 +59,10 @@ DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build
|
||||
|
||||
草稿经 `POST /api/phase-a/confirmations` 显式确认后才可投递到 `/api/phase-a/tasks`。任务由幂等键去重;`POST /api/phase-a/mock/execute` 使用 `FOR UPDATE SKIP LOCKED` 领取一分钟租约,执行前统一核对账号、草稿和确认版本。缺少确认或版本不一致会进入 `needs_confirmation`,暂停账号或 Mock 策略结果会进入 `policy_hold`,不确定结果与过期租约进入 `needs_confirmation`;这些状态都不会自动重试。`GET /api/phase-a/audit` 只导出账号、确认版本、尝试和结果等非秘密证据。
|
||||
|
||||
启动时控制面先应用 Phase A v1,再由 Hub runner 顺序应用 v2、v3;每一步都在事务和 advisory lock 下前向执行。v3 保留旧表、列和历史记录,旧账号回填为 `platform=mock` 并暂停,仅账号 ID 与环境 alias 相同的记录自动建立 binding;其余记录等待显式绑定。本阶段不提供破坏性自动回滚。
|
||||
启动时控制面先应用 Phase A v1,再由 Hub runner 顺序应用 v2、v3、v4;每一步都在事务和 advisory lock 下前向执行。v3 保留旧表、列和历史记录,旧账号回填为 `platform=mock` 并暂停,仅账号 ID 与环境 alias 相同的记录自动建立 binding;v4 只追加环境动作审计字段与索引。其余记录等待显式绑定。本阶段不提供破坏性自动回滚。
|
||||
|
||||
`POST /api/network-exits` 只接受协议、主机、端口、已有 `credential_reference: {id}` 和预期出口身份;新出口为 `unchecked`,由 `POST /api/network-exits/:id/check` 经实际代理链路变为 `healthy` 或 `unhealthy`,`disable` 不可被检查重新启用。credential reference 的 `reference_key` 不出现在 API、日志或审计中;OS Keyring/Secret Manager bridge 在控制面进程启动前注入 `CREATORHUB_CREDENTIAL_<SHA256(reference_key)>`(大写十六进制),值为请求期解析的 `username:password`,控制面不持久化解析值。
|
||||
|
||||
`POST /api/browsers` 必须同时给出 `account_id` 和 `network_exit_id`。环境创建、启动和升级都会重新检查出口身份,只有 `healthy` 才调用网关;控制面强制下发代理和 `disable_non_proxied_udp`,fingerprint 中的代理字段会被拒绝。显式 `POST /api/browsers/:alias/rebind` 只允许 paused、无 executing task 且无活动 runtime 的账号。`DELETE /api/browsers/:alias` 回收容器但保留稳定 binding、环境和命名 Profile 卷,后续 create 复用它们。create/start/stop/upgrade/recycle 均写共享 operation ID 的 requested/finished 审计对;网关断连且无法调和时 outcome 为 `unknown`。
|
||||
|
||||
解析后的出口凭据只存在于控制面单次请求和网关内存转发器中;Docker inspect、容器环境、标签、挂载、`Config.Cmd` 与进程参数只包含 `docker-gateway` 的无凭据本地代理地址。stopped 环境启动时先删除旧容器并确认 runtime lease 释放,再按当前 binding 重建;控制面每 20 秒及列表读取时调和网关,续租 running runtime、释放 stopped/missing runtime,过期 lease 也会在绑定事务中回收。
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
|
||||
type CredentialReference struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type NetworkExit struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
CredentialReference *CredentialReference `json:"credential_reference,omitempty"`
|
||||
ExpectedPublicIP string `json:"expected_public_ip,omitempty"`
|
||||
ExpectedRegion string `json:"expected_region,omitempty"`
|
||||
ObservedPublicIP string `json:"observed_public_ip,omitempty"`
|
||||
ObservedRegion string `json:"observed_region,omitempty"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
LastCheckReason string `json:"last_check_reason,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NetworkExitAccess is internal-only: reference keys are never serialized or audited.
|
||||
type NetworkExitAccess struct {
|
||||
NetworkExit
|
||||
CredentialKey string `json:"-"`
|
||||
}
|
||||
|
||||
type ExitObservation struct {
|
||||
PublicIP string
|
||||
Region string
|
||||
}
|
||||
|
||||
type EnvironmentContext struct {
|
||||
Env
|
||||
AccountID string `json:"account_id"`
|
||||
BindingID string `json:"binding_id"`
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
RuntimeCleanupPending bool `json:"runtime_cleanup_pending,omitempty"`
|
||||
Exit NetworkExit `json:"network_exit"`
|
||||
RuntimeInstanceID string `json:"runtime_instance_id,omitempty"`
|
||||
RuntimeID string `json:"runtime_id,omitempty"`
|
||||
}
|
||||
|
||||
type EnvironmentAction struct {
|
||||
OperationID string
|
||||
Action string
|
||||
AccountID string
|
||||
BrowserEnvAlias string
|
||||
NetworkExitID string
|
||||
RuntimeInstanceID string
|
||||
BindingVersion int64
|
||||
OldImageVersion string
|
||||
NewImageVersion string
|
||||
Outcome string
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
func (s *Store) CreateNetworkExit(ctx context.Context, exit NetworkExit, credentialReferenceID string) (NetworkExit, error) {
|
||||
exit.ID = "exit-" + newHubID()
|
||||
exit.Protocol, exit.Host = strings.ToLower(strings.TrimSpace(exit.Protocol)), strings.TrimSpace(exit.Host)
|
||||
exit.ExpectedPublicIP, exit.ExpectedRegion = strings.TrimSpace(exit.ExpectedPublicIP), strings.TrimSpace(exit.ExpectedRegion)
|
||||
credentialReferenceID = strings.TrimSpace(credentialReferenceID)
|
||||
if !validNetworkExit(exit) || (credentialReferenceID != "" && !exitIDPattern.MatchString(credentialReferenceID)) {
|
||||
return NetworkExit{}, ErrInvalid
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO network_exit (id, protocol, host, port, credential_reference_id, expected_public_ip, expected_region)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, '')::inet, $7)
|
||||
RETURNING id`, exit.ID, exit.Protocol, exit.Host, exit.Port, credentialReferenceID, exit.ExpectedPublicIP, exit.ExpectedRegion)
|
||||
if err := row.Scan(&exit.ID); err != nil {
|
||||
return NetworkExit{}, publicDatabaseError(err)
|
||||
}
|
||||
return s.GetNetworkExit(ctx, exit.ID)
|
||||
}
|
||||
|
||||
func validNetworkExit(exit NetworkExit) bool {
|
||||
if exit.Protocol != "http" && exit.Protocol != "https" && exit.Protocol != "socks4" && exit.Protocol != "socks5" {
|
||||
return false
|
||||
}
|
||||
if !validExitHost(exit.Host) || exit.Port < 1 || exit.Port > 65535 {
|
||||
return false
|
||||
}
|
||||
if exit.ExpectedPublicIP != "" && net.ParseIP(exit.ExpectedPublicIP) == nil {
|
||||
return false
|
||||
}
|
||||
return validOptionalRegion(exit.ExpectedRegion)
|
||||
}
|
||||
|
||||
func validExitHost(host string) bool {
|
||||
if host == "" || len(host) > 253 || strings.ContainsAny(host, "@/[]?# \t\r\n") {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") || strings.Contains(host, "..") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(host, ".") {
|
||||
if len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return false
|
||||
}
|
||||
for _, character := range label {
|
||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
|
||||
(character < '0' || character > '9') && character != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validOptionalRegion(region string) bool {
|
||||
if len(region) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, character := range region {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Store) ListNetworkExits(ctx context.Context) ([]NetworkExit, error) {
|
||||
rows, err := s.db.QueryContext(ctx, networkExitSelect+` ORDER BY network.created_at, network.id`)
|
||||
if err != nil {
|
||||
return nil, errors.New("read network exits")
|
||||
}
|
||||
defer rows.Close()
|
||||
exits := []NetworkExit{}
|
||||
for rows.Next() {
|
||||
exit, err := scanNetworkExit(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exits = append(exits, exit)
|
||||
}
|
||||
return exits, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetNetworkExit(ctx context.Context, id string) (NetworkExit, error) {
|
||||
if !exitIDPattern.MatchString(id) {
|
||||
return NetworkExit{}, ErrInvalid
|
||||
}
|
||||
return scanNetworkExit(s.db.QueryRowContext(ctx, networkExitSelect+` WHERE network.id = $1`, id))
|
||||
}
|
||||
|
||||
func (s *Store) GetNetworkExitAccess(ctx context.Context, id string) (NetworkExitAccess, error) {
|
||||
exit, err := s.GetNetworkExit(ctx, id)
|
||||
if err != nil {
|
||||
return NetworkExitAccess{}, err
|
||||
}
|
||||
access := NetworkExitAccess{NetworkExit: exit}
|
||||
if exit.CredentialReference != nil {
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT reference_key FROM credential_reference WHERE id = $1`, exit.CredentialReference.ID).
|
||||
Scan(&access.CredentialKey); err != nil {
|
||||
return NetworkExitAccess{}, rowError(err)
|
||||
}
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
|
||||
const networkExitSelect = `
|
||||
SELECT network.id, network.protocol, network.host, network.port,
|
||||
reference.id, reference.provider,
|
||||
COALESCE(host(network.expected_public_ip), ''), network.expected_region,
|
||||
COALESCE(host(network.observed_public_ip), ''), network.observed_region,
|
||||
network.health_status, COALESCE(network.last_check_reason, ''), network.version, network.last_checked_at,
|
||||
network.created_at, network.updated_at
|
||||
FROM network_exit network
|
||||
LEFT JOIN credential_reference reference ON reference.id = network.credential_reference_id`
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanNetworkExit(row rowScanner) (NetworkExit, error) {
|
||||
var exit NetworkExit
|
||||
var referenceID, provider sql.NullString
|
||||
var checked sql.NullTime
|
||||
if err := row.Scan(&exit.ID, &exit.Protocol, &exit.Host, &exit.Port, &referenceID, &provider,
|
||||
&exit.ExpectedPublicIP, &exit.ExpectedRegion, &exit.ObservedPublicIP, &exit.ObservedRegion,
|
||||
&exit.HealthStatus, &exit.LastCheckReason, &exit.Version, &checked, &exit.CreatedAt, &exit.UpdatedAt); err != nil {
|
||||
return NetworkExit{}, rowError(err)
|
||||
}
|
||||
if referenceID.Valid {
|
||||
exit.CredentialReference = &CredentialReference{ID: referenceID.String, Provider: provider.String}
|
||||
}
|
||||
if checked.Valid {
|
||||
exit.LastCheckedAt = &checked.Time
|
||||
}
|
||||
return exit, nil
|
||||
}
|
||||
|
||||
// RecordNetworkExitCheck stores only observed identity and a stable reason code.
|
||||
func (s *Store) RecordNetworkExitCheck(ctx context.Context, id string, observation ExitObservation, failureReason string) (NetworkExit, string, error) {
|
||||
if !exitIDPattern.MatchString(id) || !validOptionalRegion(observation.Region) ||
|
||||
(observation.PublicIP != "" && net.ParseIP(observation.PublicIP) == nil) || !validExitFailureReason(failureReason) {
|
||||
return NetworkExit{}, "invalid_observation", ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return NetworkExit{}, "persistence_failed", errors.New("begin network exit check")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var expectedIP, expectedRegion, oldIP, oldRegion, oldStatus string
|
||||
var version int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(host(expected_public_ip), ''), expected_region,
|
||||
COALESCE(host(observed_public_ip), ''), observed_region, health_status, version
|
||||
FROM network_exit WHERE id = $1 FOR UPDATE`, id).
|
||||
Scan(&expectedIP, &expectedRegion, &oldIP, &oldRegion, &oldStatus, &version); err != nil {
|
||||
return NetworkExit{}, "persistence_failed", rowError(err)
|
||||
}
|
||||
if oldStatus == "disabled" {
|
||||
return NetworkExit{}, "exit_disabled", ErrConflict
|
||||
}
|
||||
reason, status := strings.TrimSpace(failureReason), "unhealthy"
|
||||
if reason == "" && expectedIP != "" && !net.ParseIP(expectedIP).Equal(net.ParseIP(observation.PublicIP)) {
|
||||
reason = "exit_ip_drift"
|
||||
}
|
||||
if reason == "" && expectedRegion != "" && !strings.EqualFold(expectedRegion, observation.Region) {
|
||||
reason = "exit_region_drift"
|
||||
}
|
||||
if reason == "" {
|
||||
reason, status = "exit_healthy", "healthy"
|
||||
}
|
||||
changed := !sameIP(oldIP, observation.PublicIP) || !strings.EqualFold(oldRegion, observation.Region) || oldStatus != status
|
||||
if changed {
|
||||
version++
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE network_exit SET observed_public_ip = NULLIF($2, '')::inet, observed_region = $3,
|
||||
health_status = $4, last_check_reason = $5, version = $6, last_checked_at = now(), updated_at = now()
|
||||
WHERE id = $1`, id, observation.PublicIP, observation.Region, status, reason, version); err != nil {
|
||||
return NetworkExit{}, "persistence_failed", errors.New("record network exit check")
|
||||
}
|
||||
if changed {
|
||||
if err := invalidateAccountsForExit(ctx, tx, id); err != nil {
|
||||
return NetworkExit{}, "persistence_failed", err
|
||||
}
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return NetworkExit{}, "persistence_failed", err
|
||||
}
|
||||
exit, err := s.GetNetworkExit(ctx, id)
|
||||
return exit, reason, err
|
||||
}
|
||||
|
||||
func validExitFailureReason(reason string) bool {
|
||||
switch reason {
|
||||
case "", "credential_unavailable", "credential_invalid", "proxy_auth_failed", "proxy_check_failed", "exit_observation_invalid":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sameIP(left, right string) bool {
|
||||
if left == "" || right == "" {
|
||||
return left == right
|
||||
}
|
||||
return net.ParseIP(left).Equal(net.ParseIP(right))
|
||||
}
|
||||
|
||||
func (s *Store) DisableNetworkExit(ctx context.Context, id string) (NetworkExit, error) {
|
||||
if !exitIDPattern.MatchString(id) {
|
||||
return NetworkExit{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return NetworkExit{}, errors.New("begin network exit disable")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var oldStatus string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT health_status FROM network_exit WHERE id = $1 FOR UPDATE`, id).Scan(&oldStatus); err != nil {
|
||||
return NetworkExit{}, rowError(err)
|
||||
}
|
||||
if oldStatus != "disabled" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE network_exit SET health_status = 'disabled', last_check_reason = 'exit_disabled',
|
||||
version = version + 1, updated_at = now()
|
||||
WHERE id = $1`, id); err != nil {
|
||||
return NetworkExit{}, errors.New("disable network exit")
|
||||
}
|
||||
if err := invalidateAccountsForExit(ctx, tx, id); err != nil {
|
||||
return NetworkExit{}, err
|
||||
}
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return NetworkExit{}, err
|
||||
}
|
||||
return s.GetNetworkExit(ctx, id)
|
||||
}
|
||||
|
||||
func invalidateAccountsForExit(ctx context.Context, tx *sql.Tx, exitID string) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
WITH changed AS (
|
||||
UPDATE social_account account SET status = 'paused', paused_at = COALESCE(paused_at, now()),
|
||||
version = account.version + 1, updated_at = now()
|
||||
FROM environment_binding binding
|
||||
WHERE binding.network_exit_id = $1 AND binding.account_id = account.id
|
||||
RETURNING account.id
|
||||
)
|
||||
UPDATE operation_task task SET state = 'policy_hold', updated_at = now()
|
||||
FROM changed WHERE task.account_id = changed.id AND task.state = 'queued'`, exitID); err != nil {
|
||||
return errors.New("invalidate network exit accounts")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID string) (EnvironmentContext, bool, error) {
|
||||
env.Alias, env.Name = strings.TrimSpace(env.Alias), strings.TrimSpace(env.Name)
|
||||
if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || !aliasPattern.MatchString(accountID) ||
|
||||
!exitIDPattern.MatchString(exitID) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) ||
|
||||
env.Fingerprint.ProxyServer != "" {
|
||||
return EnvironmentContext{}, false, ErrInvalid
|
||||
}
|
||||
if err := env.Fingerprint.Validate(); err != nil {
|
||||
return EnvironmentContext{}, false, ErrInvalid
|
||||
}
|
||||
encoded, _ := json.Marshal(env.Fingerprint)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, false, errors.New("begin bound environment create")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var existingAlias, existingExit string
|
||||
err = tx.QueryRowContext(ctx, `SELECT browser_env_alias, COALESCE(network_exit_id, '') FROM environment_binding WHERE account_id = $1 FOR UPDATE`, accountID).
|
||||
Scan(&existingAlias, &existingExit)
|
||||
if err == nil {
|
||||
if existingAlias != env.Alias || existingExit != exitID {
|
||||
return EnvironmentContext{}, false, ErrConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return EnvironmentContext{}, false, errors.New("commit existing environment lookup")
|
||||
}
|
||||
context, err := s.GetEnvironmentContext(ctx, env.Alias)
|
||||
if err != nil || context.Name != env.Name || context.Gateway != env.Gateway || context.ImageVersion != env.ImageVersion || context.Fingerprint != env.Fingerprint {
|
||||
return EnvironmentContext{}, false, ErrConflict
|
||||
}
|
||||
return context, false, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return EnvironmentContext{}, false, publicDatabaseError(err)
|
||||
}
|
||||
var created string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
SELECT $1, $2, $3, image.version, $5
|
||||
FROM browser_image image, social_account account, network_exit network
|
||||
WHERE image.version = $4 AND image.enabled AND account.id = $6 AND account.status = 'paused'
|
||||
AND account.authorization_status = 'authorized' AND network.id = $7 AND network.health_status = 'healthy'
|
||||
RETURNING alias`, env.Alias, env.Name, env.Gateway, env.ImageVersion, encoded, accountID, exitID).Scan(&created); err != nil {
|
||||
return EnvironmentContext{}, false, rowError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id)
|
||||
VALUES ($1, $1, $2, $3)`, accountID, env.Alias, exitID); err != nil {
|
||||
return EnvironmentContext{}, false, publicDatabaseError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE social_account SET version = version + 1, updated_at = now() WHERE id = $1`, accountID); err != nil {
|
||||
return EnvironmentContext{}, false, errors.New("version bound account")
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return EnvironmentContext{}, false, err
|
||||
}
|
||||
context, err := s.GetEnvironmentContext(ctx, env.Alias)
|
||||
return context, true, err
|
||||
}
|
||||
|
||||
func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (EnvironmentContext, error) {
|
||||
if !aliasPattern.MatchString(alias) {
|
||||
return EnvironmentContext{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, errors.New("begin environment context read")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE runtime_instance runtime SET released_at = now()
|
||||
FROM environment_binding binding
|
||||
WHERE binding.browser_env_alias = $1 AND runtime.binding_id = binding.id
|
||||
AND runtime.released_at IS NULL AND runtime.lease_until <= now()`, alias); err != nil {
|
||||
return EnvironmentContext{}, errors.New("expire environment runtime")
|
||||
}
|
||||
var result EnvironmentContext
|
||||
var encoded []byte
|
||||
var expectedIP, observedIP string
|
||||
var checked sql.NullTime
|
||||
var runtimeInstanceID, runtimeID sql.NullString
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT environment.alias, environment.name, environment.gateway_name, environment.image_version,
|
||||
environment.fingerprint, environment.created_at, binding.account_id, binding.id, binding.version,
|
||||
binding.runtime_cleanup_pending,
|
||||
COALESCE(network.id, ''), COALESCE(network.protocol, ''), COALESCE(network.host, ''), COALESCE(network.port, 0),
|
||||
COALESCE(host(network.expected_public_ip), ''), COALESCE(network.expected_region, ''),
|
||||
COALESCE(host(network.observed_public_ip), ''), COALESCE(network.observed_region, ''),
|
||||
COALESCE(network.health_status, 'unchecked'), COALESCE(network.last_check_reason, ''),
|
||||
COALESCE(network.version, 0), network.last_checked_at,
|
||||
COALESCE(network.created_at, to_timestamp(0)), COALESCE(network.updated_at, to_timestamp(0)),
|
||||
runtime.id, runtime.runtime_id
|
||||
FROM browser_env environment
|
||||
JOIN environment_binding binding ON binding.browser_env_alias = environment.alias
|
||||
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL
|
||||
WHERE environment.alias = $1`, alias).
|
||||
Scan(&result.Alias, &result.Name, &result.Gateway, &result.ImageVersion, &encoded, &result.CreatedAt,
|
||||
&result.AccountID, &result.BindingID, &result.BindingVersion, &result.RuntimeCleanupPending,
|
||||
&result.Exit.ID, &result.Exit.Protocol, &result.Exit.Host, &result.Exit.Port,
|
||||
&expectedIP, &result.Exit.ExpectedRegion, &observedIP, &result.Exit.ObservedRegion,
|
||||
&result.Exit.HealthStatus, &result.Exit.LastCheckReason, &result.Exit.Version, &checked, &result.Exit.CreatedAt, &result.Exit.UpdatedAt,
|
||||
&runtimeInstanceID, &runtimeID)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, rowError(err)
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &result.Fingerprint); err != nil {
|
||||
return EnvironmentContext{}, errors.New("decode bound environment fingerprint")
|
||||
}
|
||||
result.Fingerprint.ProxyServer = ""
|
||||
result.Fingerprint.DisableNonProxiedUDP = false
|
||||
result.Exit.ExpectedPublicIP, result.Exit.ObservedPublicIP = expectedIP, observedIP
|
||||
if checked.Valid {
|
||||
result.Exit.LastCheckedAt = &checked.Time
|
||||
}
|
||||
result.RuntimeInstanceID, result.RuntimeID = runtimeInstanceID.String, runtimeID.String
|
||||
if err := commitHub(tx); err != nil {
|
||||
return EnvironmentContext{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateEnvironmentRebind(ctx context.Context, tx *sql.Tx, alias, exitID string, expectedBindingVersion int64) (string, string, error) {
|
||||
var accountID, bindingID string
|
||||
var bindingVersion int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT binding.account_id, binding.id, binding.version
|
||||
FROM environment_binding binding
|
||||
JOIN social_account account ON account.id = binding.account_id
|
||||
WHERE binding.browser_env_alias = $1 AND account.status = 'paused'
|
||||
AND account.authorization_status = 'authorized'
|
||||
AND NOT binding.runtime_cleanup_pending
|
||||
FOR UPDATE OF binding, account`, alias).Scan(&accountID, &bindingID, &bindingVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", publicDatabaseError(err)
|
||||
}
|
||||
if bindingVersion != expectedBindingVersion {
|
||||
return "", "", ErrConflict
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE runtime_instance SET released_at = now()
|
||||
WHERE binding_id = $1 AND released_at IS NULL AND lease_until <= now()`, bindingID); err != nil {
|
||||
return "", "", errors.New("expire runtime before rebind")
|
||||
}
|
||||
var allowed bool
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM network_exit WHERE id = $1 AND health_status = 'healthy')
|
||||
AND NOT EXISTS (SELECT 1 FROM operation_task WHERE account_id = $2 AND state = 'executing')
|
||||
AND NOT EXISTS (SELECT 1 FROM runtime_instance WHERE binding_id = $3 AND released_at IS NULL)`,
|
||||
exitID, accountID, bindingID).Scan(&allowed); err != nil {
|
||||
return "", "", errors.New("check environment rebind")
|
||||
}
|
||||
if !allowed {
|
||||
return "", "", ErrConflict
|
||||
}
|
||||
return accountID, bindingID, nil
|
||||
}
|
||||
|
||||
func (s *Store) ValidateEnvironmentRebind(ctx context.Context, alias, exitID string, expectedBindingVersion int64) error {
|
||||
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(exitID) || expectedBindingVersion < 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin environment rebind validation")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, _, err := validateEnvironmentRebind(ctx, tx, alias, exitID, expectedBindingVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64) (EnvironmentContext, error) {
|
||||
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(exitID) ||
|
||||
(runtimeID != "" && !exitIDPattern.MatchString(runtimeID)) || expectedBindingVersion < 1 {
|
||||
return EnvironmentContext{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, errors.New("begin environment rebind")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
accountID, bindingID, err := validateEnvironmentRebind(ctx, tx, alias, exitID, expectedBindingVersion)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET network_exit_id = $2, version = version + 1, updated_at = now() WHERE browser_env_alias = $1`, alias, exitID); err != nil {
|
||||
return EnvironmentContext{}, errors.New("update environment binding")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE social_account SET version = version + 1, updated_at = now() WHERE id = $1`, accountID); err != nil {
|
||||
return EnvironmentContext{}, errors.New("version rebound account")
|
||||
}
|
||||
if runtimeID != "" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, runtime_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, now() + interval '1 minute')`, "runtime-"+newHubID(), accountID, bindingID, runtimeID); err != nil {
|
||||
return EnvironmentContext{}, publicDatabaseError(err)
|
||||
}
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return EnvironmentContext{}, err
|
||||
}
|
||||
return s.GetEnvironmentContext(ctx, alias)
|
||||
}
|
||||
|
||||
func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string) (EnvironmentContext, error) {
|
||||
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 || !exitIDPattern.MatchString(exitID) {
|
||||
return EnvironmentContext{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, errors.New("begin runtime activation")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var accountID, bindingID, currentExitID string
|
||||
var currentBindingVersion int64
|
||||
var cleanupPending bool
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT binding.account_id, binding.id, binding.version, COALESCE(binding.network_exit_id, ''), binding.runtime_cleanup_pending
|
||||
FROM environment_binding binding
|
||||
WHERE binding.browser_env_alias = $1 FOR UPDATE OF binding`, alias).
|
||||
Scan(&accountID, &bindingID, ¤tBindingVersion, ¤tExitID, &cleanupPending)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, rowError(err)
|
||||
}
|
||||
if cleanupPending || currentBindingVersion != bindingVersion || currentExitID != exitID {
|
||||
return EnvironmentContext{}, ErrConflict
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE runtime_instance SET released_at = now()
|
||||
WHERE binding_id = $1 AND released_at IS NULL AND lease_until <= now()`, bindingID); err != nil {
|
||||
return EnvironmentContext{}, errors.New("expire runtime before activation")
|
||||
}
|
||||
var existingInstanceID, existingRuntimeID string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(id, ''), COALESCE(runtime_id, '') FROM runtime_instance
|
||||
WHERE binding_id = $1 AND released_at IS NULL`, bindingID).Scan(&existingInstanceID, &existingRuntimeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return EnvironmentContext{}, publicDatabaseError(err)
|
||||
}
|
||||
if existingInstanceID != "" && existingRuntimeID != runtimeID {
|
||||
return EnvironmentContext{}, ErrConflict
|
||||
}
|
||||
if existingInstanceID == "" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, runtime_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, now() + interval '1 minute')`, "runtime-"+newHubID(), accountID, bindingID, runtimeID); err != nil {
|
||||
return EnvironmentContext{}, publicDatabaseError(err)
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '1 minute' WHERE id = $1`, existingInstanceID); err != nil {
|
||||
return EnvironmentContext{}, errors.New("renew environment runtime")
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return EnvironmentContext{}, err
|
||||
}
|
||||
return s.GetEnvironmentContext(ctx, alias)
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseRuntime(ctx context.Context, alias string) error {
|
||||
if !aliasPattern.MatchString(alias) {
|
||||
return ErrInvalid
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE runtime_instance runtime SET released_at = now()
|
||||
FROM environment_binding binding
|
||||
WHERE binding.browser_env_alias = $1 AND runtime.binding_id = binding.id AND runtime.released_at IS NULL`, alias)
|
||||
if err != nil {
|
||||
return errors.New("release environment runtime")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRuntimeCleanupPending(ctx context.Context, alias string, pending bool) error {
|
||||
if !aliasPattern.MatchString(alias) {
|
||||
return ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin runtime cleanup state update")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var bindingID string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
UPDATE environment_binding SET runtime_cleanup_pending = $2, updated_at = now()
|
||||
WHERE browser_env_alias = $1 RETURNING id`, alias, pending).Scan(&bindingID); err != nil {
|
||||
return rowError(err)
|
||||
}
|
||||
if pending {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE runtime_instance SET released_at = now()
|
||||
WHERE binding_id = $1 AND released_at IS NULL`, bindingID); err != nil {
|
||||
return errors.New("release runtime for pending cleanup")
|
||||
}
|
||||
}
|
||||
return commitHub(tx)
|
||||
}
|
||||
|
||||
func (s *Store) AppendEnvironmentAction(ctx context.Context, eventType string, action EnvironmentAction) error {
|
||||
if (eventType != "environment_action_requested" && eventType != "environment_action_finished") ||
|
||||
!exitIDPattern.MatchString(action.OperationID) || action.Action == "" || action.ReasonCode == "" ||
|
||||
(action.OldImageVersion != "" && !imageVersionPattern.MatchString(action.OldImageVersion)) ||
|
||||
(action.NewImageVersion != "" && !imageVersionPattern.MatchString(action.NewImageVersion)) ||
|
||||
(eventType == "environment_action_finished" && action.Outcome != "succeeded" && action.Outcome != "failed" && action.Outcome != "unknown") {
|
||||
return ErrInvalid
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_event
|
||||
(event_type, account_id, browser_env_alias, network_exit_id, runtime_instance_id,
|
||||
binding_version, actor, reason_code, operation_id, action, outcome, old_image_version, new_image_version)
|
||||
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''),
|
||||
NULLIF($6, 0), 'local-user', $7, $8, $9, NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''))`,
|
||||
eventType, action.AccountID, action.BrowserEnvAlias, action.NetworkExitID, action.RuntimeInstanceID,
|
||||
action.BindingVersion, action.ReasonCode, action.OperationID, action.Action, action.Outcome,
|
||||
action.OldImageVersion, action.NewImageVersion)
|
||||
if err != nil {
|
||||
return errors.New("append environment action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewOperationID() string { return "operation-" + newHubID() }
|
||||
|
||||
func newHubID() string {
|
||||
var value [12]byte
|
||||
_, _ = rand.Read(value[:])
|
||||
return hex.EncodeToString(value[:])
|
||||
}
|
||||
@@ -29,12 +29,13 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3)`, 3)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3, 4, 5, 6)`, 6)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1)
|
||||
|
||||
store = openFullyMigratedHub(t, ctx, testURL)
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3)`, 3)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3, 4, 5, 6)`, 6)
|
||||
})
|
||||
|
||||
t.Run("v1 and v2 data", func(t *testing.T) {
|
||||
@@ -72,7 +73,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
INSERT INTO gateway (name, endpoint, token) VALUES ('legacy-gateway', 'http://127.0.0.1:8081', 'legacy-gateway-token');
|
||||
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1');
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES
|
||||
('mapped', 'Mapped', 'legacy-gateway', '1', '{"seed":1}'),
|
||||
('mapped', 'Mapped', 'legacy-gateway', '1', '{"seed":1,"proxy_server":"http://legacy:secret@proxy.example:8080","disable_non_proxied_udp":true}'),
|
||||
('orphan-env', 'Orphan', 'legacy-gateway', '1', '{"seed":2}');
|
||||
INSERT INTO runtime_instance (id, account_id, runtime_id, lease_until) VALUES
|
||||
('instance-mapped', 'mapped', 'runtime-mapped', now() + interval '1 hour'),
|
||||
@@ -90,11 +91,22 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE platform = 'mock' AND platform_account_key = id AND status = 'paused'`, 2)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE profile_id LIKE 'legacy-profile-%'`, 2)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'mapped' AND browser_env_alias = 'mapped' AND network_exit_id IS NULL`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'mapped' AND NOT runtime_cleanup_pending`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'unbound'`, 0)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-mapped' AND binding_id = 'mapped'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-unbound' AND binding_id IS NULL`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM audit_event WHERE event_type = 'legacy_event'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'legacy-task' AND state = 'policy_hold'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND NOT (fingerprint ?| ARRAY['proxy_server', 'disable_non_proxied_udp'])`, 1)
|
||||
store, err = Open(ctx, testURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := store.GetEnvironmentContext(ctx, "mapped")
|
||||
store.Close()
|
||||
if err != nil || legacy.Exit.ID != "" || legacy.Fingerprint.ProxyServer != "" || legacy.Fingerprint.DisableNonProxiedUDP {
|
||||
t.Fatalf("legacy NULL binding must remain visible without persisted proxy credentials: %#v err=%v", legacy, err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO credential_reference (id, provider, reference_key) VALUES ('credential-duplicate', 'os_keyring', 'creatorhub/duplicate');
|
||||
@@ -154,6 +166,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
}
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND version = 2 AND image_version = '2'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE id = 'mapped' AND version = 2`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE id = 'mapped' AND version = 2 AND status = 'paused'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'upgrade-task' AND state = 'policy_hold'`, 1)
|
||||
|
||||
@@ -162,7 +175,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version = 3`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (3, 4, 5, 6)`, 4)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE audit_event
|
||||
ADD COLUMN operation_id text,
|
||||
ADD COLUMN action text,
|
||||
ADD COLUMN outcome text CHECK (outcome IN ('succeeded', 'failed', 'unknown')),
|
||||
ADD COLUMN old_image_version text,
|
||||
ADD COLUMN new_image_version text;
|
||||
|
||||
ALTER TABLE network_exit
|
||||
ADD COLUMN last_check_reason text;
|
||||
|
||||
CREATE INDEX audit_event_operation_id_idx
|
||||
ON audit_event (operation_id) WHERE operation_id IS NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE browser_env
|
||||
SET fingerprint = fingerprint - 'proxy_server' - 'disable_non_proxied_udp'
|
||||
WHERE fingerprint ?| ARRAY['proxy_server', 'disable_non_proxied_udp'];
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE environment_binding
|
||||
ADD COLUMN runtime_cleanup_pending boolean NOT NULL DEFAULT false;
|
||||
+30
-2
@@ -25,12 +25,23 @@ var migration002 string
|
||||
//go:embed migrations/003_unified_accounts.sql
|
||||
var migration003 string
|
||||
|
||||
//go:embed migrations/004_environment_actions.sql
|
||||
var migration004 string
|
||||
|
||||
//go:embed migrations/005_sanitize_legacy_proxy.sql
|
||||
var migration005 string
|
||||
|
||||
//go:embed migrations/006_runtime_cleanup.sql
|
||||
var migration006 string
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("resource conflicts with existing state")
|
||||
ErrInvalid = errors.New("invalid hub input")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
)
|
||||
|
||||
func ValidImageVersion(version string) bool { return imageVersionPattern.MatchString(version) }
|
||||
|
||||
var (
|
||||
aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
@@ -107,7 +118,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
for _, migration := range []struct {
|
||||
version int
|
||||
sql string
|
||||
}{{2, migration002}, {3, migration003}} {
|
||||
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}} {
|
||||
var applied bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
|
||||
return errors.New("read hub schema migration state")
|
||||
@@ -273,7 +284,8 @@ func (s *Store) CreateEnv(ctx context.Context, env Env) error {
|
||||
env.Alias = strings.TrimSpace(env.Alias)
|
||||
env.Name = strings.TrimSpace(env.Name)
|
||||
if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) ||
|
||||
!gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) {
|
||||
!gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) ||
|
||||
env.Fingerprint.ProxyServer != "" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if err := env.Fingerprint.Validate(); err != nil {
|
||||
@@ -341,6 +353,15 @@ func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error {
|
||||
return errors.New("begin environment upgrade")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var cleanupPending bool
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT runtime_cleanup_pending FROM environment_binding
|
||||
WHERE browser_env_alias = $1 FOR UPDATE`, alias).Scan(&cleanupPending); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return publicDatabaseError(err)
|
||||
}
|
||||
if cleanupPending {
|
||||
return ErrConflict
|
||||
}
|
||||
var updated string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
UPDATE browser_env SET image_version = $2, version = version + 1
|
||||
@@ -348,6 +369,11 @@ func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error {
|
||||
RETURNING alias`, alias, version).Scan(&updated); err != nil {
|
||||
return rowError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE environment_binding SET version = version + 1, updated_at = now()
|
||||
WHERE browser_env_alias = $1`, alias); err != nil {
|
||||
return errors.New("version upgraded environment binding")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
WITH changed AS (
|
||||
UPDATE social_account account
|
||||
@@ -385,6 +411,8 @@ func scanEnv(rows *sql.Rows) (Env, error) {
|
||||
return Env{}, errors.New("decode env fingerprint")
|
||||
}
|
||||
}
|
||||
env.Fingerprint.ProxyServer = ""
|
||||
env.Fingerprint.DisableNonProxiedUDP = false
|
||||
return env, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,22 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 0}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid fingerprint, got %v", err)
|
||||
}
|
||||
for name, exit := range map[string]NetworkExit{
|
||||
"protocol": {Protocol: "direct", Host: "proxy.example", Port: 1080},
|
||||
"userinfo": {Protocol: "socks5", Host: "user@proxy.example", Port: 1080},
|
||||
"URL host": {Protocol: "socks5", Host: "socks5://proxy.example", Port: 1080},
|
||||
"port": {Protocol: "socks5", Host: "proxy.example", Port: 0},
|
||||
"ip": {Protocol: "socks5", Host: "proxy.example", Port: 1080, ExpectedPublicIP: "not-an-ip"},
|
||||
} {
|
||||
t.Run("network exit "+name, func(t *testing.T) {
|
||||
if _, err := store.CreateNetworkExit(ctx, exit, ""); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid network exit, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1, ProxyServer: "socks5://proxy.example:1080"}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("stored fingerprint proxy must be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubWorkflow(t *testing.T) {
|
||||
@@ -224,3 +240,158 @@ func TestHubWorkflow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
|
||||
}
|
||||
ctx := context.Background()
|
||||
store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL))
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE audit_event, runtime_instance, environment_binding, network_exit,
|
||||
social_account, credential_reference, browser_env, browser_image, gateway CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO credential_reference (id, provider, reference_key)
|
||||
VALUES ('credential-exit', 'os_keyring', 'creatorhub/proxy-main'),
|
||||
('credential-account', 'os_keyring', 'creatorhub/account-a');
|
||||
INSERT INTO social_account
|
||||
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status)
|
||||
VALUES ('account-a', 'credential-account', 'mock', 'account-a', 'owned', 'authorized')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreateGateway(ctx, "gw-main", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "148", ImageRef: "example/browser:148", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exit, err := store.CreateNetworkExit(ctx, NetworkExit{
|
||||
Protocol: "socks5", Host: "proxy.example", Port: 1080,
|
||||
ExpectedPublicIP: "203.0.113.10", ExpectedRegion: "test-region",
|
||||
}, "credential-exit")
|
||||
if err != nil || exit.HealthStatus != "unchecked" || exit.CredentialReference == nil || exit.CredentialReference.ID != "credential-exit" {
|
||||
t.Fatalf("unexpected network exit: %#v err=%v", exit, err)
|
||||
}
|
||||
exported, _ := json.Marshal(exit)
|
||||
if strings.Contains(string(exported), "creatorhub/proxy-main") {
|
||||
t.Fatalf("network exit response leaked a credential reference key: %s", exported)
|
||||
}
|
||||
access, err := store.GetNetworkExitAccess(ctx, exit.ID)
|
||||
if err != nil || access.CredentialKey != "creatorhub/proxy-main" {
|
||||
t.Fatalf("runtime-only credential resolution data unavailable: %#v err=%v", access, err)
|
||||
}
|
||||
|
||||
exit, reason, err := store.RecordNetworkExitCheck(ctx, exit.ID, ExitObservation{PublicIP: "203.0.113.11", Region: "test-region"}, "")
|
||||
if err != nil || exit.HealthStatus != "unhealthy" || reason != "exit_ip_drift" {
|
||||
t.Fatalf("identity drift must make the exit unhealthy: %#v reason=%s err=%v", exit, reason, err)
|
||||
}
|
||||
exit, reason, err = store.RecordNetworkExitCheck(ctx, exit.ID, ExitObservation{PublicIP: "203.0.113.10", Region: "test-region"}, "")
|
||||
if err != nil || exit.HealthStatus != "healthy" || reason != "exit_healthy" {
|
||||
t.Fatalf("matching identity must make the exit healthy: %#v reason=%s err=%v", exit, reason, err)
|
||||
}
|
||||
|
||||
env := Env{Alias: "environment-a", Name: "环境 A", Gateway: "gw-main", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1}}
|
||||
bound, created, err := store.CreateBoundEnv(ctx, env, "account-a", exit.ID)
|
||||
if err != nil || !created || bound.AccountID != "account-a" || bound.Exit.ID != exit.ID {
|
||||
t.Fatalf("create stable binding: %#v created=%v err=%v", bound, created, err)
|
||||
}
|
||||
reused, created, err := store.CreateBoundEnv(ctx, env, "account-a", exit.ID)
|
||||
if err != nil || created || reused.Alias != bound.Alias || reused.BindingID != bound.BindingID {
|
||||
t.Fatalf("same account must reuse its environment: %#v created=%v err=%v", reused, created, err)
|
||||
}
|
||||
active, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID)
|
||||
if err != nil || active.RuntimeInstanceID == "" {
|
||||
t.Fatalf("activate runtime: %#v err=%v", active, err)
|
||||
}
|
||||
|
||||
second, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, err = store.RecordNetworkExitCheck(ctx, second.ID, ExitObservation{PublicIP: "198.51.100.2", Region: "other"}, "")
|
||||
if err != nil || second.HealthStatus != "healthy" {
|
||||
t.Fatalf("prepare second exit: %#v err=%v", second, err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO credential_reference (id, provider, reference_key) VALUES ('credential-account-b', 'os_keyring', 'creatorhub/account-b');
|
||||
INSERT INTO social_account
|
||||
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status)
|
||||
VALUES ('account-b', 'credential-account-b', 'mock', 'account-b', 'owned', 'authorized');
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
VALUES ('environment-b', '环境 B', 'gw-main', '148', '{"seed":2}');
|
||||
INSERT INTO environment_binding (id, account_id, browser_env_alias)
|
||||
VALUES ('binding-b', 'account-b', 'environment-b')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyRebound, err := store.RebindEnvironment(ctx, "environment-b", second.ID, "", 1)
|
||||
if err != nil || legacyRebound.Exit.ID != second.ID {
|
||||
t.Fatalf("legacy binding without an exit must support explicit rebind: %#v err=%v", legacyRebound, err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion+1, second.ID); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale binding metadata must not activate a runtime: %v", err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '1 second' WHERE id = $1`, active.RuntimeInstanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID); err != nil {
|
||||
t.Fatalf("runtime heartbeat failed: %v", err)
|
||||
}
|
||||
var renewed bool
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT lease_until > now() + interval '30 seconds' FROM runtime_instance WHERE id = $1`, active.RuntimeInstanceID).Scan(&renewed); err != nil || !renewed {
|
||||
t.Fatalf("runtime lease was not renewed: renewed=%v err=%v", renewed, err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() - interval '1 second' WHERE id = $1`, active.RuntimeInstanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rebound, err := store.RebindEnvironment(ctx, env.Alias, second.ID, "", bound.BindingVersion)
|
||||
if err != nil || rebound.Exit.ID != second.ID || rebound.BindingVersion != 2 {
|
||||
t.Fatalf("expired runtime must be transactionally released before rebind: %#v err=%v", rebound, err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "same-exit-container", rebound.BindingVersion, rebound.Exit.ID); err != nil {
|
||||
t.Fatalf("activate runtime before same-exit rebind: %v", err)
|
||||
}
|
||||
if _, err := store.RebindEnvironment(ctx, env.Alias, second.ID, "", rebound.BindingVersion); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("active runtime must block same-exit rebind: %v", err)
|
||||
}
|
||||
if err := store.ReleaseRuntime(ctx, env.Alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rebound, err = store.RebindEnvironment(ctx, env.Alias, second.ID, "rebound-container", rebound.BindingVersion)
|
||||
if err != nil || rebound.BindingVersion != 3 || rebound.RuntimeID != "rebound-container" {
|
||||
t.Fatalf("same-exit rebind must atomically CAS the binding and runtime: %#v err=%v", rebound, err)
|
||||
}
|
||||
|
||||
action := EnvironmentAction{
|
||||
OperationID: NewOperationID(), Action: "start", AccountID: rebound.AccountID,
|
||||
BrowserEnvAlias: rebound.Alias, NetworkExitID: rebound.Exit.ID, BindingVersion: rebound.BindingVersion,
|
||||
ReasonCode: "action_requested",
|
||||
}
|
||||
if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
action.Outcome, action.ReasonCode = "succeeded", "environment_started"
|
||||
if err := store.AppendEnvironmentAction(ctx, "environment_action_finished", action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE operation_id = '`+action.OperationID+`'`, 2)
|
||||
invalidAction := action
|
||||
invalidAction.OperationID = NewOperationID()
|
||||
invalidAction.NewImageVersion = "http://operator:secret@proxy.example"
|
||||
if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", invalidAction); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid image version must not reach audit persistence: %v", err)
|
||||
}
|
||||
assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE operation_id = '`+invalidAction.OperationID+`'`, 0)
|
||||
var auditText string
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT string_agg(row_to_json(event)::text, '') FROM audit_event event`).Scan(&auditText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"creatorhub/proxy-main", "credential-exit", "username", "password"} {
|
||||
if strings.Contains(auditText, forbidden) {
|
||||
t.Fatalf("audit leaked sensitive value %q: %s", forbidden, auditText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ type AuditEvent struct {
|
||||
BindingVersion int64 `json:"binding_version,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
OldImageVersion string `json:"old_image_version,omitempty"`
|
||||
NewImageVersion string `json:"new_image_version,omitempty"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -730,6 +735,7 @@ func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id,
|
||||
browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code,
|
||||
operation_id, action, outcome, old_image_version, new_image_version,
|
||||
details, created_at
|
||||
FROM audit_event ORDER BY id`)
|
||||
if err != nil {
|
||||
@@ -740,10 +746,11 @@ func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) {
|
||||
for rows.Next() {
|
||||
var event AuditEvent
|
||||
var accountID, confirmationID, attemptID, taskID, browserEnvAlias, networkExitID sql.NullString
|
||||
var runtimeInstanceID, actor, reasonCode sql.NullString
|
||||
var runtimeInstanceID, actor, reasonCode, operationID, action, outcome, oldImage, newImage sql.NullString
|
||||
var confirmationVersion, bindingVersion sql.NullInt64
|
||||
if err := rows.Scan(&event.ID, &event.EventType, &accountID, &confirmationID, &confirmationVersion, &attemptID, &taskID,
|
||||
&browserEnvAlias, &networkExitID, &runtimeInstanceID, &bindingVersion, &actor, &reasonCode,
|
||||
&operationID, &action, &outcome, &oldImage, &newImage,
|
||||
&event.Details, &event.CreatedAt); err != nil {
|
||||
return nil, errors.New("decode audit event")
|
||||
}
|
||||
@@ -752,6 +759,8 @@ func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) {
|
||||
event.BrowserEnvAlias, event.NetworkExitID = browserEnvAlias.String, networkExitID.String
|
||||
event.RuntimeInstanceID, event.BindingVersion = runtimeInstanceID.String, bindingVersion.Int64
|
||||
event.Actor, event.ReasonCode = actor.String, reasonCode.String
|
||||
event.OperationID, event.Action, event.Outcome = operationID.String, action.String, outcome.String
|
||||
event.OldImageVersion, event.NewImageVersion = oldImage.String, newImage.String
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
|
||||
@@ -343,7 +343,7 @@ func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) {
|
||||
for _, migrationFile := range []struct {
|
||||
version int
|
||||
name string
|
||||
}{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}} {
|
||||
}{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}} {
|
||||
var applied bool
|
||||
if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+33
-11
@@ -17,7 +17,6 @@ import {
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -54,6 +53,8 @@ const emptyForm = {
|
||||
alias: '',
|
||||
gateway: '',
|
||||
image_version: '',
|
||||
account_id: '',
|
||||
network_exit_id: '',
|
||||
seed: '1000',
|
||||
platform: '',
|
||||
platform_version: '',
|
||||
@@ -63,8 +64,6 @@ const emptyForm = {
|
||||
lang: '',
|
||||
accept_lang: '',
|
||||
timezone: '',
|
||||
proxy_server: '',
|
||||
disable_non_proxied_udp: false,
|
||||
disable_spoofing: [],
|
||||
}
|
||||
|
||||
@@ -78,13 +77,11 @@ function buildFingerprint(form) {
|
||||
lang: form.lang,
|
||||
accept_lang: form.accept_lang,
|
||||
timezone: form.timezone,
|
||||
proxy_server: form.proxy_server,
|
||||
}
|
||||
for (const [key, value] of Object.entries(optionalText)) {
|
||||
if (value !== '') fingerprint[key] = value
|
||||
}
|
||||
if (form.hardware_concurrency !== '') fingerprint.hardware_concurrency = Number(form.hardware_concurrency)
|
||||
if (form.disable_non_proxied_udp) fingerprint.disable_non_proxied_udp = true
|
||||
if (form.disable_spoofing.length > 0) fingerprint.disable_spoofing = form.disable_spoofing.join(',')
|
||||
return fingerprint
|
||||
}
|
||||
@@ -117,10 +114,14 @@ function Copyable({ value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }) {
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [advanced, setAdvanced] = useState(false)
|
||||
const enabledImages = images.filter(image => image.enabled)
|
||||
const availableAccounts = accounts.filter(account => account.authorization_status === 'authorized' && account.runtime_status === 'paused')
|
||||
const healthyExits = networkExits.filter(exit => exit.health_status === 'healthy')
|
||||
const defaultAccountID = availableAccounts[0]?.id ?? ''
|
||||
const defaultExitID = healthyExits[0]?.id ?? ''
|
||||
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
const toggleSpoofing = option => setForm(current => ({
|
||||
...current,
|
||||
@@ -135,10 +136,17 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
useEffect(() => {
|
||||
if (form.image_version === '' && enabledImages.length > 0) update('image_version', enabledImages[0].version)
|
||||
}, [enabledImages, form.image_version])
|
||||
useEffect(() => {
|
||||
if (form.account_id === '' && defaultAccountID !== '') update('account_id', defaultAccountID)
|
||||
}, [defaultAccountID, form.account_id])
|
||||
useEffect(() => {
|
||||
if (form.network_exit_id === '' && defaultExitID !== '') update('network_exit_id', defaultExitID)
|
||||
}, [defaultExitID, form.network_exit_id])
|
||||
|
||||
const seedNumber = Number(form.seed)
|
||||
const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' &&
|
||||
form.image_version !== '' && Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
|
||||
form.image_version !== '' && form.account_id !== '' && form.network_exit_id !== '' &&
|
||||
Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
|
||||
|
||||
const submit = event => {
|
||||
event.preventDefault()
|
||||
@@ -148,9 +156,11 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
name: form.name.trim(),
|
||||
gateway: form.gateway,
|
||||
image_version: form.image_version,
|
||||
account_id: form.account_id,
|
||||
network_exit_id: form.network_exit_id,
|
||||
fingerprint: buildFingerprint(form),
|
||||
})
|
||||
setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version }))
|
||||
setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version, account_id: current.account_id, network_exit_id: current.network_exit_id }))
|
||||
}
|
||||
|
||||
const label = (htmlFor, text, required = false) => (
|
||||
@@ -185,6 +195,18 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
{label('env-seed', 'Fingerprint Seed', true)}
|
||||
<TextField id="env-seed" required type="number" slotProps={{ htmlInput: { 'aria-label': 'Fingerprint Seed', min: 1, max: 2147483647 } }} value={form.seed} onChange={event => update('seed', event.target.value)} />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{label('env-account', '社媒账号', true)}
|
||||
<TextField id="env-account" select required slotProps={{ htmlInput: { 'aria-label': '社媒账号' } }} value={form.account_id} onChange={event => update('account_id', event.target.value)} disabled={availableAccounts.length === 0} helperText={availableAccounts.length === 0 ? '请先准备已授权且暂停的账号' : ' '}>
|
||||
{availableAccounts.map(account => <MenuItem key={account.id} value={account.id}>{account.id} · {account.platform}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{label('env-exit', '网络出口', true)}
|
||||
<TextField id="env-exit" select required slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} disabled={healthyExits.length === 0} helperText={healthyExits.length === 0 ? '请先检查并启用健康出口' : ' '}>
|
||||
{healthyExits.map(exit => <MenuItem key={exit.id} value={exit.id}>{exit.id} · {exit.protocol}://{exit.host}:{exit.port}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1} sx={{ gridColumn: { md: '1 / -1' } }}>
|
||||
<Button type="button" variant="text" onClick={() => setAdvanced(value => !value)} endIcon={<ExpandMoreOutlined sx={{ transform: advanced ? 'rotate(180deg)' : 'none', transition: theme => theme.transitions.create('transform') }} />} sx={{ justifySelf: 'start' }}>
|
||||
@@ -206,8 +228,6 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
<TextField label="语言 lang" value={form.lang} onChange={event => update('lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '语言 lang' } }} placeholder="如 zh-CN" />
|
||||
<TextField label="接受语言 accept-lang" value={form.accept_lang} onChange={event => update('accept_lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '接受语言 accept-lang' } }} placeholder="如 zh-CN,en-US" />
|
||||
<TextField label="时区 timezone" value={form.timezone} onChange={event => update('timezone', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '时区 timezone' } }} placeholder="如 Asia/Shanghai" />
|
||||
<TextField label="代理 proxy-server" value={form.proxy_server} onChange={event => update('proxy_server', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '代理 proxy-server' } }} placeholder="如 socks5://127.0.0.1:1080" />
|
||||
<FormControlLabel control={<Switch checked={form.disable_non_proxied_udp} onChange={event => update('disable_non_proxied_udp', event.target.checked)} slotProps={{ input: { 'aria-label': '禁用非代理 UDP' } }} />} label="禁用非代理 UDP(WebRTC)" sx={{ gridColumn: { md: 'span 2' } }} />
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="body2" fontWeight={650}>禁用指纹伪装 disable-spoofing</Typography>
|
||||
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
@@ -283,6 +303,8 @@ export function BrowserList() {
|
||||
const { data: runtimes = [], error, isPending, refetch } = useGetList('browsers', {}, { refetchInterval: 3000 })
|
||||
const { data: gateways = [] } = useGetList('gateways')
|
||||
const { data: images = [] } = useGetList('browser-images')
|
||||
const { data: accounts = [] } = useGetList('accounts')
|
||||
const { data: networkExits = [] } = useGetList('network-exits')
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'CreatorHub · 运行环境'
|
||||
@@ -335,7 +357,7 @@ export function BrowserList() {
|
||||
<>
|
||||
<Box component="header" sx={{ mb: 4 }}><Typography variant="h1">运行环境</Typography><Typography color="text.secondary" sx={{ mt: 1, fontSize: '1.05rem' }}>启动、停止、升级并回收隔离的指纹浏览器</Typography></Box>
|
||||
|
||||
<CreateForm gateways={gateways} images={images} onSubmit={createRuntime} busy={busy === 'create'} />
|
||||
<CreateForm gateways={gateways} images={images} accounts={accounts} networkExits={networkExits} onSubmit={createRuntime} busy={busy === 'create'} />
|
||||
|
||||
{message ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => { setLocalError(''); refetch() }}>重试</Button>} sx={{ mb: 2.5 }}>{message}</Alert> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载运行环境" /></Box> : null}
|
||||
|
||||
@@ -16,6 +16,8 @@ const images = [
|
||||
{ id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', enabled: true },
|
||||
{ id: '139.0.0.1', version: '139.0.0.1', image_ref: 'reg/img:139', enabled: false },
|
||||
]
|
||||
const accounts = [{ id: 'social-a', platform: 'douyin', authorization_status: 'authorized', runtime_status: 'paused' }]
|
||||
const networkExits = [{ id: 'exit-1', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy' }]
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
@@ -23,6 +25,8 @@ function provider(overrides = {}) {
|
||||
if (resource === 'browsers') return Promise.resolve({ data: runtimes, total: runtimes.length })
|
||||
if (resource === 'gateways') return Promise.resolve({ data: gateways, total: gateways.length })
|
||||
if (resource === 'browser-images') return Promise.resolve({ data: images, total: images.length })
|
||||
if (resource === 'accounts') return Promise.resolve({ data: accounts, total: accounts.length })
|
||||
if (resource === 'network-exits') return Promise.resolve({ data: networkExits, total: networkExits.length })
|
||||
return Promise.reject(new Error(`unsupported ${resource}`))
|
||||
}),
|
||||
create: vi.fn().mockResolvedValue({ data: { id: 'account-a', alias: 'account-a' } }),
|
||||
@@ -77,7 +81,7 @@ describe('BrowserList', () => {
|
||||
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined))
|
||||
})
|
||||
|
||||
it('creates an env with chinese name, alias, gateway and enabled image version', async () => {
|
||||
it('creates an env with an account and healthy network exit without legacy proxy fields', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('店铺一号')
|
||||
@@ -91,8 +95,12 @@ describe('BrowserList', () => {
|
||||
name: '店铺三号',
|
||||
gateway: 'gw-1',
|
||||
image_version: '148.0.0.1',
|
||||
account_id: 'social-a',
|
||||
network_exit_id: 'exit-1',
|
||||
fingerprint: { seed: 1000 },
|
||||
} }))
|
||||
expect(screen.queryByLabelText('代理 proxy-server')).toBeNull()
|
||||
expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull()
|
||||
})
|
||||
|
||||
it('upgrades an env through the version dialog', async () => {
|
||||
|
||||
@@ -21,6 +21,8 @@ const resourcePaths = {
|
||||
browsers: '/browsers',
|
||||
'browser-images': '/browser-images',
|
||||
gateways: '/gateways',
|
||||
accounts: '/phase-a/accounts',
|
||||
'network-exits': '/network-exits',
|
||||
}
|
||||
|
||||
export const dataProvider = {
|
||||
@@ -28,7 +30,7 @@ export const dataProvider = {
|
||||
const path = resourcePaths[resource]
|
||||
if (!path) return unsupported(resource, 'getList')
|
||||
const records = await request(path)
|
||||
return { data: records.map(record => ({ ...record, id: record.alias ?? record.version ?? record.name })), total: records.length }
|
||||
return { data: records.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.length }
|
||||
},
|
||||
async create(resource, { data }) {
|
||||
const path = resourcePaths[resource]
|
||||
|
||||
@@ -15,6 +15,17 @@ describe('dataProvider', () => {
|
||||
expect(fetch).toHaveBeenCalledWith('/api/browsers', undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['accounts', '/api/phase-a/accounts'],
|
||||
['network-exits', '/api/network-exits'],
|
||||
])('loads %s for the environment create contract', async (resource, path) => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('[{"id":"record-1"}]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await expect(dataProvider.getList(resource)).resolves.toMatchObject({ data: [{ id: 'record-1' }] })
|
||||
expect(fetch).toHaveBeenCalledWith(path, undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['start', '/api/browsers/account-a/start', 'POST'],
|
||||
['stop', '/api/browsers/account-a/stop', 'POST'],
|
||||
|
||||
@@ -16,6 +16,8 @@ test('keeps the create form inside a 900px viewport', async ({ page }) => {
|
||||
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
|
||||
await page.setViewportSize({ width: 900, height: 800 })
|
||||
await page.goto('/')
|
||||
|
||||
@@ -30,6 +32,10 @@ test('keeps the create form inside a 900px viewport', async ({ page }) => {
|
||||
|
||||
test('shows the CDP endpoint only in the active branch at 900px and 599px', async ({ page }) => {
|
||||
await page.route('**/api/browsers', route => route.fulfill({ json: [runtime] }))
|
||||
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
|
||||
const endpoint = page.getByText('http://account-a:9222')
|
||||
|
||||
await page.setViewportSize({ width: 900, height: 800 })
|
||||
|
||||
Reference in New Issue
Block a user