Repository: caddyserver/caddy (language: go)

Issue:
Allow one to specify file permissions for unix sockets
Caddy supports unix sockets via `unix//path/to/unix.socket`[^1]
[^1]: https://caddyserver.com/docs/conventions#network-addresses ([perma link](https://github.com/caddyserver/website/blob/60323a9e839e43209a462ba2a2166b381f08e5c4/src/docs/markdown/conventions.md#network-addresses))

Those currently will infer the sockets' file permissions from the shell's set file-creation mode mask (umask)[^2].
[^2]: That is just how the Golang stdlib does it

Usually something along the following:
```bash
❯ umask # octal
022
❯ umask -S # symbolic
u=rwx,g=rx,o=rx
```

**:information_source: A user only needs `rw` (read/write) access to a socket to be able to use it. `x` (execute) is not required.**

However, one might want to modify the permission of a socket via json config or within the Caddyfile.
Not only is this *optionally* more declarative, but would properly allow for granular access control.

For example:
1. _Admin API_ shall only be accessible for user `caddy`
1. _vhost_1_ shall be accessible for all users on the host
1. _vhost_2_ shall only be accessible for users of a specific group

Though the last one (*vhost_2*) won't work with the default systemd service, because it runs as `caddy:caddy`, thus inferring `caddy:caddy` as owner.
The idea is to only run a `chmod`. An additional `chown` would make sense for *vhost_2* though :woman_shrugging: 

Example use-case and context where the default `u=rwx,g=rx` are insufficient, because both `caddy` and `debian-tor` users need to access the socket: https://caddy.community/t/new-user-cant-get-darkweb-site-host-working/15491

@francislavoie and I discussed that in slack a bit, where he proposed `unix//path/to/unix.sock|0666` as an extended version of `unix//path/to/unix.sock` but with `os.Chmod(socketPath, 0666)`.

Currently, in 2.5.0-beta.1, a socket with `|0666` appended will just get directly passed through and create the socket at `/path/to/unix.sock|0666` (literally).



Discussion / hints:
Not a bad idea, will consider this! Thanks :)
Not sure if this belongs here or into a new issue, but is there a possibility to support setting the socket ownership—at least the group?

I have a use case where I'm running Caddy behind cloudflared because it's on a private network with dynamic public IP. To increase security, I would like to restrict permissions so that only cloudflared can connect to Caddy by making the sockets owned by a group that only Caddy and cloudflared are members of.

Fix patch:
diff --git a/listeners.go b/listeners.go
index 1387d385bb2..cbce68d6138 100644
--- a/listeners.go
+++ b/listeners.go
@@ -20,6 +20,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"io/fs"
 	"net"
 	"net/netip"
 	"os"
@@ -148,11 +149,27 @@ func (na NetworkAddress) Listen(ctx context.Context, portOffset uint, config net
 func (na NetworkAddress) listen(ctx context.Context, portOffset uint, config net.ListenConfig) (any, error) {
 	var ln any
 	var err error
+	var address string
+	var unixFileMode fs.FileMode
 
-	address := na.JoinHostPort(portOffset)
+	// split unix socket addr early so lnKey
+	// is independent of permissions bits
+	if na.IsUnixNetwork() {
+		var err error
+		address, unixFileMode, err = splitUnixSocketPermissionsBits(na.Host)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		address = na.JoinHostPort(portOffset)
+	}
 
-	// if this is a unix socket, see if we already have it open
+	// if this is a unix socket, see if we already have it open,
+	// force socket permissions on it and return early
 	if socket, err := reuseUnixSocket(na.Network, address); socket != nil || err != nil {
+		if err := os.Chmod(address, unixFileMode); err != nil {
+			return nil, fmt.Errorf("unable to set permissions (%s) on %s: %v", unixFileMode, address, err)
+		}
 		return socket, err
 	}
 
@@ -193,6 +210,12 @@ func (na NetworkAddress) listen(ctx context.Context, portOffset uint, config net
 		unixSockets[lnKey] = unix
 	}
 
+	if IsUnixNetwork(na.Network) {
+		if err := os.Chmod(address, unixFileMode); err != nil {
+			return nil, fmt.Errorf("unable to set permissions (%s) on %s: %v", unixFileMode, address, err)
+		}
+	}
+
 	return ln, nil
 }
 
@@ -288,6 +311,40 @@ func IsUnixNetwork(netw string) bool {
 	return strings.HasPrefix(netw, "unix")
 }
 
+// Takes a unix socket address in the unusual "path|bits" format
+// (e.g. /run/caddy.sock|0222) and tries to split it into
+// socket path (host) and permissions bits (port). Colons (":")
+// can't be used as separator, as socket paths on Windows may
+// include a drive letter (e.g. `unix/c:\absolute\path.sock`).
+// Permission bits will default to 0200 if none are specified.
+// Throws an error, if the first carrying bit does not
+// include write perms (e.g. `0422` or `022`).
+// Symbolic permission representation (e.g. `u=w,g=w,o=w`)
+// is not supported and will throw an error for now!
+func splitUnixSocketPermissionsBits(addr string) (path string, fileMode fs.FileMode, err error) {
+	addrSplit := strings.SplitN(addr, "|", 2)
+
+	if len(addrSplit) == 2 {
+		// parse octal permission bit string as uint32
+		fileModeUInt64, err := strconv.ParseUint(addrSplit[1], 8, 32)
+		if err != nil {
+			return "", 0, fmt.Errorf("could not parse octal permission bits in %s: %v", addr, err)
+		}
+		fileMode = fs.FileMode(fileModeUInt64)
+
+		// FileMode.String() returns a string like `-rwxr-xr--` for `u=rwx,g=rx,o=r` (`0754`)
+		if string(fileMode.String()[2]) != "w" {
+			return "", 0, fmt.Errorf("owner of the socket requires '-w-' (write, octal: '2') permissions at least; got '%s' in %s", fileMode.String()[1:4], addr)
+		}
+
+		return addrSplit[0], fileMode, nil
+	}
+
+	// default to 0200 (symbolic: `u=w,g=,o=`)
+	// if no permission bits are specified
+	return addr, 0200, nil
+}
+
 // ParseNetworkAddress parses addr into its individual
 // components. The input string is expected to be of
 // the form "network/host:port-range" where any part is
@@ -312,10 +369,11 @@ func ParseNetworkAddressWithDefaults(addr, defaultNetwork string, defaultPort ui
 		network = defaultNetwork
 	}
 	if IsUnixNetwork(network) {
+		_, _, err := splitUnixSocketPermissionsBits(host)
 		return NetworkAddress{
 			Network: network,
 			Host:    host,
-		}, nil
+		}, err
 	}
 	var start, end uint64
 	if port == "" {


Test patch:
diff --git a/listeners_test.go b/listeners_test.go
index 5508a9f08a4..1b534563d8a 100644
--- a/listeners_test.go
+++ b/listeners_test.go
@@ -555,3 +555,98 @@ func TestExpand(t *testing.T) {
 		}
 	}
 }
