feat(socket-proxy): implement Docker socket proxy and related configurations

- Updated Dockerfile and Makefile for socket-proxy build.
- Modified go.mod to include necessary dependencies.
- Updated CI workflows for socket-proxy integration.
- Better module isolation
- Code refactor
This commit is contained in:
yusing
2025-05-10 09:47:03 +08:00
parent 4ddfb48b9d
commit 8fe94d6d14
38 changed files with 658 additions and 523 deletions

101
socket-proxy/pkg/env.go Normal file
View File

@@ -0,0 +1,101 @@
package socketproxy
import (
"log"
"os"
"strconv"
)
var (
DockerSocket,
ListenAddr string
DockerPost,
DockerRestarts,
DockerStart,
DockerStop,
DockerAuth,
DockerBuild,
DockerCommit,
DockerConfigs,
DockerContainers,
DockerDistribution,
DockerEvents,
DockerExec,
DockerGrpc,
DockerImages,
DockerInfo,
DockerNetworks,
DockerNodes,
DockerPing,
DockerPlugins,
DockerSecrets,
DockerServices,
DockerSession,
DockerSwarm,
DockerSystem,
DockerTasks,
DockerVersion,
DockerVolumes bool
)
func init() {
Load()
}
func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
value, ok := os.LookupEnv(key)
if !ok || value == "" {
return defaultValue
}
parsed, err := parser(value)
if err != nil {
log.Fatalf("env %s: invalid %T value: %s", key, parsed, value)
}
return parsed
}
func GetEnvString(key string, defaultValue string) string {
return GetEnv(key, defaultValue, stringstring)
}
func GetEnvBool(key string, defaultValue bool) bool {
return GetEnv(key, defaultValue, strconv.ParseBool)
}
func stringstring(s string) (string, error) {
return s, nil
}
func Load() {
DockerSocket = GetEnvString("DOCKER_SOCKET", GetEnvString("DOCKER_HOST", "/var/run/docker.sock"))
ListenAddr = GetEnvString("LISTEN_ADDR", GetEnvString("DOCKER_SOCKET_ADDR", "")) // default to disabled
DockerPost = GetEnvBool("POST", false)
DockerRestarts = GetEnvBool("ALLOW_RESTARTS", false)
DockerStart = GetEnvBool("ALLOW_START", false)
DockerStop = GetEnvBool("ALLOW_STOP", false)
DockerAuth = GetEnvBool("AUTH", false)
DockerBuild = GetEnvBool("BUILD", false)
DockerCommit = GetEnvBool("COMMIT", false)
DockerConfigs = GetEnvBool("CONFIGS", false)
DockerContainers = GetEnvBool("CONTAINERS", false)
DockerDistribution = GetEnvBool("DISTRIBUTION", false)
DockerEvents = GetEnvBool("EVENTS", true)
DockerExec = GetEnvBool("EXEC", false)
DockerGrpc = GetEnvBool("GRPC", false)
DockerImages = GetEnvBool("IMAGES", false)
DockerInfo = GetEnvBool("INFO", false)
DockerNetworks = GetEnvBool("NETWORKS", false)
DockerNodes = GetEnvBool("NODES", false)
DockerPing = GetEnvBool("PING", true)
DockerPlugins = GetEnvBool("PLUGINS", false)
DockerSecrets = GetEnvBool("SECRETS", false)
DockerServices = GetEnvBool("SERVICES", false)
DockerSession = GetEnvBool("SESSION", false)
DockerSwarm = GetEnvBool("SWARM", false)
DockerSystem = GetEnvBool("SYSTEM", false)
DockerTasks = GetEnvBool("TASKS", false)
DockerVersion = GetEnvBool("VERSION", true)
DockerVolumes = GetEnvBool("VOLUMES", false)
}

179
socket-proxy/pkg/handler.go Normal file
View File

