Analysis
The motivation came from a university Python course: I wanted to use a tablet rather than bring my heavy laptop to every class. coder/code-server: VS Code in the browser suited that use case. After I deployed it for myself, friends also wanted access. I considered containers, then chose a binary deployment with Linux users and venvs to manage each person’s files and Python packages. I did not benchmark container performance against direct deployment.
The requirements are simple, roughly summarized as follows:
- Each user can log into CodeServer using their own domain name.
- Each user can change their own login password.
- Each user has a separate Python package environment and can install pip packages independently.
- Users should not be able to access each other’s files.
- Prevent users from accidentally deleting critical system components.
- Prevent users from accidentally writing infinite loops or similar that exhaust server resources.
- Prevent users from using the server for cryptocurrency mining or as a jump box for attacks.
After analysis and discussions with AI, the final solution is roughly as follows:
- Run CodeServer under a separate Linux user and home directory, with ownership and permissions controlling file access. Home directories should be accessible only to their owners, for example with mode
0700; creating separate users alone does not hide every file. Domain access also requires a separate reverse-proxy configuration. - Listening ports are calculated based on UID. Create a venv manually for each user and activate it automatically via
.bashrcto satisfy requirement 3. - Use systemd to limit resource consumption and some process operations. This reduces the impact of mistakes but cannot prevent all mining or network abuse.
- Use systemd’s path mechanism to monitor
$HOME/.config/code-server/config.yamland automatically restart the daemon, allowing users to change their login password by modifying the configuration file – a relatively elegant way to achieve requirement 2.
For requirement 7, given the computer literacy level of first-year students, I’m not too worried (I hope?).
I kept network access for package downloads and tasks such as web scraping. Ordinary IPv4/IPv6 outbound connections remain allowed, so these settings do not replace egress controls or abuse management.
This article does not cover SELinux, because in my opinion SELinux would be over-engineering for these requirements, and I’m not very familiar with SELinux/SEPolicy (that’s the main reason lol). I also wanted to avoid imposing restrictions that hinder legitimate usage, so SELinux is not used.
Deployment
Configuring systemd
Download the CodeServer binary and upload it to the server – for example, I put it at /usr/bin/code-server. Then write the systemd service file, create /etc/systemd/system/code-server@.service:
# /etc/systemd/system/code-server@.service[Unit]Description=Code-Server for %iAfter=network.target
[Service]Type=simpleUser=%iGroup=%iWorkingDirectory=/home/%iMemoryMax=1GCPUQuota=150%TasksMax=200IOWeight=50ProtectSystem=strictReadWritePaths=/home/%iPrivateTmp=yesPrivateDevices=yesNoNewPrivileges=yesRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX# ExecStart=/bin/bash -c "PASSWORD=$(echo -n "%i" | md5sum | cut -d' ' -f1) /usr/bin/code-server --bind-addr 0.0.0.0:$((7000 + $(id -u %i))) --auth password"ExecStart=/bin/bash -c ' \ PORT=$((7000 + $(id -u %i) %% 10000)); \ /usr/bin/code-server --bind-addr 0.0.0.0:$PORT; \ 'Restart=alwaysRestartSec=5
[Install]WantedBy=multi-user.targetThe filename is code-server@.service; the part after @ is substituted for %i. The design passes the username via %i, calculates the port, and starts automatically. The port calculation logic is 7000+uid%10000. Typically UIDs start from 1000, so ports start from 8000 in order of user creation.
ProtectSystem=strict also makes home directories read-only, so ReadWritePaths=/home/%i explicitly allows writes to this user’s working directory. It does not grant additional filesystem permissions; ownership and permissions still need to be set correctly. A venv separates Python packages, but is not a security sandbox restricting access to the host or other files.
Note this part of the configuration:
MemoryMax=1GCPUQuota=150%TasksMax=200IOWeight=50ProtectSystem=strictPrivateTmp=yesPrivateDevices=yesNoNewPrivileges=yesRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX(I’ll let AI explain it) It mainly covers two aspects: Resource Control and Security & Sandboxing.
I. Resource Control
This uses Linux’s cgroups mechanism to prevent the service from consuming excessive system resources and affecting other programs.
MemoryMax=1G- Effect: Sets a 1 GiB memory limit for the service’s cgroup.
- Outcome: If the service cgroup reaches its limit and memory reclaim cannot relieve the pressure, an OOM kill may occur within the group. Here,
1Gmeans 1 GiB.
CPUQuota=150%- Effect: Limits the service to at most 150% CPU usage.
- Outcome: 100% represents one full CPU core. 150% means the service can saturate one core and use half of a second core (i.e., up to 1.5 CPU cores’ worth of compute).
TasksMax=200- Effect: Limits the service to a maximum of 200 tasks (processes or threads).
- Outcome: Prevents the service from creating unlimited child processes due to bugs or infinite loops (e.g., fork bomb), which could exhaust system PID resources and crash the system.
IOWeight=50- Effect: Sets the disk I/O priority weight. The default is usually 100.
- Outcome: Where the device and I/O controller support weighted scheduling, 50 reduces the service’s relative share under contention. It is not a fixed throughput limit or a guarantee that other services remain unaffected.
II. Security & Isolation
These settings restrict access to filesystems, devices, and sockets to reduce the impact of mistakes and limit some attack paths.
ProtectSystem=strict- Effect: Strictly protects system files.
- Outcome: Makes the filesystem hierarchy read-only, except for API filesystems and explicitly writable paths. Here,
ReadWritePathskeeps the user’s home writable. This mount restriction does not replace kernel security updates.
PrivateTmp=yes- Effect: Provides an independent temporary directory for the service.
- Outcome: The service sees its own private
/tmpand/var/tmpdirectories. It cannot see or modify files placed in the global/tmpby other users/services, and vice versa. This effectively prevents symlink attacks or data leaks based on temporary files.
PrivateDevices=yes- Effect: Isolates physical devices.
- Outcome: Provides a private
/devwith necessary pseudo-devices such as/dev/null,/dev/zero, and/dev/urandom, restricting direct access to host device nodes. This does not isolate every possible route to hardware access.
NoNewPrivileges=yes- Effect: Restricts acquiring additional privileges by executing another program.
- Outcome: The service and its descendants cannot gain privileges through setuid/setgid bits or file capabilities during
execve(). It does not prevent every escalation path, such as a kernel exploit that directly changes process credentials.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX- Effect: Restricts the socket address families the service can use.
- Outcome: Allows creating sockets in these address families:
AF_INET: IPv4 network communicationAF_INET6: IPv6 network communicationAF_UNIX: local UNIX domain sockets (for local IPC)
- On systems supporting the relevant seccomp filtering, calls to create sockets in other address families are restricted. This reduces attack surface but does not restrict IPv4/IPv6 destinations or revoke sockets inherited or passed to the service.
When creating a new user, just run:
adduser <username>systemctl enable --now code-server@<username>to simultaneously create the corresponding CodeServer service.
Configuring password change auto-restart
Create /etc/systemd/system/code-server-restart@.path:
# /etc/systemd/system/code-server-restart@.path[Unit]Description=Monitor code-server config change for %iAfter=network.target
[Path]PathChanged=/home/%i/.config/code-server/config.yaml
[Install]WantedBy=multi-user.targetCreate /etc/systemd/system/code-server-restart@.service:
[Unit]Description=Triggered safe restart for %i
[Service]Type=oneshotUser=rootExecStart=/usr/local/bin/safe-restart-codeserver.sh %i
[Install]WantedBy=multi-user.targetThe logic works like this:
Step 1: Start monitoring (handled by the .path file)
- The system or administrator starts
code-server-restart@yms.path. - systemd parses the
.pathfile, replacing%iwithyms. PathChanged=/home/yms/.config/code-server/config.yaml: systemd starts quietly listening (using the inotify mechanism) for changes to this specificconfig.yamlfile at the kernel level.
Step 2: Detect change and trigger
- The user modifies settings in the CodeServer web interface, or edits
config.yamlvia the command line and saves. PathChangeddetects that the file has been modified and closed (ensuring the write is complete and avoiding reading half-written dirty data).- Implicit binding: Because the
.pathfile does not explicitly specify a service to trigger usingUnit=, systemd’s default behavior is to trigger the.servicefile with the same name (without the suffix). - systemd then automatically starts
code-server-restart@yms.service.
Step 3: Execute the action (handled by the .service file)
- systemd executes
code-server-restart@yms.service. Again,%iis replaced withyms. Type=oneshot: Tells the system this is not a long-running daemon but a one-shot task. It exits when done.User=root: The restart operation requires higher privileges, so it’s forced to run asroot.ExecStart=/usr/local/bin/safe-restart-codeserver.sh <user>: This is the final step in the logic. The system runs the custom shell script as root, passing the username as an argument.
We don’t restart the main daemon directly inside the restart service because CodeServer auto-saves on every edit. There’s a risk that during password changes, a half-edited file might trigger a restart, causing incomplete writes. So we write a script that waits until the file is stable before triggering the restart:
/usr/local/bin/safe-restart-codeserver.sh
#!/bin/bashUSER_NAME=$1CONFIG_FILE="/home/$USER_NAME/.config/code-server/config.yaml"STAMP_FILE="/tmp/code-server-restart-${USER_NAME}.stamp"
# 1. Intercept: if stamp file exists and config file is older (or same age) than stamp,# this trigger is a leftover queued event from systemd – exitif [ -f "$STAMP_FILE" ] && [ "$CONFIG_FILE" -ot "$STAMP_FILE" ]; then echo "Config hasn't changed since last restart, exiting." exit 0fi
# 2. Debounce wait logic (unchanged)while true; do last_md5=$(md5sum "$CONFIG_FILE") sleep 5 current_md5=$(md5sum "$CONFIG_FILE")
if [ "$last_md5" == "$current_md5" ]; then # File is stable, perform restart systemctl restart code-server@$USER_NAME
# 3. After successful restart, update the stamp file's mtime touch "$STAMP_FILE" break else echo "Config file for $USER_NAME is still changing, waiting..." fidoneThe timestamp mechanism prevents multiple pointless restarts. If the config file’s last edit time is earlier than the last restart time, the restart is rejected.
After writing the files, enable the trigger: systemctl enable --now code-server-restart@<username>.path (note it’s .path, not .service). Then try slowly editing the password in the config file via the web interface, and check the logs for something like:
○ code-server-restart@lyr.service - Triggered safe restart for lyr Loaded: loaded (/etc/systemd/system/code-server-restart@.service; disabled; preset: enabled) Active: inactive (dead) since Sat 2026-06-06 06:43:55 UTC; 29s ago Invocation: 0bd37fcb6ea44a58870c06b4fde5300cTriggeredBy: ● code-server-restart@lyr.path Process: 6997 ExecStart=/usr/local/bin/safe-restart-codeserver.sh lyr (code=exited, status=0/SUCCESS) Main PID: 6997 (code=exited, status=0/SUCCESS) Mem peak: 2.5M CPU: 43ms
Jun 06 06:43:40 CodeServer systemd[1]: Starting code-server-restart@lyr.service - Triggered safe restart for lyr...Jun 06 06:43:45 CodeServer safe-restart-codeserver.sh[6997]: Config file for lyr is still changing, waiting...Jun 06 06:43:50 CodeServer safe-restart-codeserver.sh[6997]: Config file for lyr is still changing, waiting...Jun 06 06:43:55 CodeServer systemd[1]: code-server-restart@lyr.service: Deactivated successfully.Jun 06 06:43:55 CodeServer systemd[1]: Finished code-server-restart@lyr.service - Triggered safe restart for lyr.After editing stops for 5 seconds, CodeServer will restart, and the new password can be used to log in.
Configuring Python venv
First, install Python3 on the system (no need to elaborate – use the package manager). Also install python3-venv:
apt install python3-venvThen, enter the user’s home directory. I chose to create a folder named .venv for the virtual environment. Inside .venv, run python3 -m venv myvenv to create a virtual environment named myvenv, and append the following line to the user’s .bashrc:
fifi
source ~/.venv/myvenv/bin/activateAfter that, the venv is automatically activated when the shell starts.
Installing the Python extension in CodeServer and pointing it to the venv
I won’t go into detail here – just install the Python and debugger extensions in the CodeServer web interface, and set the Python interpreter to the one under the venv.
Postscript
I set this up before CopyFail and DirtyFrag were disclosed. After disclosure, I ran the PoCs available to me under this configuration and did not observe successful privilege escalation. That observation applies to the environment and samples I tested, not every related exploit variant.
The tested exploit paths needed sockets such as AF_ALG, or AF_NETLINK and AF_RXRPC, which this RestrictAddressFamilies allowlist excludes. It may therefore block those paths at socket creation. Establishing the exact failure point requires the specific PoC, kernel version, and system logs.
NoNewPrivileges=yes restricts another path: the service and its descendants cannot gain privileges through setuid/setgid bits or file capabilities during execve(). It does not prevent all privilege changes outside execve(), nor ensure that damaged system files cannot affect other privileged processes. System updates remain necessary; these settings do not replace vulnerability fixes.
This article does not explore SELinux. The setup is intended for a server shared with friends rather than critical production workloads. These systemd options combine namespaces, cgroups, and seccomp, but do not form a complete tenant security boundary. SELinux/AppArmor can further restrict access, but cannot guarantee protection against every kernel vulnerability either. Mutually untrusted users call for an assessment of stronger isolation based on the threat model.
Of course, this solution has other limitations. For example, when there are many users or special UID allocation requirements that exceed 10000, the port calculation logic may fail. For legitimate needs like scientific computing, the current restrictions may be too strict. This article is only intended as a discussion and reference.
Random thoughts: In the age of containerization and cloud-native, traditional systemd still has many interesting features worth exploring that can accomplish a lot…
Reference: