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
}