+
+func TestSplitUnixSocketPermissionsBits(t *testing.T) {
+	for i, tc := range []struct {
+		input          string
+		expectNetwork  string
+		expectPath     string
+		expectFileMode string
+		expectErr      bool
+	}{
+		{
+			input:          "./foo.socket",
+			expectPath:     "./foo.socket",
+			expectFileMode: "--w-------",
+		},
+		{
+			input:          `.\relative\path.socket`,
+			expectPath:     `.\relative\path.socket`,
+			expectFileMode: "--w-------",
+		},
+		{
+			// literal colon in resulting address
+			// and defaulting to 0200 bits
+			input:          "./foo.socket:0666",
+			expectPath:     "./foo.socket:0666",
+			expectFileMode: "--w-------",
+		},
+		{
+			input:          "./foo.socket|0220",
+			expectPath:     "./foo.socket",
+			expectFileMode: "--w--w----",
+		},
+		{
+			input:          "/var/run/foo|222",
+			expectPath:     "/var/run/foo",
+			expectFileMode: "--w--w--w-",
+		},
+		{
+			input:          "./foo.socket|0660",
+			expectPath:     "./foo.socket",
+			expectFileMode: "-rw-rw----",
+		},
+		{
+			input:          "./foo.socket|0666",
+			expectPath:     "./foo.socket",
+			expectFileMode: "-rw-rw-rw-",
+		},
+		{
+			input:          "/var/run/foo|666",
+			expectPath:     "/var/run/foo",
+			expectFileMode: "-rw-rw-rw-",
+		},
+		{
+			input:          `c:\absolute\path.socket|220`,
+			expectPath:     `c:\absolute\path.socket`,
+			expectFileMode: "--w--w----",
+		},
+		{
+			// symbolic permission representation is not supported for now
+			input:     "./foo.socket|u=rw,g=rw,o=rw",
+			expectErr: true,
+		},
+		{
+			// octal (base-8) permission representation has to be between
+			// `0` for no read, no write, no exec (`---`) and
+			// `7` for read (4), write (2), exec (1) (`rwx` => `4+2+1 = 7`)
+			input:     "./foo.socket|888",
+			expectErr: true,
+		},
+		{
+			// too many colons in address
+			input:     "./foo.socket|123456|0660",
+			expectErr: true,
+		},
+		{
+			// owner is missing write perms
+			input:     "./foo.socket|0522",
+			expectErr: true,
+		},
+	} {
+		actualPath, actualFileMode, err := splitUnixSocketPermissionsBits(tc.input)
+		if tc.expectErr && err == nil {
+			t.Errorf("Test %d: Expected error but got: %v", i, err)
+		}
+		if !tc.expectErr && err != nil {
+			t.Errorf("Test %d: Expected no error but got: %v", i, err)
+		}
+		if actualPath != tc.expectPath {
+			t.Errorf("Test %d: Expected path '%s' but got '%s'", i, tc.expectPath, actualPath)
+		}
+		// fileMode.Perm().String() parses 0 to "----------"
+		if !tc.expectErr && actualFileMode.Perm().String() != tc.expectFileMode {
+			t.Errorf("Test %d: Expected perms '%s' but got '%s'", i, tc.expectFileMode, actualFileMode.Perm().String())
+		}
+	}
+}