@@ -0,0 +1,179 @@
package socketproxy
import (
"context"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/gorilla/mux"
"net/url"
)
var dialer = &net.Dialer{KeepAlive: 1 * time.Second}
func dialDockerSocket(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", DockerSocket)
}
var DockerSocketHandler = dockerSocketHandler
func dockerSocketHandler() http.HandlerFunc {
rp := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "api.moby.localhost",
})
rp.Transport = &http.Transport{
DialContext: dialDockerSocket,
}
return rp.ServeHTTP
}
func endpointNotAllowed(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "Endpoint not allowed", http.StatusForbidden)
}
// ref: https://github.com/Tecnativa/docker-socket-proxy/blob/master/haproxy.cfg
func NewHandler() http.Handler {
r := mux.NewRouter()
socketHandler := DockerSocketHandler()
const apiVersionPrefix = `/{version:(?:v[\d\.]+)?}`
const containerPath = "/containers/{id:[a-zA-Z0-9_.-]+}"
allowedPaths := []string{}
deniedPaths := []string{}
if DockerContainers {
allowedPaths = append(allowedPaths, "/containers")
if !DockerRestarts {
deniedPaths = append(deniedPaths, containerPath+"/stop")
deniedPaths = append(deniedPaths, containerPath+"/restart")
deniedPaths = append(deniedPaths, containerPath+"/kill")
}
if !DockerStart {
deniedPaths = append(deniedPaths, containerPath+"/start")
}
if !DockerStop && DockerRestarts {
deniedPaths = append(deniedPaths, containerPath+"/stop")
}
}
if DockerAuth {
allowedPaths = append(allowedPaths, "/auth")
}
if DockerBuild {
allowedPaths = append(allowedPaths, "/build")
}
if DockerCommit {
allowedPaths = append(allowedPaths, "/commit")
}
if DockerConfigs {
allowedPaths = append(allowedPaths, "/configs")
}
if DockerDistribution {
allowedPaths = append(allowedPaths, "/distribution")
}
if DockerEvents {
allowedPaths = append(allowedPaths, "/events")
}
if DockerExec {
allowedPaths = append(allowedPaths, "/exec")
}
if DockerGrpc {
allowedPaths = append(allowedPaths, "/grpc")
}
if DockerImages {
allowedPaths = append(allowedPaths, "/images")
}
if DockerInfo {
allowedPaths = append(allowedPaths, "/info")
}
if DockerNetworks {
allowedPaths = append(allowedPaths, "/networks")
}
if DockerNodes {
allowedPaths = append(allowedPaths, "/nodes")
}
if DockerPing {
allowedPaths = append(allowedPaths, "/_ping")
}
if DockerPlugins {
allowedPaths = append(allowedPaths, "/plugins")
}
if DockerSecrets {
allowedPaths = append(allowedPaths, "/secrets")
}
if DockerServices {
allowedPaths = append(allowedPaths, "/services")
}
if DockerSession {
allowedPaths = append(allowedPaths, "/session")
}
if DockerSwarm {
allowedPaths = append(allowedPaths, "/swarm")
}
if DockerSystem {
allowedPaths = append(allowedPaths, "/system")
}
if DockerTasks {
allowedPaths = append(allowedPaths, "/tasks")
}
if DockerVersion {
allowedPaths = append(allowedPaths, "/version")
}
if DockerVolumes {
allowedPaths = append(allowedPaths, "/volumes")
}
// Helper to determine if a path should be treated as a prefix
isPrefixPath := func(path string) bool {
return strings.Count(path, "/") == 1
}
// 1. Register Denied Paths (specific)
for _, path := range deniedPaths {
// Handle with version prefix
r.HandleFunc(apiVersionPrefix+path, endpointNotAllowed)
// Handle without version prefix
r.HandleFunc(path, endpointNotAllowed)
}
// 2. Register Allowed Paths
for _, p := range allowedPaths {
fullPathWithVersion := apiVersionPrefix + p
if isPrefixPath(p) {
r.PathPrefix(fullPathWithVersion).Handler(socketHandler)
r.PathPrefix(p).Handler(socketHandler)
} else {
r.HandleFunc(fullPathWithVersion, socketHandler)
r.HandleFunc(p, socketHandler)
}
}
// 3. Add fallback for any other routes
r.PathPrefix("/").HandlerFunc(endpointNotAllowed)
// HTTP method filtering
if !DockerPost {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.ServeHTTP(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodPost, http.MethodGet:
r.ServeHTTP(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
}

View File

@@ -0,0 +1,427 @@
package socketproxy_test
import (
"net/http"
"net/http/httptest"
"os"
"testing"
. "github.com/yusing/go-proxy/socketproxy/pkg"
)
func mockDockerSocketHandler() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("mock docker response"))
})
}
func TestMain(m *testing.M) {
DockerSocketHandler = mockDockerSocketHandler
os.Exit(m.Run())
}
func TestNewDockerHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
envSetup func()
wantStatusCode int
}{
{
name: "GET _ping allowed by default",
method: http.MethodGet,
path: "/_ping",
envSetup: func() {},
wantStatusCode: http.StatusOK,
},
{
name: "GET version allowed by default",
method: http.MethodGet,
path: "/version",
envSetup: func() {},
wantStatusCode: http.StatusOK,
},
{
name: "GET containers allowed when enabled",
method: http.MethodGet,
path: "/containers",
envSetup: func() {
DockerContainers = true
},
wantStatusCode: http.StatusOK,
},
{
name: "GET containers not allowed when disabled",
method: http.MethodGet,
path: "/containers",
envSetup: func() {
DockerContainers = false
},
wantStatusCode: http.StatusForbidden,
},
{
name: "POST not allowed by default",
method: http.MethodPost,
path: "/_ping",
envSetup: func() {
DockerPost = false
},
wantStatusCode: http.StatusMethodNotAllowed,
},
{
name: "POST allowed when enabled",
method: http.MethodPost,
path: "/_ping",
envSetup: func() {
DockerPost = true
DockerPing = true
},
wantStatusCode: http.StatusOK,
},
{
name: "Container restart not allowed when disabled",
method: http.MethodPost,
path: "/containers/test-container/restart",
envSetup: func() {
DockerPost = true
DockerContainers = true
DockerRestarts = false
},
wantStatusCode: http.StatusForbidden,
},
{
name: "Container restart allowed when enabled",
method: http.MethodPost,
path: "/containers/test-container/restart",
envSetup: func() {
DockerPost = true
DockerContainers = true
DockerRestarts = true
},
wantStatusCode: http.StatusOK,
},
{
name: "Container start not allowed when disabled",
method: http.MethodPost,
path: "/containers/test-container/start",
envSetup: func() {
DockerPost = true
DockerContainers = true
DockerStart = false
},
wantStatusCode: http.StatusForbidden,
},
{
name: "Container start allowed when enabled",
method: http.MethodPost,
path: "/containers/test-container/start",
envSetup: func() {
DockerPost = true
DockerContainers = true
DockerStart = true
},
wantStatusCode: http.StatusOK,
},
{
name: "Container stop not allowed when disabled",
method: http.MethodPost,
path: "/containers/test-container/stop",
envSetup: func() {
DockerPost = true
DockerContainers = true
DockerStop = false
},
wantStatusCode: http.StatusForbidden,
},
{
name: "Container stop allowed when enabled",
method: http.MethodPost,
path: "/containers/test-container/stop",
envSetup: func() {
DockerPost = true
DockerContainers = true
DockerStop = true
},
wantStatusCode: http.StatusOK,
},
{
name: "Versioned API paths work",
method: http.MethodGet,
path: "/v1.41/version",
envSetup: func() {
DockerVersion = true
},
wantStatusCode: http.StatusOK,
},
{
name: "PUT method not allowed",
method: http.MethodPut,
path: "/version",
envSetup: func() {
DockerVersion = true
},
wantStatusCode: http.StatusMethodNotAllowed,
},
{
name: "DELETE method not allowed",
method: http.MethodDelete,
path: "/version",
envSetup: func() {
DockerVersion = true
},
wantStatusCode: http.StatusMethodNotAllowed,
},
}
// Save original env values to restore after tests
originalContainers := DockerContainers
originalRestarts := DockerRestarts
originalStart := DockerStart
originalStop := DockerStop
originalPost := DockerPost
originalPing := DockerPing
originalVersion := DockerVersion
defer func() {
// Restore original values
DockerContainers = originalContainers
DockerRestarts = originalRestarts
DockerStart = originalStart
DockerStop = originalStop
DockerPost = originalPost
DockerPing = originalPing
DockerVersion = originalVersion
}()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup environment for this test
tt.envSetup()
// Create test handler that will record the response for verification
dockerHandler := NewHandler()
// Test server to capture the response
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest(tt.method, tt.path, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Process the request
dockerHandler.ServeHTTP(recorder, req)
// Check response
if recorder.Code != tt.wantStatusCode {
t.Errorf("Expected status code %d, got %d",
tt.wantStatusCode, recorder.Code)
}
})
}
}
// This test focuses on checking that all the path prefix handling works correctly
func TestNewDockerHandler_PathHandling(t *testing.T) {
tests := []struct {
name string
path string
envVarName string
envVarValue bool
method string
wantAllowed bool
}{
{"Container path", "/containers/json", "DockerContainers", true, http.MethodGet, true},
{"Container path disabled", "/containers/json", "DockerContainers", false, http.MethodGet, false},
{"Auth path", "/auth", "DockerAuth", true, http.MethodGet, true},
{"Auth path disabled", "/auth", "DockerAuth", false, http.MethodGet, false},
{"Build path", "/build", "DockerBuild", true, http.MethodGet, true},
{"Build path disabled", "/build", "DockerBuild", false, http.MethodGet, false},
{"Commit path", "/commit", "DockerCommit", true, http.MethodGet, true},
{"Commit path disabled", "/commit", "DockerCommit", false, http.MethodGet, false},
{"Configs path", "/configs", "DockerConfigs", true, http.MethodGet, true},
{"Configs path disabled", "/configs", "DockerConfigs", false, http.MethodGet, false},
{"Distribution path", "/distribution", "DockerDistribution", true, http.MethodGet, true},
{"Distribution path disabled", "/distribution", "DockerDistribution", false, http.MethodGet, false},
{"Events path", "/events", "DockerEvents", true, http.MethodGet, true},
{"Events path disabled", "/events", "DockerEvents", false, http.MethodGet, false},
{"Exec path", "/exec", "DockerExec", true, http.MethodGet, true},
{"Exec path disabled", "/exec", "DockerExec", false, http.MethodGet, false},
{"Grpc path", "/grpc", "DockerGrpc", true, http.MethodGet, true},
{"Grpc path disabled", "/grpc", "DockerGrpc", false, http.MethodGet, false},
{"Images path", "/images", "DockerImages", true, http.MethodGet, true},
{"Images path disabled", "/images", "DockerImages", false, http.MethodGet, false},
{"Info path", "/info", "DockerInfo", true, http.MethodGet, true},
{"Info path disabled", "/info", "DockerInfo", false, http.MethodGet, false},
{"Networks path", "/networks", "DockerNetworks", true, http.MethodGet, true},
{"Networks path disabled", "/networks", "DockerNetworks", false, http.MethodGet, false},
{"Nodes path", "/nodes", "DockerNodes", true, http.MethodGet, true},
{"Nodes path disabled", "/nodes", "DockerNodes", false, http.MethodGet, false},
{"Plugins path", "/plugins", "DockerPlugins", true, http.MethodGet, true},
{"Plugins path disabled", "/plugins", "DockerPlugins", false, http.MethodGet, false},
{"Secrets path", "/secrets", "DockerSecrets", true, http.MethodGet, true},
{"Secrets path disabled", "/secrets", "DockerSecrets", false, http.MethodGet, false},
{"Services path", "/services", "DockerServices", true, http.MethodGet, true},
{"Services path disabled", "/services", "DockerServices", false, http.MethodGet, false},
{"Session path", "/session", "DockerSession", true, http.MethodGet, true},
{"Session path disabled", "/session", "DockerSession", false, http.MethodGet, false},
{"Swarm path", "/swarm", "DockerSwarm", true, http.MethodGet, true},
{"Swarm path disabled", "/swarm", "DockerSwarm", false, http.MethodGet, false},
{"System path", "/system", "DockerSystem", true, http.MethodGet, true},
{"System path disabled", "/system", "DockerSystem", false, http.MethodGet, false},
{"Tasks path", "/tasks", "DockerTasks", true, http.MethodGet, true},
{"Tasks path disabled", "/tasks", "DockerTasks", false, http.MethodGet, false},
{"Volumes path", "/volumes", "DockerVolumes", true, http.MethodGet, true},
{"Volumes path disabled", "/volumes", "DockerVolumes", false, http.MethodGet, false},
// Test versioned paths
{"Versioned auth", "/v1.41/auth", "DockerAuth", true, http.MethodGet, true},
{"Versioned auth disabled", "/v1.41/auth", "DockerAuth", false, http.MethodGet, false},
}
defer func() {
// Restore original env values
Load()
}()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset all Docker* env vars to false for this test
Load()
// Enable POST for all these tests
DockerPost = true
// Set the specific env var for this test
switch tt.envVarName {
case "DockerContainers":
DockerContainers = tt.envVarValue
case "DockerRestarts":
DockerRestarts = tt.envVarValue
case "DockerStart":
DockerStart = tt.envVarValue
case "DockerStop":
DockerStop = tt.envVarValue
case "DockerAuth":
DockerAuth = tt.envVarValue
case "DockerBuild":
DockerBuild = tt.envVarValue
case "DockerCommit":
DockerCommit = tt.envVarValue
case "DockerConfigs":
DockerConfigs = tt.envVarValue
case "DockerDistribution":
DockerDistribution = tt.envVarValue
case "DockerEvents":
DockerEvents = tt.envVarValue
case "DockerExec":
DockerExec = tt.envVarValue
case "DockerGrpc":
DockerGrpc = tt.envVarValue
case "DockerImages":
DockerImages = tt.envVarValue
case "DockerInfo":
DockerInfo = tt.envVarValue
case "DockerNetworks":
DockerNetworks = tt.envVarValue
case "DockerNodes":
DockerNodes = tt.envVarValue
case "DockerPlugins":
DockerPlugins = tt.envVarValue
case "DockerSecrets":
DockerSecrets = tt.envVarValue
case "DockerServices":
DockerServices = tt.envVarValue
case "DockerSession":
DockerSession = tt.envVarValue
case "DockerSwarm":
DockerSwarm = tt.envVarValue
case "DockerSystem":
DockerSystem = tt.envVarValue
case "DockerTasks":
DockerTasks = tt.envVarValue
case "DockerVolumes":
DockerVolumes = tt.envVarValue
default:
t.Fatalf("Unknown env var: %s", tt.envVarName)
}
// Create test handler
dockerHandler := NewHandler()
// Test server to capture the response
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest(tt.method, tt.path, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Process the request
dockerHandler.ServeHTTP(recorder, req)
// Check if the status indicates if the path is allowed or not
isAllowed := recorder.Code != http.StatusForbidden
if isAllowed != tt.wantAllowed {
t.Errorf("Path %s with env %s=%v: got allowed=%v, want allowed=%v (status=%d)",
tt.path, tt.envVarName, tt.envVarValue, isAllowed, tt.wantAllowed, recorder.Code)
}
})
}
}
// TestNewDockerHandlerWithMockDocker mocks the Docker API to test the actual HTTP handler behavior
// This is a more comprehensive test that verifies the full request/response chain
func TestNewDockerHandlerWithMockDocker(t *testing.T) {
// Set up environment
DockerContainers = true
DockerPost = true
// Create the handler
handler := NewHandler()
// Test a valid request
req, _ := http.NewRequest(http.MethodGet, "/containers", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status OK for /containers, got %d", recorder.Code)
}
// Test a disallowed path
DockerContainers = false
handler = NewHandler() // recreate with new env
req, _ = http.NewRequest(http.MethodGet, "/containers", nil)
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Errorf("Expected status Forbidden for /containers when disabled, got %d", recorder.Code)
}
}