Add the shared Claude Remote Control setup

Units, settings and setup README for the claude-rc user, which serves my projects' read-only MCP servers to the Claude app (negsplit for now).
This commit is contained in:
Daniele Fucini
2026-11-01 11:06:58 +01:00
parent 51b2bbeff8
commit 3a2d2b7c37
8 changed files with 197 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
# Always-on Remote Control
Reference copy of a machine setup: Claude Code Remote Control running as its own system user `claude-rc`, so my
projects' read-only MCP servers (now: negsplit) can be used from the Claude app on the phone. One user and one
session (`data`) for all projects. The user holds only read-only database logins; Bash runs sandboxed without
network and can't read them (only the MCP servers, which run outside the sandbox, do). Nothing secret in this
folder: the ntfy topic lives only in `/etc/claude-rc-notify.env`.
Needs `claude-code`, `bubblewrap`, `socat` and each project's MCP server installed. Per project:
- negsplit: the installed `negsplit` (with `python-mcp`) and the `garmin_ro` grants from its
`sql/garmin_ro_grants.sql`. A per-user query limit is a useful safety net (the session can lift it):
```
mariadb -u root -p -e "ALTER USER 'garmin_ro'@'localhost' WITH MAX_STATEMENT_TIME 30;"
```
## Setup
Run from this folder. User and folders:
```
doas useradd --system --create-home --home-dir /var/lib/claude-rc --shell /usr/bin/nologin claude-rc
doas chmod 700 /var/lib/claude-rc
doas install -d -o claude-rc -g claude-rc -m 700 /var/lib/claude-rc/.config /var/lib/claude-rc/.claude /var/lib/claude-rc/data /var/lib/claude-rc/data/.claude
doas install -o claude-rc -g claude-rc -m 644 mcp.json /var/lib/claude-rc/data/.mcp.json
doas install -o claude-rc -g claude-rc -m 644 project-settings.json /var/lib/claude-rc/data/.claude/settings.json
doas install -o claude-rc -g claude-rc -m 644 user-settings.json /var/lib/claude-rc/.claude/settings.json
```
`strictAllowlist` must stay in the user settings: in project settings it has no effect.
Each project's config holds only a `[readonly]` section. negsplit (`garmin_ro`):
```
doas install -d -o claude-rc -g claude-rc -m 700 /var/lib/claude-rc/.config/negsplit
doas install -o claude-rc -g claude-rc -m 600 config.toml /var/lib/claude-rc/.config/negsplit/config.toml
```
Log in, trust the folder and enable Remote Control once, interactively:
```
doas -u claude-rc sh -c 'cd /var/lib/claude-rc/data && exec claude auth login'
doas -u claude-rc sh -c 'cd /var/lib/claude-rc/data && exec claude'
doas -u claude-rc sh -c 'cd /var/lib/claude-rc/data && exec claude remote-control --name data --spawn session'
```
ntfy topic for the failure alert (subscribe to it on ntfy.sh), then the units:
```
doas sh -c 'umask 077; printf "NTFY_TOPIC=claude-rc-%s\n" "$(openssl rand -hex 16)" > /etc/claude-rc-notify.env'
doas install -m 644 claude-rc.service claude-rc-notify.service claude-rc-recover.service claude-rc-recover.timer -t /etc/systemd/system/
doas systemctl daemon-reload
doas systemctl enable --now claude-rc.service claude-rc-recover.timer
```
The service restarts with a growing delay (30 s to 10 min); after about 50 minutes of failures it gives up and
sends the alert. The timer restarts it every 3 hours if it gave up, so a long network outage doesn't stop it for
good. After upgrading a project's MCP server, or changing `mcp.json` or the settings, restart `claude-rc.service`
so phone sessions use the new version.
## Adding a project
Add its server to `mcp.json` and `mcp__<server>` to the `allow` list in `project-settings.json` (plus a `Read` rule
for its chart folder, if any), install its `[readonly]` config as above, reinstall the changed files and restart
the service.
## Dead login
```
doas systemctl stop claude-rc.service
doas -u claude-rc sh -c 'cd /var/lib/claude-rc/data && exec claude auth login'
doas systemctl reset-failed claude-rc.service
doas systemctl start claude-rc.service
```
@@ -0,0 +1,13 @@
[Unit]
Description=Notify via ntfy that Claude Remote Control stopped
[Service]
Type=oneshot
DynamicUser=yes
# NTFY_TOPIC=...: root-only (0600), the topic is the only secret
EnvironmentFile=/etc/claude-rc-notify.env
TimeoutStartSec=10min
ExecStart=/usr/bin/curl -fsS -m 30 --retry 5 --retry-delay 60 --retry-all-errors \
-H "Title: Claude Remote Control stopped" -H "Tags: warning" \
-d "%H: claude-rc.service keeps failing (dead login?). Retried every 3 h; fix at home: claude auth login as claude-rc." \
https://ntfy.sh/${NTFY_TOPIC}
@@ -0,0 +1,8 @@
[Unit]
Description=Restart Claude Remote Control if it gave up (e.g. after a long network outage)
[Service]
Type=oneshot
ExecCondition=/usr/bin/systemctl is-failed --quiet claude-rc.service
ExecStart=/usr/bin/systemctl reset-failed claude-rc.service
ExecStart=/usr/bin/systemctl start --no-block claude-rc.service
@@ -0,0 +1,9 @@
[Unit]
Description=Every 3 h, restart Claude Remote Control if it gave up
[Timer]
OnBootSec=1h
OnUnitActiveSec=3h
[Install]
WantedBy=timers.target
+39
View File
@@ -0,0 +1,39 @@
[Unit]
Description=Claude Code Remote Control (read-only MCP servers)
Wants=network-online.target
After=network-online.target mariadb.service
# about 50 min of failed starts (back-off below): give up and notify
StartLimitIntervalSec=1h
StartLimitBurst=8
OnFailure=claude-rc-notify.service
[Service]
User=claude-rc
Group=claude-rc
WorkingDirectory=/var/lib/claude-rc/data
# strips credentials from subprocess environments; on Linux also sandboxes ! shell commands
Environment=CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1
ExecStart=/usr/bin/claude remote-control --name data --spawn session
Restart=always
RestartSec=30s
RestartSteps=4
RestartMaxDelaySec=10min
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/claude-rc
PrivateTmp=yes
NoNewPrivileges=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
CapabilityBoundingSet=
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
# no RestrictNamespaces/MemoryDenyWriteExecute: the Bash sandbox (bubblewrap) needs user namespaces
[Install]
WantedBy=multi-user.target
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"negsplit": {
"command": "negsplit",
"args": ["mcp"]
}
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"permissions": {
"defaultMode": "default",
"disableBypassPermissionsMode": "disable",
"disableAutoMode": "disable",
"allow": [
"mcp__negsplit",
"Read(~/.local/share/negsplit/charts/**)"
],
"deny": [
"Read(~/.config/**)",
"Read(~/.claude/**)",
"Read(~/.claude.json)",
"Edit(~/.config/**)",
"Edit(~/.claude/**)",
"Edit(~/.claude.json)",
"Edit(./.claude/**)",
"Edit(./.mcp.json)",
"WebFetch",
"WebSearch"
]
},
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"allowUnsandboxedCommands": false,
"autoAllowBashIfSandboxed": true,
"filesystem": {
"denyRead": ["~/.config", "~/.claude", "~/.claude.json"],
"denyWrite": ["./.claude", "./.mcp.json"]
},
"network": {
"allowedDomains": []
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"theme": "dark",
"sandbox": {
"network": {
"strictAllowlist": true,
"allowedDomains": []
}
}
}