Software developers frequently encounter scenarios where Go applications need to bind to privileged network ports (those below 1024), such as port 80 for HTTP or 443 for HTTPS. The standard Linux mechanism for granting this permission without full root privileges is the CAP_NET_BIND_SERVICE
capability. However, integrating this with systemd-managed services can lead to the dreaded EPERM
(Operation not permitted) error, often due to subtle misconfigurations in how capabilities, especially ambient capabilities, are handled.
This article provides a comprehensive guide to understanding and resolving these EPERM
issues. We’ll explore Linux capabilities, systemd’s role in privilege management, and how to correctly configure your Go application’s systemd service unit to use CAP_NET_BIND_SERVICE
effectively, focusing on the modern ambient capabilities model.
Understanding the Core Components
Successfully binding to privileged ports involves the interplay of Linux capabilities, your systemd service definition, and your Go application’s behavior.
Linux Capabilities: The “Why” and “What”
Linux capabilities capabilities(7) break down the all-powerful root privileges into smaller, distinct units. This allows processes to be granted only the specific permissions they need, adhering to the principle of least privilege.
CAP_NET_BIND_SERVICE
: This is the star of our show. It specifically grants a process the permission to bind to TCP/UDP sockets on privileged ports (0-1023).
Each process possesses several capability sets:
- Permitted (P): The full set of capabilities the process may use.
- Effective (E): The capabilities currently in effect for permission checks by the kernel.
- Inheritable (I): Capabilities that can be passed to a new program during an
execve(2)
call. - Bounding (B): A limiting superset. Once a capability is dropped from this set, it cannot be regained by the process or its children.
- Ambient (A): Introduced in Linux kernel 4.3+, this set allows capabilities to be inherited by child processes across
execve(2)
calls, even for non-privileged executables (those without file capabilities set). This is crucial for systemd services.
Systemd: Managing Services and Their Privileges
Systemd systemd.exec(5) is the de facto init system and service manager on most modern Linux distributions. It provides robust mechanisms for controlling the execution environment of services, including their capabilities.
Key systemd service unit directives for capability management include:
User=
andGroup=
: Define the user and group under which the service process runs. Running as a non-root user is a security best practice.CapabilityBoundingSet=
: Specifies the limiting set of capabilities for the service. It’s often used to drop unneeded capabilities.AmbientCapabilities=
: Defines the capabilities that are added to the service process’s ambient set. This is the preferred modern way to grant specific capabilities likeCAP_NET_BIND_SERVICE
to services that don’t run as root.NoNewPrivileges=true
: A security feature that prevents the service process or its children from gaining more privileges than they started with (e.g., viasetuid
binaries). Ambient capabilities are designed to work correctly withNoNewPrivileges=true
.
Go Applications: The Receiving End
Go’s standard net
package, when attempting to Listen
on a TCP or UDP port, ultimately makes a bind(2)
syscall. If the port is privileged and the Go process lacks CAP_NET_BIND_SERVICE
in its effective capability set, the kernel will deny the request, resulting in an error like “bind: permission denied” (which is an EPERM
error).
Go applications themselves don’t typically manipulate capability sets directly. They inherit them from the environment in which they are launched, which, in our case, is systemd.
The EPERM
Culprit: Why Permissions Fail
An EPERM
error when trying to use CAP_NET_BIND_SERVICE
often boils down to one or more of these common issues:
Systemd Unit Misconfiguration (Most Common):
AmbientCapabilities=CAP_NET_BIND_SERVICE
is missing or misspelled.CAP_NET_BIND_SERVICE
is inadvertently dropped from theCapabilityBoundingSet=
.- The service is configured to run as a non-root user (e.g.,
User=myuser
), but capabilities aren’t correctly passed through this user transition. Ambient capabilities solve this. - Misunderstanding the interaction with
NoNewPrivileges=true
(though ambient capabilities generally handle this well).
Incorrect Use of File Capabilities (
setcap
):- If using file capabilities (e.g.,
sudo setcap 'cap_net_bind_service=+ep' /path/to/app
), this might not play well with systemd’s user switching orNoNewPrivileges=true
, especially if the user is non-root. Ambient capabilities are generally superior for systemd services. - The binary was updated, and
setcap
was not reapplied.
- If using file capabilities (e.g.,
Overly Restrictive Global Settings: System-wide security policies or parent systemd scope unit settings might be unexpectedly limiting capabilities.
The Solution: Correctly Configuring Systemd with Ambient Capabilities
For Go applications managed by systemd, using the AmbientCapabilities=
directive is the most robust and recommended way to grant CAP_NET_BIND_SERVICE
.
The Preferred Method: AmbientCapabilities
in Systemd
Let’s construct a systemd service unit that correctly grants our Go application the ability to bind to privileged ports.
First, create or ensure you have a dedicated non-root user for your application.
This example assumes a user gouser
and group gogroup
.
|
|
Below is an example systemd service unit file, typically located at /etc/systemd/system/mygoapp.service
:
|
|
Explanation of Key Directives:
User=gouser
,Group=gogroup
: Runs the Go application as thegouser
user. This is crucial for security.ExecStart=/usr/local/bin/mygoapp ...
: Specifies the command to run your application.AmbientCapabilities=CAP_NET_BIND_SERVICE
: This is the core directive. It addsCAP_NET_BIND_SERVICE
to the ambient capability set of the process, allowing it to be inherited and made effective even when running as a non-root user and withNoNewPrivileges=true
.CapabilityBoundingSet=...
: This defines the “ceiling” of capabilities your service can ever possess.CAP_NET_BIND_SERVICE
must be present in this set for it to be made ambient and effective. If you don’t specify this, systemd’s defaults usually allow it for system services. However, if you are crafting a minimal set, ensureCAP_NET_BIND_SERVICE
is included.NoNewPrivileges=true
: This security measure prevents the process from acquiring any new privileges after it starts. Ambient capabilities are granted before the main executable runs, so this directive is compatible and recommended.
A Practical Go Application Example
Here’s a simple Go HTTP server that attempts to bind to port 80. This code, when run directly without proper permissions, would fail.
File: main.go
|
|
Compile this application:
|
|
Applying and Verifying the Setup
After creating or modifying your systemd service file (/etc/systemd/system/mygoapp.service
):
Reload Systemd Configuration:
1
sudo systemctl daemon-reload
Enable and Start Your Service:
1 2
sudo systemctl enable mygoapp.service sudo systemctl start mygoapp.service
Check Service Status:
1
sudo systemctl status mygoapp.service
Look for “active (running)”. If it failed, the status output will often show recent log lines indicating the error.
Inspect Logs:
1
sudo journalctl -u mygoapp.service -f
If the Go application failed with
EPERM
, you’d see thelog.Fatalf
output here. If successful, you’ll see your “Attempting to listen…” message. You can then test by accessinghttp://your_server_ip/
in a browser.
Diagnosing Persistent EPERM
Issues
If you still face EPERM
errors, systematic troubleshooting is key.
Step-by-Step Troubleshooting
Double-Check Systemd Unit:
- Typos in capability names (
CAP_NET_BIND_SERVICE
is correct). - Correct paths in
ExecStart=
. - Ensure
User=
andGroup=
exist and have permissions to execute the binary (though systemd usually handles this if the binary is executable by others).
- Typos in capability names (
Inspect Process Capabilities: Once the service attempts to start (even if it fails quickly), find its Process ID (PID). If it’s running,
pgrep mygoapp
might work. If it fails fast, you might need to temporarily add asleep
in your Go app or systemd unit’sExecStartPre
to catch it.With the PID, check its capability sets:
1 2
# Replace <PID> with the actual process ID cat /proc/<PID>/status | grep Cap
This outputs hexadecimal masks for
CapInh
(Inheritable),CapPrm
(Permitted),CapEff
(Effective),CapBnd
(Bounding), andCapAmb
(Ambient).CAP_NET_BIND_SERVICE
has the numerical value 10. Its bitmask is2^10 = 1024
, which is0x400
in hexadecimal. You want to see this bit set inCapEff
andCapAmb
. For example:CapAmb: 0000000000000400
CapEff: 0000000000000400
A helper script can get the PID and display capabilities:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
#!/bin/bash APP_NAME="mygoapp" # Change to your app's executable name PID=$(pgrep -u gouser "${APP_NAME}") # Search by user and name if [ -z "$PID" ]; then echo "Process ${APP_NAME} not found or not running as gouser." # Fallback if it runs very briefly or under a different name context # This is less reliable: # PID=$(ps aux | grep "[/]usr/local/bin/${APP_NAME}" | awk '{print $2}' | head -n1) fi if [ -n "$PID" ]; then echo "PID for ${APP_NAME}: ${PID}" echo "Capabilities from /proc/${PID}/status:" cat "/proc/${PID}/status" | grep Cap # If libcap-ng-utils is installed, pscap is more readable if command -v pscap &> /dev/null; then echo -e "\nCapabilities from pscap:" sudo pscap | grep "${PID}" fi # Or getpcaps if installed (usually part of libcap-progs or similar) if command -v getpcaps &> /dev/null; then echo -e "\nCapabilities from getpcaps ${PID}:" sudo getpcaps "${PID}" fi else echo "Could not find PID for ${APP_NAME}." fi
Save this,
chmod +x script.sh
, and run it.Test with
capsh
(Capability Shell):capsh
allows you to simulate launching processes with specific capabilities. This can help verify if the user and capability combination can work.1 2 3 4 5 6 7 8
# As root, simulate how systemd might try to grant ambient caps sudo capsh --user='gouser' \ --caps='cap_net_bind_service+eip' \ --ambient='cap_net_bind_service' \ --inh='cap_net_bind_service' \ --addamb='cap_net_bind_service' \ --keep=1 \ -- -c "/usr/local/bin/mygoapp"
This is a complex command; refer to
man capsh
. The goal is to see ifmygoapp
can run undergouser
if the capabilities are correctly set up bycapsh
. If this works but systemd fails, the issue is almost certainly in the systemd unit file or broader systemd environment.Simplify the Systemd Unit: Temporarily comment out other security hardening options (
ProtectSystem
,PrivateTmp
, etc.) to rule out interference. Re-introduce them one by one. Do not disableNoNewPrivileges=true
lightly; strive to make ambient capabilities work with it enabled.Use
strace
for Deep Dives:strace
can show the exact syscalls your application makes.1 2 3 4 5 6
# Modify your systemd unit temporarily: # ExecStart=strace -o /tmp/mygoapp.strace -f -e trace=bind /usr/local/bin/mygoapp # Then, after attempting to start: # sudo systemctl daemon-reload # sudo systemctl restart mygoapp.service # cat /tmp/mygoapp.strace
Look for the
bind(...)
call. If it returns-1 EPERM (Operation not permitted)
, this confirms the permission issue at the syscall level.
Beyond Ambient Capabilities: Alternatives and Advanced Scenarios
While ambient capabilities are preferred for systemd services, other methods exist.
File Capabilities (setcap
): When and Why (with Caveats)
You can attach capabilities directly to an executable file:
|
|
And verify with:
|
|
- Pros: Simple for standalone binaries run directly, works outside systemd.
- Cons:
- Must be reapplied every time the binary is updated.
- Can interact poorly with systemd’s
User=
directive (file capabilities might be dropped during UID transition if the user is non-root) andNoNewPrivileges=true
. - Generally less robust for services managed by systemd compared to
AmbientCapabilities
.
Socket Activation with Systemd
Systemd can pre-bind to sockets (as root) and then pass the ready-to-use file descriptors to your service, which then runs as a non-root user without needing any capabilities itself.
Create a
.socket
unit (e.g.,/etc/systemd/system/mygoapp.socket
):1 2 3 4 5 6 7 8 9 10 11 12
[Unit] Description=My Go Application Socket [Socket] ListenStream=0.0.0.0:80 # For IPv6, add: ListenStream=[::]:80 # SocketUser=root # systemd binds the socket, often as root # SocketMode=0660 # SocketGroup=gogroup # Allow the service group to use it [Install] WantedBy=sockets.target
Modify your
.service
unit (e.g.,/etc/systemd/system/mygoapp.service
):1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
[Unit] Description=My Go Application Service (Socket Activated) # No "After=network.target" needed if only socket activated Requires=mygoapp.socket # Important dependency After=mygoapp.socket [Service] User=gouser Group=gogroup ExecStart=/usr/local/bin/mygoapp -socket-activation # App needs to handle this # No capabilities needed here! NoNewPrivileges=true [Install] WantedBy=multi-user.target
Go Application Changes: Your Go app needs to be aware of socket activation. It will receive the listener file descriptor (usually FD 3). Libraries like
go.einride.tech/linters/std/ υπηρεσιακός/go/systemd
orgithub.com/coreos/go-systemd/v22/activation
can help, or you can usenet.FileListener()
. A conceptual Go snippet:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// import "net" // import "os" // import "strconv" // Check for systemd socket activation environment variables // Typically, LISTEN_FDS will be set. // listenFdsStr := os.Getenv("LISTEN_FDS") // if listenFdsStr != "" { // fds, _ := strconv.Atoi(listenFdsStr) // if fds > 0 { // // FD 3 is the first passed listener by convention // file := os.NewFile(uintptr(3), "socket") // listener, err := net.FileListener(file) // // ... use this listener with http.Serve(listener, nil) // } // }
Enable and start mygoapp.socket
, then mygoapp.service
.
Using a Reverse Proxy (e.g., Nginx, Caddy, HAProxy)
A very common and robust pattern is to run your Go application on an unprivileged port (e.g., 8080) as a non-root user (no special capabilities needed). Then, place a dedicated web server/reverse proxy like Nginx in front. Nginx listens on port 80/443 (it handles its own CAP_NET_BIND_SERVICE
needs or runs initial processes as root) and forwards traffic to your Go app.
- Pros: Enhanced security (Go app has minimal privileges), load balancing, TLS termination, caching, serving static files, etc., handled by the proxy.
- Cons: Adds another component to configure and manage. Might be overkill for very simple services.
Important Security Considerations
- Principle of Least Privilege: Always run your Go service as a non-root user. Use
AmbientCapabilities
to grant onlyCAP_NET_BIND_SERVICE
if that’s all it needs. KeepCapabilityBoundingSet
as minimal as possible. - SELinux/AppArmor: Mandatory Access Control systems like SELinux or AppArmor can also prevent port binding, even if Linux capabilities are correctly configured. If
EPERM
persists, check your system’s audit logs (e.g.,/var/log/audit/audit.log
orjournalctl | grep 'avc: denied'
) for denials from these systems and adjust policies accordingly (e.g.,setsebool -P httpd_can_network_connect 1
for SELinux, or updating AppArmor profiles).
Conclusion
Resolving EPERM
errors when your Go application needs to bind to privileged ports under systemd typically involves a correct understanding and application of Linux capabilities, particularly AmbientCapabilities
. By configuring your systemd service unit to run as a non-root user and explicitly granting CAP_NET_BIND_SERVICE
through the ambient set, you can achieve secure and functional deployments.
While alternatives like file capabilities or socket activation exist, ambient capabilities provide a clean, declarative, and robust solution within the systemd ecosystem. Remember to always apply the principle of least privilege and consider other security layers like reverse proxies or MAC systems for a comprehensive security posture. With careful configuration and systematic troubleshooting, you can conquer EPERM
and get your Go services listening on the ports they need.