Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
eb5cc7e
implement per-container allowlists
amanda-wee Oct 4, 2025
5b313a3
Add logging for allowlists
amanda-wee Oct 4, 2025
74330cc
Fix Docker label prefix typo
amanda-wee Oct 4, 2025
bb56d83
Update allowlists by subscribing to Docker events stream
amanda-wee Oct 4, 2025
7d5261e
Move non-default allowlist setup to after logger setup
amanda-wee Oct 5, 2025
4b8fe0e
Refactor proxy container networks to socket proxy networks in allowli…
amanda-wee Oct 5, 2025
b5c75c6
Refactor and comment config functions
amanda-wee Oct 5, 2025
7840bb3
Update readme for per-container allowlists
amanda-wee Oct 5, 2025
0f7ffd6
Add more documentation for per-container allowlists
amanda-wee Oct 5, 2025
d1d7b9f
Only setup non-default allowlists for TCP proxy and when socket proxy…
amanda-wee Oct 6, 2025
ed03073
Handle mutex more carefully
amanda-wee Oct 6, 2025
036b964
Handle Docker restart events
amanda-wee Oct 6, 2025
ceab24a
Retrieve container restart events from Docker API
amanda-wee Oct 6, 2025
34536a1
Loop over Docker event stream
amanda-wee Oct 6, 2025
17356cc
Improve container event handling
amanda-wee Oct 6, 2025
eaec378
Improve adding of allowlist
amanda-wee Oct 6, 2025
1eefa2d
Refactor HTTP request handling for allowlists
amanda-wee Oct 7, 2025
dbbed19
Do not export AllowListRegistry networks field
amanda-wee Oct 8, 2025
323e5c5
Eliminate unnecessary method name extraction
amanda-wee Oct 9, 2025
24ea509
Store AllowList values directly as they should be immutable once set
amanda-wee Oct 9, 2025
4db7757
Cancel context for Docker event stream
amanda-wee Oct 10, 2025
8ce7d45
Add retry logic for socket proxy container summary
amanda-wee Oct 10, 2025
cd5c813
Print container short IDs to identify them
amanda-wee Oct 20, 2025
71a8366
Print allowed bind mounts for per-container allowlists
amanda-wee Oct 20, 2025
a7c7a74
Integrate Docker SDK to remove dependency
amanda-wee Oct 28, 2025
af71252
Update README.md
wollomatic Nov 23, 2025
602981a
Update internal/docker/api/types/events/events.go
wollomatic Nov 23, 2025
17e00e2
Update internal/config/config.go
amanda-wee Nov 24, 2025
bb61be7
Update README.md
amanda-wee Nov 24, 2025
599e190
Merge pull request #69 from amanda-wee/per-container-allowlists
wollomatic Nov 29, 2025
1a84d37
Update internal/docker/client/request.go
wollomatic Dec 4, 2025
4315e12
update README
wollomatic Dec 4, 2025
d615adc
Update internal/docker/client/request.go
wollomatic Dec 4, 2025
2aa8e57
Merge remote-tracking branch 'origin/28-fr-support-granular-api-permi…
wollomatic Dec 4, 2025
db5ae2c
Go 1.25.4
wollomatic Dec 4, 2025
59f1ccc
Apply suggestions from code review
wollomatic Dec 4, 2025
09bb747
Update README.md
wollomatic Dec 4, 2025
eead766
Merge pull request #75 from wollomatic/28-fr-support-granular-api-per…
wollomatic Dec 4, 2025
6ea3d8f
Bump golang from 1.25.4-alpine3.22 to 1.25.5-alpine3.22
dependabot[bot] Dec 8, 2025
3e8675c
Merge branch 'develop' into dependabot/docker/golang-1.25.5-alpine3.22
wollomatic Dec 8, 2025
0ec68ed
Merge pull request #77 from wollomatic/dependabot/docker/golang-1.25.…
wollomatic Dec 8, 2025
a2d711b
Go 1.25.6 + Alpine 3.23 as build platform
wollomatic Jan 17, 2026
7a39eff
Log if per-container allowlists are enabled
wollomatic Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS build
FROM --platform=$BUILDPLATFORM golang:1.25.6-alpine3.23 AS build
WORKDIR /application
COPY . ./
ARG TARGETOS
Expand Down
5 changes: 3 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---
Parts of this project, specifically the file cmd/internal/bindmount.go,
Parts of this project, specifically the file cmd/socket-proxy/bindmount.go and
the files in the internal/docker and internal/go-connections folders,
contain source code licensed under the Apache License 2.0. See the comments
in that file for details.
in the applicable files for details.
The rest of the project is licensed under the MIT License.

