Server without root
This profile runs ordinary TCP/UDP server inbounds. TUN, transparent proxying, host route management and raw ICMP are unavailable. Existing citrus.service, citrus@.service and the main Dockerfile retain their settings for those roles.
systemd
Section titled “systemd”Packages include citrus-server.service, /usr/lib/sysusers.d/citrus-server.conf and the template /usr/share/citrus/server-config.yaml. Enable this profile separately; installing a package does not start it. After installing the package:
sudo systemd-sysusers /usr/lib/sysusers.d/citrus-server.confsudo install -d -o root -g citrus-server -m 0750 /etc/citrus-serversudo install -o root -g citrus-server -m 0640 /usr/share/citrus/server-config.yaml /etc/citrus-server/config.yamlsudoedit /etc/citrus-server/config.yaml# Add authenticated inbounds and certificates before exposing ports.sudo systemctl daemon-reloadsudo systemctl enable --now citrus-serverjournalctl -u citrus-server -fFor a source installation, first install the binary to /usr/bin/citrus, the unit from .github/release/citrus-server.service to /usr/lib/systemd/system/, the sysusers file to /usr/lib/sysusers.d/citrus-server.conf, and docker/server-config.yaml to /usr/share/citrus/server-config.yaml.
The core runs as citrus-server, with only CAP_NET_BIND_SERVICE for ports below 1024 and no privilege escalation. For ports at or above 1024, remove that capability too with systemctl edit citrus-server:
[Service]CapabilityBoundingSet=AmbientCapabilities=Store the config, certificates and private keys in /etc/citrus-server/: owner root:citrus-server, directories 0750, files 0640. Parent directories must also be traversable by that group. Relative paths resolve under /var/lib/citrus-server; use absolute paths for keys. SAFE_PATHS=/etc/citrus-server lets the core read this directory, while the systemd sandbox keeps it read-only.
Put caches, geodata, providers, UI and other writable files under /var/lib/citrus-server/ (the -d argument). systemd creates it owned by citrus-server with mode 0700; /run/citrus-server/ is available for runtime sockets. API operations that write the config file are unavailable: edit it as administrator and run sudo systemctl reload citrus-server. In-memory user management does not need config writes. Do not run both profiles on the same ports or share their data directories.
Update the binary through the package manager and update containers by replacing the image. The profile permissions prevent the core self-updater from replacing its executable.
Docker
Section titled “Docker”The separate Dockerfile.server takes the same bin/ artifacts as the main Dockerfile: bin/version.txt and the matching compressed Linux binary (see docker/file-name.sh). It is not automatically published under a separate tag. Build for the current platform:
docker build -f Dockerfile.server -t citrus-server:local .The image uses UID/GID 65532:65532, has no iptables and does not download geodata during the build. Its template opens no listening ports; add your authenticated inbounds. Prepare a ./server-config directory readable by this UID, containing config.yaml and keys. For secrets, directories 0750 and files 0640 with group 65532 suffice; on SELinux systems configure the access labels separately.
docker volume create citrus-server-datadocker run -d --name citrus-server --restart unless-stopped \ --read-only --cap-drop ALL --security-opt no-new-privileges \ --tmpfs /tmp:rw,noexec,nosuid,size=16m \ --mount type=bind,src="$(pwd)/server-config",dst=/etc/citrus,readonly \ --mount type=volume,src=citrus-server-data,dst=/var/lib/citrus \ -p 443:10443/tcp -p 443:10443/udp citrus-server:localHere the inbound listens on 10443 inside the container and Docker publishes host port 443; the process needs zero capabilities. Publish only the TCP/UDP ports your protocol uses. Use absolute TLS-key paths under /etc/citrus and writable paths under /var/lib/citrus. A new named volume inherits UID 65532 from the image. Set an existing volume or a bind-mounted data directory to owner 65532 and mode 0700 beforehand; the image does not chown files at startup. Geodata updates, when required by the config, are downloaded into the data directory at runtime.
Egress guard
Section titled “Egress guard”Through DIRECT, the authenticated users of the server inbounds (vless, trojan, shadowsocks, snell, tuic, hysteria2, anytls, mieru, sudoku, trusttunnel, shadowquic) cannot reach the server’s own network. This is on by default. The guard refuses:
- loopback, private, link-local, CGNAT (
100.64.0.0/10), ULA, multicast, unspecified and reserved addresses; - the cloud metadata services:
169.254.169.254,fd00:ec2::254,100.100.100.200,192.0.0.192; - the addresses of this host’s interfaces, where the controller, the inbounds and every other local service listen.
The guard checks IP literals and the addresses a domain resolves to, right before the connect, so a domain cannot rebind into the LAN after the rules. It covers TCP and UDP. A UDP datagram to a blocked address is dropped, and so is a datagram that comes from one. The check runs inside DIRECT after the rules, so a rule like IP-CIDR,10.0.0.0/8,DIRECT does not open it. Proxies other than DIRECT are not checked, because their server dials the destination.
A blocked dial is logged as a warning:
[TCP] dial DIRECT (match Match/) 198.51.100.7:40000 --> 127.0.0.1:9090 error: egress guard blocked 127.0.0.1: private, loopback, link-local, reserved or cloud metadata address, not reachable from inbound vless-in (user alice) (allow it with egress-guard.allow-ips, or egress-guard: false on the listener)To let the users reach what they need, such as a database on the LAN, list it in allow-ips. Each entry is a CIDR, a single address or an address:port. An entry with a port opens that port only, for example a website on this host’s public address, while the controller and the other ports of the host stay closed:
egress-guard: enable: true # the default allow-ips: [10.8.0.0/24, 192.168.1.10, 203.0.113.10:443]A config reload applies to the UDP sessions that are already open as well.
Every listener takes its own egress-guard, which overrides enable for that listener:
falselets the users of a trusted server listener reach the LAN and this host;trueguards a client listener too, for example asocksserver for remote users.
The local client inbounds (http, socks, mixed, redir, tproxy, tun, tunnel) are not guarded by default, so a client config behaves as before.
The guard does not know a public address of this host that sits behind NAT and is not on an interface. Keep the controller on 127.0.0.1 or on a unix socket anyway.
Validation
Section titled “Validation”CI runs the real binary in both profiles. The systemd check verifies UID, effective capabilities, no privilege escalation, writable state and no TUN. Docker is tested with a read-only root filesystem, zero capabilities, a writable volume and a real HTTP proxy request: authenticated traffic succeeds and anonymous traffic gets 407. Repeat the Docker check locally with bash .github/scripts/test-server-profile.sh /path/to/linux-amd64-citrus.
See systemd.exec for sandbox settings and sysusers.d for the account format.
