diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..837e07b --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main, initial] + pull_request: + branches: [main, initial] + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4.2.2 + + - uses: actions/setup-go@v5.2.0 + with: + go-version-file: go.mod + + - name: Run tests + run: go test -v -race ./... + + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4.2.2 + + - uses: actions/setup-go@v5.2.0 + with: + go-version-file: go.mod + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6.2.0 + with: + version: v2.8.0 + + fmt: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4.2.2 + + - uses: actions/setup-go@v5.2.0 + with: + go-version-file: go.mod + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 + + - name: Check formatting + run: test -z "$(golangci-lint fmt --diff)" || (echo "Run 'golangci-lint fmt' to fix formatting" && exit 1) + + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4.2.2 + + - uses: actions/setup-go@v5.2.0 + with: + go-version-file: go.mod + + - name: Build + run: go build -o docker-api-auth . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..65d269a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release binaries + +permissions: + contents: write + +on: + release: + types: [created] + +jobs: + releases-amd64: + name: Release Go Binary AMD64 + runs-on: ubuntu-latest + steps: + - name: Git checkout + uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Build + run: CGO_ENABLED=0 go build -o docker-api-auth-linux-amd64 . + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: docker-api-auth-linux-amd64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe0056 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.* +docker-api-auth diff --git a/example/acl.yml b/example/acl.yml new file mode 100644 index 0000000..8a76e14 --- /dev/null +++ b/example/acl.yml @@ -0,0 +1,7 @@ +--- +deployers: + + - username: example + password_hash: $2a$12$RvXRFK7mv.Q4M0ltpwLgL.tqBU2KkFV5i58q7AaDhbPOu9.DU.4qK + service_prefix: example_ + network_attachments: [example-shared] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b438118 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/cego/caddy-docker-api-auth + +go 1.25.0 + +require ( + github.com/moby/moby/client v0.1.0-beta.0 + github.com/samber/lo v1.51.0 + github.com/stretchr/testify v1.10.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.42.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.52.0-beta.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..decefad --- /dev/null +++ b/go.sum @@ -0,0 +1,82 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.52.0-beta.1 h1:r5U4U72E7xSHh4zX72ndY1mA/FOGiAPiGiz2a8rBW+w= +github.com/moby/moby/api v1.52.0-beta.1/go.mod h1:8sBV0soUREiudtow4vqJGOxa4GyHI5vLQmvgKdHq5Ok= +github.com/moby/moby/client v0.1.0-beta.0 h1:eXzrwi0YkzLvezOBKHafvAWNmH1B9HFh4n13yb2QgFE= +github.com/moby/moby/client v0.1.0-beta.0/go.mod h1:irAv8jRi4yKKBeND96Y+3AM9ers+KaJYk9Vmcm7loxs= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/acl.go b/internal/acl.go new file mode 100644 index 0000000..2a4a97b --- /dev/null +++ b/internal/acl.go @@ -0,0 +1,74 @@ +package internal + +import ( + "os" + "regexp" + + "github.com/samber/lo" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +type ACLDeployer struct { + servicePrefixRegexp *regexp.Regexp + + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` + ServicePrefix string `yaml:"service_prefix"` + NetworkAttachments []string `yaml:"network_attachments"` +} + +type ACL struct { + Deployers []*ACLDeployer `json:"deployers"` +} + +func NewACL(aclFilePath string) *ACL { + acl := &ACL{} + + out := MustReturn(os.ReadFile(aclFilePath)) + MustNotFail(yaml.Unmarshal(out, &acl)) + + // Compile and assign regexp's + for _, deployer := range acl.Deployers { + deployer.servicePrefixRegexp = MustReturn(regexp.Compile("^" + deployer.ServicePrefix)) + } + + return acl +} + +func (a *ACL) VerifyUser(username string, password string) bool { + matches := lo.Filter(a.Deployers, func(item *ACLDeployer, _ int) bool { + if item.Username != username { + return false + } + + err := bcrypt.CompareHashAndPassword([]byte(item.PasswordHash), []byte(password)) + return err == nil + }) + return len(matches) > 0 +} + +func (a *ACL) MatchNetworkAttachment(username string, networkName string) bool { + matches := lo.Filter(a.Deployers, func(item *ACLDeployer, _ int) bool { + if item.Username != username { + return false + } + + networkMatches := lo.Filter(item.NetworkAttachments, func(item string, _ int) bool { + return item == networkName + }) + return len(networkMatches) > 0 + }) + return len(matches) > 0 +} + +func (a *ACL) MatchServicePrefix(username string, serviceName string) bool { + matches := lo.Filter(a.Deployers, func(item *ACLDeployer, _ int) bool { + if item.Username != username { + return false + } + + return item.servicePrefixRegexp.MatchString(serviceName) + }) + return len(matches) > 0 +} diff --git a/internal/acl_test.go b/internal/acl_test.go new file mode 100644 index 0000000..0382237 --- /dev/null +++ b/internal/acl_test.go @@ -0,0 +1,44 @@ +package internal_test + +import ( + "testing" + + "github.com/cego/caddy-docker-api-auth/internal" + "github.com/stretchr/testify/assert" +) + +func TestACL(t *testing.T) { + acl := internal.NewACL("../example/acl.yml") + + validExampleUsername := "example" + validExamplePassword := "see im a proper password" + validExamplePrefix := "example_" + + t.Run("it passes VerifyUser", func(t *testing.T) { + found := acl.VerifyUser(validExampleUsername, validExamplePassword) + assert.True(t, found) + }) + + t.Run("it fails VerifyUser with invalid password", func(t *testing.T) { + found := acl.VerifyUser(validExampleUsername, "im not a proper password") + assert.False(t, found) + }) + + t.Run("it fails VerifyUser with invalid username", func(t *testing.T) { + found := acl.VerifyUser("notavalidusername", validExamplePassword) + assert.False(t, found) + }) + + t.Run("it matches by username and service name", func(t *testing.T) { + found := acl.MatchServicePrefix(validExampleUsername, validExamplePrefix) + assert.True(t, found) + }) + t.Run("it matches by username, but not service name", func(t *testing.T) { + found := acl.MatchServicePrefix(validExampleUsername, "notexample_") + assert.False(t, found) + }) + t.Run("it matches by service name, but not username", func(t *testing.T) { + found := acl.MatchServicePrefix("invalid-username", validExamplePrefix) + assert.False(t, found) + }) +} diff --git a/internal/guards/services_edit.go b/internal/guards/services_edit.go new file mode 100644 index 0000000..8fcc06f --- /dev/null +++ b/internal/guards/services_edit.go @@ -0,0 +1,102 @@ +package guards + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "regexp" + + "github.com/cego/caddy-docker-api-auth/internal" + "github.com/moby/moby/client" + "go.uber.org/zap" +) + +type ServicesEdit struct { + ctx context.Context + logger *zap.Logger + acl *internal.ACL + dockerApi *client.Client + regexps []*regexp.Regexp +} + +type ServiceDefTaskTemplateNetwork struct { + Target string +} + +type ServiceDefTaskTemplate struct { + Networks []*ServiceDefTaskTemplateNetwork +} + +type ServiceDef struct { + Name string `json:"Name"` + TaskTemplate *ServiceDefTaskTemplate +} + +func NewServicesEdit(ctx context.Context, logger *zap.Logger, acl *internal.ACL, dockerApi *client.Client) *ServicesEdit { + regexps := []*regexp.Regexp{ + regexp.MustCompile("/.*?/services/.*?/update"), + regexp.MustCompile("/.*?/services/create"), + } + return &ServicesEdit{ctx, logger, acl, dockerApi, regexps} +} + +func (n *ServicesEdit) Matches(path string) bool { + for _, r := range n.regexps { + if r.MatchString(path) { + return true + } + } + return false +} + +func (n *ServicesEdit) findNetworkName(networkID string) string { + inspect, err := n.dockerApi.NetworkInspect(n.ctx, networkID, client.NetworkInspectOptions{}) + if err != nil { + n.logger.Warn("Could not find network name for '" + networkID + "'") + return "" + } + return inspect.Name +} + +func (n *ServicesEdit) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Handler, username string) { + logger := n.logger + + buf, err := io.ReadAll(r.Body) + if err != nil { + logger.Error("Failed to read request body", zap.Error(err)) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + rdr1 := io.NopCloser(bytes.NewBuffer(buf)) + rdr2 := io.NopCloser(bytes.NewBuffer(buf)) + r.Body = rdr2 + + service := new(ServiceDef) + err = json.NewDecoder(rdr1).Decode(&service) + if err != nil { + logger.Error("Failed to parse json", zap.Error(err)) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if !n.acl.MatchServicePrefix(username, service.Name) { + msg := "'" + username + "' is not permitted to update or create '" + service.Name + "'" + logger.Error(msg) + http.Error(w, msg, http.StatusForbidden) + return + } + + for _, network := range service.TaskTemplate.Networks { + networkName := n.findNetworkName(network.Target) + if !n.acl.MatchNetworkAttachment(username, networkName) { + msg := "'" + username + "' is not permitted to attach to network '" + network.Target + "'" + logger.Error(msg) + http.Error(w, msg, http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) +} diff --git a/internal/guards/services_edit_test.go b/internal/guards/services_edit_test.go new file mode 100644 index 0000000..2605dc5 --- /dev/null +++ b/internal/guards/services_edit_test.go @@ -0,0 +1,115 @@ +package guards_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cego/caddy-docker-api-auth/internal" + "github.com/cego/caddy-docker-api-auth/internal/guards" + "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func newTestGuard(t *testing.T) *guards.ServicesEdit { + t.Helper() + acl := internal.NewACL("../../example/acl.yml") + dockerApi, err := client.NewClientWithOpts(client.WithAPIVersionNegotiation()) + if err != nil { + t.Fatal(err) + } + return guards.NewServicesEdit(context.Background(), zap.NewNop(), acl, dockerApi) +} + +func TestMatchesServiceCreate(t *testing.T) { + guard := newTestGuard(t) + + assert.True(t, guard.Matches("/v1.41/services/create")) + assert.True(t, guard.Matches("/v1.45/services/create")) +} + +func TestMatchesServiceUpdate(t *testing.T) { + guard := newTestGuard(t) + + assert.True(t, guard.Matches("/v1.41/services/abc123/update")) + assert.True(t, guard.Matches("/v1.45/services/my-service/update")) +} + +func TestDoesNotMatchOtherPaths(t *testing.T) { + guard := newTestGuard(t) + + assert.False(t, guard.Matches("/v1.41/containers/json")) + assert.False(t, guard.Matches("/_ping")) + assert.False(t, guard.Matches("/v1.41/services/abc123")) + assert.False(t, guard.Matches("/v1.41/services")) + assert.False(t, guard.Matches("/version")) +} + +func TestServeHTTPForbidsWrongPrefix(t *testing.T) { + guard := newTestGuard(t) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + body := `{"Name":"forbidden_service","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader(body)) + w := httptest.NewRecorder() + + guard.ServeHTTP(w, req, next, "example") + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "is not permitted to update or create") +} + +func TestServeHTTPAllowsCorrectPrefix(t *testing.T) { + guard := newTestGuard(t) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + body := `{"Name":"example_myservice","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader(body)) + w := httptest.NewRecorder() + + guard.ServeHTTP(w, req, next, "example") + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestServeHTTPRejectsInvalidJSON(t *testing.T) { + guard := newTestGuard(t) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader("not json")) + w := httptest.NewRecorder() + + guard.ServeHTTP(w, req, next, "example") + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestServeHTTPPreservesBodyForNext(t *testing.T) { + guard := newTestGuard(t) + + var capturedBody string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, 1024) + n, _ := r.Body.Read(b) + capturedBody = string(b[:n]) + w.WriteHeader(http.StatusOK) + }) + + body := `{"Name":"example_myservice","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader(body)) + w := httptest.NewRecorder() + + guard.ServeHTTP(w, req, next, "example") + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, body, capturedBody) +} diff --git a/internal/must.go b/internal/must.go new file mode 100644 index 0000000..5325fd7 --- /dev/null +++ b/internal/must.go @@ -0,0 +1,21 @@ +package internal + +import ( + "fmt" + "os" +) + +func MustReturn[T any](obj T, err error) T { + if err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } + return obj +} + +func MustNotFail(err error) { + if err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..66d57c6 --- /dev/null +++ b/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "net/http/httputil" + "os" + "os/signal" + "syscall" + + "github.com/cego/caddy-docker-api-auth/internal" + "github.com/cego/caddy-docker-api-auth/internal/guards" + "github.com/moby/moby/client" + "go.uber.org/zap" +) + +func main() { + aclFile := flag.String("acl", "", "path to ACL YAML file") + listen := flag.String("listen", ":3004", "listen address") + dockerSocket := flag.String("docker-socket", "/var/run/docker.sock", "path to Docker socket") + flag.Parse() + + if *aclFile == "" { + fmt.Fprintln(os.Stderr, "error: --acl flag is required") + flag.Usage() + os.Exit(1) + } + + logger, _ := zap.NewProduction() + defer func() { _ = logger.Sync() }() + + acl := internal.NewACL(*aclFile) + + dockerApi, err := client.NewClientWithOpts( + client.WithHost("unix://"+*dockerSocket), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + logger.Fatal("failed to create docker client", zap.Error(err)) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + servicesEditGuard := guards.NewServicesEdit(ctx, logger, acl, dockerApi) + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = "docker" + }, + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", *dockerSocket) + }, + }, + } + + handler := authMiddleware(logger, acl, servicesEditGuard, proxy) + + server := &http.Server{ + Addr: *listen, + Handler: handler, + } + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + logger.Info("shutting down") + _ = server.Close() + }() + + logger.Info("starting server", zap.String("listen", *listen), zap.String("docker-socket", *dockerSocket)) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal("server error", zap.Error(err)) + } +} + +func authMiddleware(logger *zap.Logger, acl *internal.ACL, servicesEditGuard *guards.ServicesEdit, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username := r.Header.Get("X-Docker-Auth-Username") + password := r.Header.Get("X-Docker-Auth-Password") + if username == "" || password == "" { + msg := "X-Docker-Auth-Username or X-Docker-Auth-Password is empty or unspecified" + logger.Error(msg) + http.Error(w, msg, http.StatusUnauthorized) + return + } + + if !acl.VerifyUser(username, password) { + msg := "Could not verify username/password for username '" + username + "'" + logger.Error(msg) + http.Error(w, msg, http.StatusUnauthorized) + return + } + + if servicesEditGuard.Matches(r.URL.Path) { + servicesEditGuard.ServeHTTP(w, r, next, username) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..af9e655 --- /dev/null +++ b/main_test.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cego/caddy-docker-api-auth/internal" + "github.com/cego/caddy-docker-api-auth/internal/guards" + "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +var ( + testACL = internal.NewACL("example/acl.yml") + testLogger = zap.NewNop() +) + +func newTestHandler(t *testing.T) http.Handler { + t.Helper() + dockerApi, err := client.NewClientWithOpts(client.WithAPIVersionNegotiation()) + if err != nil { + t.Fatal(err) + } + guard := guards.NewServicesEdit(context.Background(), testLogger, testACL, dockerApi) + backend := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + return authMiddleware(testLogger, testACL, guard, backend) +} + +func TestAuthRejectsNoHeaders(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/_ping", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "X-Docker-Auth-Username or X-Docker-Auth-Password is empty or unspecified") +} + +func TestAuthRejectsMissingPassword(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/_ping", nil) + req.Header.Set("X-Docker-Auth-Username", "example") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthRejectsMissingUsername(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/_ping", nil) + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthRejectsWrongPassword(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/_ping", nil) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "wrongpassword") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Could not verify username/password") +} + +func TestAuthRejectsWrongUsername(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/_ping", nil) + req.Header.Set("X-Docker-Auth-Username", "nobody") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthPassesValidCredentials(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/_ping", nil) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "OK", w.Body.String()) +} + +func TestAuthPassesNonGuardedPath(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("GET", "/containers/json", nil) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "OK", w.Body.String()) +} + +func TestServicesCreateForbiddenPrefix(t *testing.T) { + handler := newTestHandler(t) + body := `{"Name":"forbidden_service","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader(body)) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "is not permitted to update or create") +} + +func TestServicesCreateAllowedPrefix(t *testing.T) { + handler := newTestHandler(t) + body := `{"Name":"example_myservice","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader(body)) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestServicesUpdateForbiddenPrefix(t *testing.T) { + handler := newTestHandler(t) + body := `{"Name":"other_service","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/abc123/update", strings.NewReader(body)) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestServicesUpdateAllowedPrefix(t *testing.T) { + handler := newTestHandler(t) + body := `{"Name":"example_myservice","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/abc123/update", strings.NewReader(body)) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestServicesCreateInvalidJSON(t *testing.T) { + handler := newTestHandler(t) + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader("not json")) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestServicesCreateBodyPassedThrough(t *testing.T) { + dockerApi, _ := client.NewClientWithOpts(client.WithAPIVersionNegotiation()) + guard := guards.NewServicesEdit(context.Background(), testLogger, testACL, dockerApi) + + var capturedBody string + backend := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + capturedBody = string(b) + w.WriteHeader(http.StatusOK) + }) + handler := authMiddleware(testLogger, testACL, guard, backend) + + body := `{"Name":"example_myservice","TaskTemplate":{"Networks":[]}}` + req := httptest.NewRequest("POST", "/v1.41/services/create", strings.NewReader(body)) + req.Header.Set("X-Docker-Auth-Username", "example") + req.Header.Set("X-Docker-Auth-Password", "see im a proper password") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, body, capturedBody) +} + +func TestProxyDirector(t *testing.T) { + req := httptest.NewRequest("GET", "http://localhost:3004/v1.41/containers/json", nil) + + director := func(r *http.Request) { + r.URL.Scheme = "http" + r.URL.Host = "docker" + } + director(req) + + assert.Equal(t, "http", req.URL.Scheme) + assert.Equal(t, "docker", req.URL.Host) + assert.Equal(t, "/v1.41/containers/json", req.URL.Path) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..dd8c735 --- /dev/null +++ b/readme.md @@ -0,0 +1,27 @@ +# docker-api-auth + +Standalone reverse proxy for the Docker API with ACL-based authentication. + +Uses Go's `httputil.ReverseProxy` to proxy requests to the Docker socket, with proper support for connection hijacking (`docker run`, `docker exec`). + +## Usage + +```bash +docker-api-auth --acl acl.yml --listen :3004 --docker-socket /var/run/docker.sock +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--acl` | (required) | Path to ACL YAML file | +| `--listen` | `:3004` | Listen address | +| `--docker-socket` | `/var/run/docker.sock` | Path to Docker socket | + +## ACL configuration + +See [example/acl.yml](example/acl.yml). + +## Authentication + +Requests must include `X-Docker-Auth-Username` and `X-Docker-Auth-Password` headers. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7750669 --- /dev/null +++ b/renovate.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + ":disableDependencyDashboard", + ":maintainLockFilesWeekly" + ], + "recreateWhen": "always" +} \ No newline at end of file