HH-803: add stable network exit orchestration (#19)

This commit is contained in:
2026-08-29 07:59:55 +08:00
parent 884505bb55
commit f1ccfa0096
25 changed files with 6082 additions and 368 deletions
+1151 -119
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -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",
+175
View File
@@ -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
}
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+362
View File
@@ -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
}
+154
View File
@@ -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")
}
}
-1
View File
@@ -67,6 +67,5 @@ services:
networks:
control:
name: creatorhub_control
volumes:
creatorhub_postgres:
+8 -2
View File
@@ -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 也会在绑定事务中回收。
+662
View File
@@ -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, &currentBindingVersion, &currentExitID, &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[:])
}
+17 -4
View File
@@ -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
View File
@@ -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
}
+171
View File
@@ -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)
}
}
}
+10 -1
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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="禁用非代理 UDPWebRTC" 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}
+9 -1
View File
@@ -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 () => {
+3 -1
View File
@@ -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]
+11
View File
@@ -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'],
+6
View File
@@ -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 })