Want to run Gas Town on a Linux machine with a pretense of isolation? You could probably run Docker if you're sensible, but I decided to be foolish and build a complete VM-like setup on top of systemd-nspawn with:
- A mapped, shared
gtdirectory. - A network firewall to isolate it from your local net, assuming you use private networking IDs. It should be able to look stuff up on the internet, but probably can't talk to internal stuff.
- A bunch of container namespacing support which seemed like a good idea.
The instructions below are by Claude Code Sonnet 4.5, which also did 80% of the troubleshooting. There may be a missing step or two somewhere. Use at your own risk. This is basically a jury-rigged amateur zoo for observing 10-30 agents. The threat models is "well-meaning but overenthusiastic."
This guide documents the setup of a systemd-nspawn container named "gastown" with:
- Ubuntu 24.04 (noble)
- User namespacing for security isolation
- Shared directory with UID mapping
- Isolated network with internet access
- Optional firewall rules for network isolation
- Port forwarding (8080:8080)
sudo apt-get install debootstrap systemd-container# Bootstrap the container
sudo debootstrap --include=systemd,dbus,sudo,vim,wget,curl noble /var/lib/machines/gastown http://archive.ubuntu.com/ubuntu/# Set root password
sudo systemd-nspawn -D /var/lib/machines/gastown passwd
# Set machine ID
sudo systemd-nspawn -D /var/lib/machines/gastown systemd-machine-id-setup# Shell into the container
sudo machinectl shell gastown
# Inside the container:
useradd -m -s /bin/bash gastown
passwd gastown
usermod -aG sudo gastown
exitCreate /etc/systemd/nspawn/gastown.nspawn:
[Exec]
Boot=yes
Hostname=gastown
[Files]
Bind=/home/USERNAME/gt:/home/gastown/gt:idmap
[Network]
Private=yes
VirtualEthernet=yes
Port=tcp:8080:8080Key point: The :idmap suffix on the bind mount automatically maps host UID/GID to container UID/GID, making files owned by the Ubuntu initial user (UID 1000) on the host appear as owned by gastown (UID 1000) in the container.
The container needs network configuration to access the internet and perform DNS resolution.
sudo systemctl enable systemd-networkd
sudo systemctl start systemd-networkdNote: This will only manage container interfaces (ve-*), not your main network interfaces.
Create /etc/systemd/network/80-container-ve.network:
[Match]
Name=ve-*
Driver=veth
[Network]
Address=10.0.85.1/24
IPMasquerade=ipv4
DHCPServer=yes
IPForward=yes
[DHCPServer]
PoolOffset=100
PoolSize=50
EmitDNS=yes
DNS=8.8.8.8 1.1.1.1This configuration:
- Matches all virtual ethernet interfaces for containers (ve-*)
- Assigns 10.0.85.1/24 to the host side
- Enables NAT/masquerading for internet access
- Runs a DHCP server providing IPs 10.0.85.100-149
- Provides public DNS servers (Google 8.8.8.8, Cloudflare 1.1.1.1)
Create /var/lib/machines/gastown/etc/systemd/network/80-container-host0.network:
[Match]
Virtualization=container
Name=host0
[Network]
DHCP=yesThis configures the container to obtain its IP address, gateway, and DNS via DHCP.
Critical step: Enable systemd-networkd and systemd-resolved inside the container:
# Enable and start systemd-networkd in container
machinectl shell gastown /bin/systemctl enable --now systemd-networkd
# Enable and start systemd-resolved in container for DNS
machinectl shell gastown /bin/systemctl enable --now systemd-resolvedReload systemd-networkd on the host and restart the container:
# Restart systemd-networkd to pick up new configuration
sudo systemctl restart systemd-networkd
# Wait for network to stabilize
sleep 3Important: If you have Docker installed, this step is required because Docker sets the FORWARD chain policy to DROP. The firewall rules allow internet access while blocking local network access.
Create /etc/systemd/system/gastown-firewall.service:
[Unit]
Description=Firewall rules for gastown container
After=network.target systemd-nspawn@gastown.service
Wants=systemd-nspawn@gastown.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c 'iptables -I FORWARD 1 -o ve-gastown -m state --state RELATED,ESTABLISHED -j ACCEPT; \
iptables -I FORWARD 2 -i ve-gastown -d 192.168.0.0/16 -j REJECT --reject-with icmp-port-unreachable; \
iptables -I FORWARD 3 -i ve-gastown -d 172.16.0.0/12 -j REJECT --reject-with icmp-port-unreachable; \
iptables -I FORWARD 4 -i ve-gastown -d 10.0.0.0/8 ! -s 10.0.85.0/24 -j REJECT --reject-with icmp-port-unreachable; \
iptables -I FORWARD 5 -i ve-gastown -d 169.254.0.0/16 -j REJECT --reject-with icmp-port-unreachable; \
iptables -I FORWARD 6 -i ve-gastown -j ACCEPT'
ExecStop=/bin/bash -c 'iptables -D FORWARD -o ve-gastown -m state --state RELATED,ESTABLISHED -j ACCEPT; \
iptables -D FORWARD -i ve-gastown -d 192.168.0.0/16 -j REJECT --reject-with icmp-port-unreachable; \
iptables -D FORWARD -i ve-gastown -d 172.16.0.0/12 -j REJECT --reject-with icmp-port-unreachable; \
iptables -D FORWARD -i ve-gastown -d 10.0.0.0/8 ! -s 10.0.85.0/24 -j REJECT --reject-with icmp-port-unreachable; \
iptables -D FORWARD -i ve-gastown -d 169.254.0.0/16 -j REJECT --reject-with icmp-port-unreachable; \
iptables -D FORWARD -i ve-gastown -j ACCEPT'
[Install]
WantedBy=multi-user.targetEnable the firewall service:
sudo systemctl daemon-reload
sudo systemctl enable gastown-firewall.service
sudo systemctl start gastown-firewall.serviceWhat this does:
- ✅ Rule 1: Allows return traffic from internet to container (ESTABLISHED,RELATED)
- ❌ Rules 2-5: Block access to private networks (192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8 except container subnet, 169.254.0.0/16)
- ✅ Rule 6: Allow all outbound traffic from container (internet access)
Result:
- ✅ Container can access the internet
- ❌ Container cannot access local network (192.168.0.0/16)
- ❌ Container cannot access other private networks
Note: Rules are inserted at the top of the FORWARD chain (positions 1-6) to ensure they are evaluated before Docker's rules. This is critical when Docker is installed.
# Enable auto-start
sudo machinectl enable gastown
# Start the container
sudo machinectl start gastown
# Verify it's running
machinectl listAfter setup, verify internet connectivity and DNS resolution:
# Check host-side interface has IP
ip addr show ve-gastown
# Check container-side interface has IP from DHCP
machinectl shell gastown /bin/ip addr show host0
# Test internet connectivity (IP-based)
machinectl shell gastown /bin/ping -c 2 8.8.8.8
# Test DNS resolution
machinectl shell gastown /bin/ping -c 2 google.com
# Test HTTPS connectivity
machinectl shell gastown /bin/curl -I https://google.com
# Verify local network is blocked (should fail)
machinectl shell gastown /bin/ping -c 2 192.168.86.1Expected results:
- ✅ Pings to 8.8.8.8 should succeed
- ✅ Pings to google.com should succeed (DNS working)
- ✅ HTTPS requests should work
- ❌ Pings to 192.168.86.1 (or your router IP) should fail
# Start container
sudo machinectl start gastown
# Stop container
sudo machinectl stop gastown
# Restart container
sudo machinectl restart gastown
# Check status
machinectl list
sudo machinectl status gastown# Shell as root
sudo machinectl shell gastown
# Shell as gastown user
sudo machinectl shell --uid=gastown gastown
# Run a single command as gastown
sudo machinectl shell --uid=gastown gastown /bin/bash -c "command here"# View recent logs
sudo journalctl -u systemd-nspawn@gastown.service -n 50
# Follow logs in real-time
sudo journalctl -u systemd-nspawn@gastown.service -fThe default systemd-nspawn service uses the -U flag, which enables user namespacing:
- Container UIDs are shifted into a high range (e.g., 1776025600+)
- UID 0 (root) in container maps to 1776025600 on host
- UID 1000 (gastown) in container maps to 1776025601 on host
- This provides security isolation—container root can't affect host
The idmap bind mount option:
- Maps host UID 1000 (initial user) to container UID 1000 (gastown)
- Makes shared files readable/writable by both users
- Works transparently with user namespacing
Container Network Namespace:
Private=yes: Container gets its own network namespaceVirtualEthernet=yes: Creates a virtual ethernet pair (veth)- Host side:
ve-gastown - Container side:
host0
- Host side:
Port=tcp:8080:8080: Forwards port 8080 from container to host
systemd-networkd Setup:
- Host: systemd-networkd manages
ve-gastowninterface- Host-side interface (
ve-gastown) gets 10.0.85.1/24 - DHCP server runs on host, providing IPs to containers (10.0.85.100-149)
IPMasquerade=ipv4enables NAT for internet access- Public DNS servers (8.8.8.8, 1.1.1.1) provided via DHCP
- Host-side interface (
- Container: systemd-networkd must be enabled inside the container
- Container-side interface (
host0) gets IP via DHCP - systemd-resolved handles DNS queries
- Container-side interface (
Firewall Isolation:
- Required when Docker is installed (Docker sets FORWARD policy to DROP)
- Allows internet access from container
- Blocks container from accessing local network (192.168.0.0/16)
- Prevents lateral movement to other private networks
- Rules inserted at top of FORWARD chain (positions 1-6) to take precedence over Docker rules
# Check detailed logs
sudo journalctl -u systemd-nspawn@gastown.service -n 100
# Verify configuration syntax
sudo systemd-analyze verify systemd-nspawn@gastown.service- Ensure you're using
:idmapsuffix on the bind mount - Verify both host and container users have UID 1000
- Restart container after config changes
Common causes:
-
systemd-networkd not enabled in container - Most common issue!
# Check status in container machinectl shell gastown /bin/systemctl status systemd-networkd # If disabled, enable it machinectl shell gastown /bin/systemctl enable --now systemd-networkd machinectl shell gastown /bin/systemctl enable --now systemd-resolved
-
Firewall rules missing or incorrect - Required when Docker is installed
# Check FORWARD policy (if DROP, firewall rules are required) sudo iptables -L FORWARD -n -v | head -2 # Check if firewall service is running sudo systemctl status gastown-firewall.service # View current FORWARD rules sudo iptables -L FORWARD -n -v --line-numbers
-
Network interface diagnostic commands:
# Check if virtual ethernet interface exists and is UP ip link show ve-gastown # Check host-side interface has IP address ip addr show ve-gastown # Check container-side interface machinectl shell gastown /bin/ip addr show host0 # Test internet connectivity (IP-based) machinectl shell gastown /bin/ping -c 4 8.8.8.8 # Test DNS resolution machinectl shell gastown /bin/ping -c 4 google.com # Check DNS configuration in container machinectl shell gastown /bin/resolvectl status # Verify NAT/masquerading is enabled sudo iptables -t nat -L -n -v | grep MASQUERADE # Check systemd-networkd status on host sudo systemctl status systemd-networkd # View networkd logs sudo journalctl -u systemd-networkd -n 50
# Stop and disable container
sudo machinectl stop gastown
sudo machinectl disable gastown
# Stop and disable firewall (if configured)
sudo systemctl stop gastown-firewall.service
sudo systemctl disable gastown-firewall.service
# Remove container root filesystem
sudo rm -rf /var/lib/machines/gastown
# Remove container configuration
sudo rm /etc/systemd/nspawn/gastown.nspawn
# Remove network configuration (optional - affects all ve-* containers)
sudo rm /etc/systemd/network/80-container-ve.network
# Remove firewall service
sudo rm /etc/systemd/system/gastown-firewall.service
# Reload systemd
sudo systemctl daemon-reload