Skip to content

Commit bac8f8f

Browse files
committed
[breaking] switch to complete distroless
1 parent de9e6c4 commit bac8f8f

File tree

5 files changed

+138
-83
lines changed

5 files changed

+138
-83
lines changed

.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
{
22
"image":"11notes/socket-proxy",
33
"name":"socket-proxy",
4-
"root":"/socket-proxy",
4+
"root":"/",
55

66
"semver":{
7-
"version":"1.0.1",
8-
"stable":"1.0.1",
9-
"latest":"1.0.1"
7+
"version":"2.0.0",
8+
"stable":"2.0.0",
9+
"latest":"2.0.0"
1010
},
1111

1212
"readme":{
13-
"description":"Access your docker socket safely as read-only",
13+
"description":"Access your docker socket safely as read-only, rootless and distroless",
1414
"parent":{
15-
"image":"11notes/alpine:stable"
16-
}
15+
"image":"scratch"
16+
},
17+
"distroless":true
1718
}
1819
}

arch.dockerfile

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1-
# :: Util
2-
FROM 11notes/util AS util
3-
4-
# :: Build
5-
FROM golang:1.24-alpine AS build
1+
# :: Distroless
2+
FROM alpine AS fs
3+
USER root
4+
RUN set -ex; \
5+
mkdir -p /rootfs/run/proxy; \
6+
mkdir -p /rootfs/etc; \
7+
echo "root:x:0:0:root:/root:/bin/sh" > /rootfs/etc/passwd; \
8+
echo "root:x:0:root" > /rootfs/etc/group;
9+
10+
# :: Build // socket-proxy
11+
FROM golang:1.24-alpine AS socket-proxy
12+
ARG TARGETARCH
13+
USER root
614
COPY ./go/ /go
715
RUN set -ex; \
816
cd /go/socket-proxy; \
917
go build -ldflags="-extldflags=-static" -o socket-proxy main.go; \
1018
mv socket-proxy /usr/local/bin/socket-proxy;
1119

1220
# :: Header
13-
FROM 11notes/alpine:stable
21+
FROM scratch
1422

1523
# :: arguments
1624
ARG TARGETARCH
@@ -27,30 +35,18 @@
2735
ENV APP_VERSION=${APP_VERSION}
2836
ENV APP_ROOT=${APP_ROOT}
2937

30-
ENV SOCKET_PROXY="${APP_ROOT}/run/docker.sock"
38+
ENV SOCKET_PROXY_VOLUME="/run/proxy"
3139
ENV SOCKET_PROXY_DOCKER_SOCKET="/run/docker.sock"
40+
ENV SOCKET_PROXY_UID=1000
41+
ENV SOCKET_PROXY_GID=1000
3242

3343
# :: multi-stage
34-
COPY --from=util /usr/local/bin/ /usr/local/bin
35-
COPY --from=build /usr/local/bin/ /usr/local/bin
36-
37-
# :: Run
38-
USER root
39-
RUN eleven printenv;
40-
41-
# :: install application
42-
RUN set -ex; \
43-
eleven mkdir ${APP_ROOT}/{etc,run};
44-
45-
# :: copy filesystem changes and set correct permissions
46-
COPY ./rootfs /
47-
RUN set -ex; \
48-
chmod +x -R /usr/local/bin; \
49-
chown -R 1000:1000 \
50-
${APP_ROOT}
44+
COPY --from=fs /rootfs/ /
45+
COPY --from=socket-proxy /usr/local/bin/socket-proxy /
5146

5247
# :: Monitor
53-
HEALTHCHECK --interval=5s --timeout=2s CMD curl --unix-socket ${SOCKET_PROXY} http://localhost/version || exit 1
48+
HEALTHCHECK --interval=5s --timeout=2s CMD ["/socket-proxy", "--healthcheck"]
5449

5550
# :: Start
56-
USER root
51+
USER root
52+
ENTRYPOINT ["/socket-proxy"]

compose.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
name: "socket-proxy"
1+
name: "traefik" # this is a compose example for Traefik
22
services:
33
socket-proxy:
4-
image: "11notes/socket-proxy:1.0.0"
4+
image: "11notes/socket-proxy:2.0.0"
55
volumes:
66
- "/run/docker.sock:/run/docker.sock:ro" # mount host docker socket, the :ro does not mean read-only for the socket, just for the actual file
7-
- "socket-proxy:/socket-proxy/run" # this socket is run as 1000:1000, not as root!
7+
- "socket-proxy:/run/proxy" # this socket is run as 1000:1000, not as root!
88
restart: "always"
99

1010
traefik:
@@ -37,7 +37,7 @@ services:
3737
net.ipv4.ip_unprivileged_port_start: 80
3838
restart: "always"
3939

40-
nginx:
40+
nginx: # example container
4141
image: "11notes/nginx:1.26.2"
4242
labels:
4343
- "traefik.enable=true"

go/socket-proxy/main.go

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import(
1212
"syscall"
1313
"sync"
1414
"regexp"
15+
"strconv"
16+
"flag"
1517
)
1618

1719
var(
1820
proxy *httputil.ReverseProxy
1921
socket net.Listener
2022
wg sync.WaitGroup
23+
socketProxy string
2124
)
2225

2326
func signals(){
@@ -29,7 +32,35 @@ func signals(){
2932
}()
3033
}
3134

35+
func prepareFileSystemDropPrivileges(){
36+
// unprivileged user
37+
proxyUID, err := strconv.Atoi(os.Getenv("SOCKET_PROXY_UID"))
38+
if err != nil {
39+
log.Fatalf("SOCKET_PROXY_UID must be a number %v", err)
40+
}
41+
proxyGID, err := strconv.Atoi(os.Getenv("SOCKET_PROXY_GID"))
42+
if err != nil {
43+
log.Fatalf("SOCKET_PROXY_GID must be a number %v", err)
44+
}
45+
proxyVolume := regexp.MustCompile(`/+$`).ReplaceAllString(os.Getenv("SOCKET_PROXY_VOLUME"), "")
46+
47+
// chown file system for unprivileged user
48+
if err := os.Chown(proxyVolume, proxyUID , proxyGID); err != nil {
49+
log.Fatalf("could not chown folder %s", proxyVolume, err)
50+
}
51+
52+
// drop privileges since only the proxy must access the socket as root and nothing else
53+
if err := syscall.Setgid(proxyGID); err != nil {
54+
log.Fatalf("could not set GID to %d %v", proxyGID, err)
55+
}
56+
57+
if err := syscall.Setuid(proxyUID); err != nil {
58+
log.Fatalf("could not set UID to %d %v", proxyUID, err)
59+
}
60+
}
61+
3262
func httpProxyBlockedPaths(url string) bool {
63+
// block paths that use GET but pose security risk
3364
blockedPatterns := []*regexp.Regexp{
3465
regexp.MustCompile(`(?i)containers/\S+/attach/ws.*`), // could attach to stdin via web socket and issue command inside the container
3566
regexp.MustCompile(`(?i)containers/\S+/export.*`), // could exfil container data
@@ -60,54 +91,70 @@ func httpProxy(w http.ResponseWriter, r *http.Request){
6091
}
6192

6293
func main(){
63-
signals()
64-
65-
// setup proxy to docker socket
66-
localhost, _ := url.Parse("http://localhost")
67-
proxy = httputil.NewSingleHostReverseProxy(localhost)
68-
proxy.Transport = &http.Transport{
69-
DialContext: func(_ context.Context, _, _ string)(net.Conn, error){
70-
return net.Dial("unix", os.Getenv("SOCKET_PROXY_DOCKER_SOCKET"))
71-
},
72-
}
73-
74-
// drop privileges since only the proxy must access the socket as root and nothing else
75-
if err := syscall.Setgid(1000); err != nil {
76-
log.Fatalf("could not set GID to 1000 %v", err)
77-
}
78-
79-
if err := syscall.Setuid(1000); err != nil {
80-
log.Fatalf("could not set UID to 1000 %v", err)
81-
}
94+
// set socket proxy file path
95+
socketProxy = regexp.MustCompile(`/+$`).ReplaceAllString(os.Getenv("SOCKET_PROXY_VOLUME"), "") + "/docker.sock"
96+
97+
// check for command line flags
98+
healthCheckFlag := flag.Bool("healthcheck", false, "just run healthcheck")
99+
flag.Parse()
100+
101+
if(*healthCheckFlag){
102+
// only run healthcheck
103+
_, err := net.Dial("unix", socketProxy)
104+
if err != nil {
105+
os.Exit(1)
106+
}
107+
os.Exit(0)
108+
}else{
109+
// setup signal handler
110+
signals()
111+
112+
// setup proxy to docker socket as root
113+
localhost, _ := url.Parse("http://localhost")
114+
proxy = httputil.NewSingleHostReverseProxy(localhost)
115+
proxy.Transport = &http.Transport{
116+
DialContext: func(_ context.Context, _, _ string)(net.Conn, error){
117+
return net.Dial("unix", os.Getenv("SOCKET_PROXY_DOCKER_SOCKET"))
118+
},
119+
}
82120

83-
wg.Add(2)
121+
// prepare the file system and drop privileges to UID/GID
122+
prepareFileSystemDropPrivileges()
84123

85-
// setup unix to socket proxy
86-
serverUnix := &http.Server{
87-
Handler: http.HandlerFunc(httpProxy),
88-
}
124+
wg.Add(2)
89125

90-
os.Remove(os.Getenv("SOCKET_PROXY"))
91-
unix, _ := net.Listen("unix", os.Getenv("SOCKET_PROXY"))
92-
go func(){
93-
defer wg.Done()
94-
if err := serverUnix.Serve(unix); err != nil {
126+
// setup unix to socket proxy
127+
serverUnix := &http.Server{
128+
Handler: http.HandlerFunc(httpProxy),
129+
}
130+
os.Remove(socketProxy)
131+
unix, err := net.Listen("unix", socketProxy)
132+
if err != nil {
95133
log.Fatalf("could not start unix socket %v", err)
96134
}
97-
}()
98-
99-
// setup http to socket proxy
100-
httpServer := &http.Server{
101-
Handler: http.HandlerFunc(httpProxy),
102-
}
135+
go func(){
136+
defer wg.Done()
137+
if err := serverUnix.Serve(unix); err != nil {
138+
log.Fatalf("could not start unix socket %v", err)
139+
}
140+
}()
141+
142+
// setup http to socket proxy
143+
httpServer := &http.Server{
144+
Handler: http.HandlerFunc(httpProxy),
145+
}
103146

104-
tcp, _ := net.Listen("tcp", "0.0.0.0:8080")
105-
go func(){
106-
defer wg.Done()
107-
if err := httpServer.Serve(tcp); err != nil {
147+
tcp, err := net.Listen("tcp", "0.0.0.0:2375")
148+
if err != nil {
108149
log.Fatalf("could not start tcp socket %v", err)
109150
}
110-
}()
111-
112-
wg.Wait()
151+
go func(){
152+
defer wg.Done()
153+
if err := httpServer.Serve(tcp); err != nil {
154+
log.Fatalf("could not start tcp socket %v", err)
155+
}
156+
}()
157+
158+
wg.Wait()
159+
}
113160
}

project.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
${{ content_synopsis }} This image will run a proxy to access your docker socket read-only. The exposed proxy socket is run as 1000:1000, not as root, although the image starts the proxy process as root to interact with the actual docker socket as root. There is also a TCP endpoint started at 8080 that will also proxy to the actual docker socket if needed.
1+
${{ content_synopsis }} This image will run a proxy to access your docker socket as read-only. The exposed proxy socket is run as 1000:1000, not as root, although the image starts the proxy process as root to interact with the actual docker socket. There is also a TCP endpoint started at 2375 that will also proxy to the actual docker socket if needed. It is not exposed by default and must be exposed via using ```- "2375:2375/tcp"``` in your compose.
22

3-
${{ content_compose }}
3+
${{ content_uvp }} Good question! All the other images on the market that do exactly the same don’t do or offer these options:
4+
5+
* This image runs the proxy part as a specific UID/GID (not root), all other images run everything as root
6+
* This image uses a single binary, all other images use apps like Nginx or HAProxy (bloat)
7+
* This image has no shell since it is 100% distroless, all other images run on a distro like Debian or Alpine with full shell access (security)
8+
* This image does not ship with any CVE and is automatically maintained via CI/CD, all other images mostly have no CVE scanning or code quality tools in place
9+
* This image has no upstream dependencies, all other images have upstream dependencies
10+
* This image exposes the socket as a UNIX socket and TCP socket, all other images only expose it via a TCP socket
411

5-
${{ content_defaults }}
12+
If you value security, simplicity and the ability to interact with the maintainer and developer of an image. Then using my images is a great start in the right direction.
13+
14+
${{ content_compose }}
615

716
${{ content_environment }}
8-
| `SOCKET_PROXY` | path to the socket used as a proxy | ${{ json_root }}$/run/docker.sock |
17+
| `SOCKET_PROXY_VOLUME` | path to the docker volume used to expose the prox socket | /run/proxy |
918
| `SOCKET_PROXY_DOCKER_SOCKET` | path to the actual docker socket | /run/docker.sock |
19+
| `SOCKET_PROXY_UID` | the UID used to run the proxy parts | 1000 |
20+
| `SOCKET_PROXY_GID` | the GID used to run the proxy parts | 1000 |
1021

1122
${{ content_source }}
1223

0 commit comments

Comments
 (0)