Apache License
Expand Down
61 changes: 44 additions & 17 deletions README.md

Large diffs are not rendered by default.

41 changes: 22 additions & 19 deletions cmd/socket-proxy/bindmount.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ type (
)

// checkBindMountRestrictions checks if bind mounts in the request are allowed.
func checkBindMountRestrictions(r *http.Request) error {
func checkBindMountRestrictions(allowedBindMounts []string, r *http.Request) error {
// Only check if bind mount restrictions are configured
if len(cfg.AllowBindMountFrom) == 0 {
if len(allowedBindMounts) == 0 {
return nil
}

Expand All @@ -94,23 +94,23 @@ func checkBindMountRestrictions(r *http.Request) error {
switch {
case len(pathParts) >= 4 && pathParts[2] == "containers" && pathParts[3] == "create":
// Container creation: /vX.xx/containers/create
return checkContainer(r)
return checkContainer(allowedBindMounts, r)
case len(pathParts) >= 5 && pathParts[2] == "containers" && pathParts[4] == "update":
// Container update: /vX.xx/containers/{id}/update
return checkContainer(r)
return checkContainer(allowedBindMounts, r)
case len(pathParts) >= 4 && pathParts[2] == "services" && pathParts[3] == "create":
// Service creation: /vX.xx/services/create
return checkService(r)
return checkService(allowedBindMounts, r)
case len(pathParts) >= 5 && pathParts[2] == "services" && pathParts[4] == "update":
// Service update: /vX.xx/services/{id}/update
return checkService(r)
return checkService(allowedBindMounts, r)
default:
return nil
}
}

// checkContainer checks bind mounts in container creation requests.
func checkContainer(r *http.Request) error {
func checkContainer(allowedBindMounts []string, r *http.Request) error {
body, err := readAndRestoreBody(r)
if err != nil {
return err
Expand All @@ -122,11 +122,11 @@ func checkContainer(r *http.Request) error {
return nil // Don't block if we can't parse.
}

return checkHostConfigBindMounts(req.HostConfig)
return checkHostConfigBindMounts(allowedBindMounts, req.HostConfig)
}

// checkService checks bind mounts in service creation requests.
func checkService(r *http.Request) error {
func checkService(allowedBindMounts []string, r *http.Request) error {
body, err := readAndRestoreBody(r)
if err != nil {
return err
Expand All @@ -141,28 +141,31 @@ func checkService(r *http.Request) error {
if req.TaskTemplate.ContainerSpec == nil {
return nil // No container spec, nothing to check.
}
return checkHostConfigBindMounts(&containerHostConfig{
Mounts: req.TaskTemplate.ContainerSpec.Mounts,
})
return checkHostConfigBindMounts(
allowedBindMounts,
&containerHostConfig{
Mounts: req.TaskTemplate.ContainerSpec.Mounts,
},
)
}

// checkHostConfigBindMounts checks bind mounts in HostConfig.
func checkHostConfigBindMounts(hostConfig *containerHostConfig) error {
func checkHostConfigBindMounts(allowedBindMounts []string, hostConfig *containerHostConfig) error {
if hostConfig == nil {
return nil // No HostConfig, nothing to check
}

// Check legacy Binds field
for _, bind := range hostConfig.Binds {
if err := validateBindMount(bind); err != nil {
if err := validateBindMount(allowedBindMounts, bind); err != nil {
return err
}
}

// Check modern Mounts field
for _, mountItem := range hostConfig.Mounts {
if mountItem.Type == mountTypeBind {
if err := validateBindMountSource(mountItem.Source); err != nil {
if err := validateBindMountSource(allowedBindMounts, mountItem.Source); err != nil {
return err
}
}
Expand All @@ -172,23 +175,23 @@ func checkHostConfigBindMounts(hostConfig *containerHostConfig) error {
}

// validateBindMount validates a bind mount string in the format "source:target:options".
func validateBindMount(bind string) error {
func validateBindMount(allowedBindMounts []string, bind string) error {
parts := strings.Split(bind, ":")
if len(parts) < 2 {
return fmt.Errorf("invalid bind mount format: %s", bind)
}
return validateBindMountSource(parts[0])
return validateBindMountSource(allowedBindMounts, parts[0])
}

// validateBindMountSource checks if the source directory is allowed.
func validateBindMountSource(source string) error {
func validateBindMountSource(allowedBindMounts []string, source string) error {
// Skip if source is not an absolute path (i.e. bind mount).
if !strings.HasPrefix(source, "/") {
return nil
}

source = filepath.Clean(source) // Clean the path to resolve .. and . components.
for _, allowedDir := range cfg.AllowBindMountFrom {
for _, allowedDir := range allowedBindMounts {
if allowedDir == "/" || source == allowedDir || strings.HasPrefix(source, allowedDir+"/") {
return nil
}
Expand Down
25 changes: 7 additions & 18 deletions cmd/socket-proxy/bindmount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"net/http"
"runtime"
"testing"

"github.com/wollomatic/socket-proxy/internal/config"
)

func skipIfNotUnix(t *testing.T) {
Expand All @@ -21,9 +19,7 @@ func skipIfNotUnix(t *testing.T) {
func TestValidateBindMountSource(t *testing.T) {
skipIfNotUnix(t)

cfg = &config.Config{
AllowBindMountFrom: []string{"/home", "/var/log"},
}
allowedBindMounts := []string{"/home", "/var/log"}

tests := []struct {
name string
Expand All @@ -44,7 +40,7 @@ func TestValidateBindMountSource(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBindMountSource(tt.source)
err := validateBindMountSource(allowedBindMounts, tt.source)
if tt.shouldPass && err != nil {
t.Errorf("expected %s to pass, but got error: %v", tt.source, err)
}
Expand Down Expand Up @@ -83,10 +79,7 @@ func TestIsPathAllowed(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg = &config.Config{
AllowBindMountFrom: []string{tt.allowedDir},
}
err := validateBindMountSource(tt.path)
err := validateBindMountSource([]string{tt.allowedDir}, tt.path)
if (err == nil) != tt.expected {
t.Errorf("isPathAllowed(%s, %s) = %v, expected %v", tt.path, tt.allowedDir, err, tt.expected)
}
Expand All @@ -97,9 +90,7 @@ func TestIsPathAllowed(t *testing.T) {
func TestValidateBindMount(t *testing.T) {
skipIfNotUnix(t)

cfg = &config.Config{
AllowBindMountFrom: []string{"/home", "/var/log"},
}
allowedBindMounts := []string{"/home", "/var/log"}

tests := []struct {
name string
Expand All @@ -115,7 +106,7 @@ func TestValidateBindMount(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBindMount(tt.bind)
err := validateBindMount(allowedBindMounts, tt.bind)
if tt.shouldPass && err != nil {
t.Errorf("expected %s to pass, but got error: %v", tt.bind, err)
}
Expand All @@ -129,9 +120,7 @@ func TestValidateBindMount(t *testing.T) {
func TestCheckBindMountRestrictions(t *testing.T) {
skipIfNotUnix(t)

cfg = &config.Config{
AllowBindMountFrom: []string{"/home"},
}
allowedBindMounts := []string{"/home"}

tests := []struct {
name string
Expand Down Expand Up @@ -212,7 +201,7 @@ func TestCheckBindMountRestrictions(t *testing.T) {
t.Fatalf("failed to create request: %v", err)
}

err = checkBindMountRestrictions(req)
err = checkBindMountRestrictions(allowedBindMounts, req)
if tt.shouldPass && err != nil {
t.Errorf("expected request to pass, but got error: %v", err)
}
Expand Down
58 changes: 40 additions & 18 deletions cmd/socket-proxy/handlehttprequest.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,21 @@ import (
"log/slog"
"net"
"net/http"

"github.com/wollomatic/socket-proxy/internal/config"
)

// handleHTTPRequest checks if the request is allowed and sends it to the proxy.
// Otherwise, it returns a "405 Method Not Allowed" or a "403 Forbidden" error.
// In case of an error, it returns a 500 Internal Server Error.
func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket
allowedIP, err := isAllowedClient(r.RemoteAddr)
if err != nil {
slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr)
}
if !allowedIP {
communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden)
return
}
allowList, ok := determineAllowList(r)
if !ok {
communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden)
return
}

// check if the request is allowed
allowed, exists := cfg.AllowedRequests[r.Method]
allowed, exists := allowList.AllowedRequests[r.Method]
if !exists { // method not in map -> not allowed
communicateBlockedRequest(w, r, "method not allowed", http.StatusMethodNotAllowed)
return
Expand All @@ -34,7 +30,7 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
}

// check bind mount restrictions
if err := checkBindMountRestrictions(r); err != nil {
if err := checkBindMountRestrictions(allowList.AllowedBindMounts, r); err != nil {
communicateBlockedRequest(w, r, "bind mount restriction: "+err.Error(), http.StatusForbidden)
return
}
Expand All @@ -44,14 +40,40 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
socketProxy.ServeHTTP(w, r) // proxy the request
}

// return the relevant allowlist
func determineAllowList(r *http.Request) (config.AllowList, bool) {
if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket
// Get the client IP address from the remote address string
clientIPStr, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
slog.Warn("cannot get valid IP address from request", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr)
return config.AllowList{}, false
}

// If applicable, get the non-default allowlist corresponding to the client IP address
if cfg.ProxyContainerName != "" {
allowList, found := cfg.AllowLists.FindByIP(clientIPStr)
if found {
return allowList, true
}
}

// Check if client is allowed for the default allowlist:
allowedIP, err := isAllowedClient(clientIPStr)
if err != nil {
slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr)
}
if !allowedIP {
return config.AllowList{}, false
}
}

return cfg.AllowLists.Default, true
}

// isAllowedClient checks if the given remote address is allowed to connect to the proxy.
// The IP address is extracted from a RemoteAddr string (the part before the colon).
func isAllowedClient(remoteAddr string) (bool, error) {
// Get the client IP address from the remote address string
clientIPStr, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return false, err
}
func isAllowedClient(clientIPStr string) (bool, error) {
// Parse the IP address
clientIP := net.ParseIP(clientIPStr)
if clientIP == nil {
Expand Down
31 changes: 14 additions & 17 deletions cmd/socket-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func main() {
}
slog.SetDefault(logger)

// setup non-default allowlists
if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" {
go cfg.UpdateAllowLists()
}

// print configuration
slog.Info("starting socket-proxy", "version", version, "os", runtime.GOOS, "arch", runtime.GOARCH, "runtime", runtime.Version(), "URL", programURL)
if cfg.ProxySocketEndpoint == "" {
Expand All @@ -71,26 +76,18 @@ func main() {
} else {
slog.Info("watchdog disabled")
}
if len(cfg.AllowBindMountFrom) > 0 {
slog.Info("Docker bind mount restrictions enabled", "allowbindmountfrom", cfg.AllowBindMountFrom)
if len(cfg.ProxyContainerName) > 0 {
slog.Info("Proxy container name provided", "proxycontainername", cfg.ProxyContainerName)
slog.Info("per-container allowlists enabled!")
} else {
// we only log this on DEBUG level because bind mount restrictions are a very special use case
slog.Debug("no Docker bind mount restrictions")
// we only log this on DEBUG level because providing the socket-proxy container name
// enables the use of labels to specify per-container allowlists
slog.Debug("no proxy container name provided")
}
cfg.AllowLists.PrintNetworks()

// print request allowlist
if cfg.LogJSON {
for method, regex := range cfg.AllowedRequests {
slog.Info("configured allowed request", "method", method, "regex", regex)
}
} else {
// don't use slog here, as we want to print the regexes as they are
// see https://github.com/wollomatic/socket-proxy/issues/11
fmt.Printf("Request allowlist:\n %-8s %s\n", "Method", "Regex")
for method, regex := range cfg.AllowedRequests {
fmt.Printf(" %-8s %s\n", method, regex)
}
}
// print default request allowlist
cfg.AllowLists.PrintDefault(cfg.LogJSON)

// check if the socket is available
err = checkSocketAvailability(cfg.SocketPath)
Expand Down
Loading
Loading