Every per-feature deep dive: gate setup, anti-spam, honeypot, session recording, JIT grants, alerts, files, recovery, dashboard.
The main README has the quickstart and the elevator pitch; come here when you're configuring a feature.
profile.d only fires for interactive shells, so SFTP/SCP would otherwise slip past CodeGuard. The sftp-gate subcommand plugs that gap: SFTP is denied by default, and the user must grant a single-use approval from an already-authenticated SSH session.
Wire it into /etc/ssh/sshd_config:
Subsystem sftp /usr/local/sbin/codeguard.py sftp-gate
Then systemctl restart ssh. Workflow:
# 1. SSH in (passes CodeGuard via profile.d):
ssh you@host
# 2. Grant a 60-second SFTP window (single-use):
codeguard approve-sftp 60
# 3. From another terminal, within the window:
sftp you@host # ✓ allowed; approval is consumed
sftp you@host # ✗ blocked againApproval state lives in /run/codeguard-sftp-approved (mode 0600) and is auto-deleted on first use or expiry. Each accept / deny is recorded to /var/log/codeguard.log with action, user, IP, and reason.
If the telegram webhook is configured in /etc/codeguard/config.json, enable the bot:
systemctl enable --now codeguard-botNow any unapproved SFTP attempt sends a Telegram message with [✅ Approve] [❌ Deny] buttons. Tap from your phone — sftp-gate waits up to 30 seconds for the verdict and either execs sftp-server or denies. No need to SSH in first.
🔐 CodeGuard SFTP request
Host: vps
User: root
IP: 203.0.113.45
Expires in: 30s
[ ✅ Approve ] [ ❌ Deny ]
Per-approval state lives in /run/codeguard-approvals/<token>.{pending,approved,denied} (mode 0700 directory).
⚠ Tailscale SSH bypasses OpenSSH entirely — including this Subsystem hook. If Tailscale SSH is enabled on the host, disable it (
tailscale set --ssh=false) before relying on the gate.
profile.d only fires for interactive logins, so plain ssh host 'cmd', scp, rsync, and any other non-interactive SSH channel would otherwise slip past CodeGuard. exec-gate is the matching ForceCommand hook for the SFTP Subsystem hook.
Wire it into /etc/ssh/sshd_config:
ForceCommand /usr/local/sbin/codeguard.py exec-gate
Then systemctl restart ssh. Behavior:
- Interactive shell (no
SSH_ORIGINAL_COMMAND) — exec the user's login shell; profile.d/CodeGuard prompt runs as before. internal-sftp/sftp-server— fall through (the Subsystem hook handles approval).- Anything else — require approval: pre-granted window from
codeguard approve-exec, or Telegram inline[✅ Approve] [❌ Deny](same bot daemon as SFTP). The Telegram prompt includes the requested command (truncated to 200 chars) so the approver can see what's being authorized.
⚠ Once
ForceCommandis enabled, all non-interactive automation (CI deploys,rsyncbackups,git over ssh) needs approval. The pragmatic pattern for one-off commands: SSH in interactively, runcodeguard approve-exec 30, then fire the one-shot command from another terminal within the window.
If an attacker captures valid SSH credentials they can otherwise spray ssh host 'cmd' and bombard the approver with Telegram prompts — fishing for an accidental tap. CodeGuard pushes back at three layers:
| layer | trigger | effect |
|---|---|---|
| Rate limit | > N attempts in the window (default 5 / 10 min per IP) | auto-deny, no Telegram message sent |
| Deny cooldown | approver tapped ❌ Deny | that IP is silent for N min (default 5) |
| 🚫 Block 1h button | third inline button on every prompt | banhammer — IP locked out for 1 h |
Tune in /etc/codeguard/config.json:
Admin overrides:
codeguard list-blocked # see who's rate-limited
codeguard unblock 1.2.3.4 # clear state for an IPState lives in /var/lib/codeguard/ratelimit.json (mode 0600).
If someone gets through your SSH password but doesn't know the CodeGuard secret, denying them and disconnecting is the boring outcome. After 3 wrong-secret attempts from the same IP, CodeGuard instead routes the next login into a fake shell — Ubuntu banner, plausible prompt, fake ls/cat/ps/id/uname output, virtual CWD, and a sudo that always says "Sorry, try again." Every command they type is appended to /var/log/codeguard-honeypot.log, and Telegram pings you the moment they start (and again whenever they touch interesting things — sudo, wget, curl, /etc/shadow, id_rsa, nmap, …).
codeguard honeypot list # who's trapped right now
codeguard honeypot add 1.2.3.4 14 # manually route an IP into the trap for 14 days
codeguard honeypot remove 1.2.3.4 # releaseThe fake shell session is capped at 50 commands. On exit (or exit/Ctrl+D), CodeGuard returns exit code 1 so the existing profile.d hook SIGHUPs the parent shell — the attacker doesn't pop back into a real bash, they just see "connection closed".
Every Telegram alert from the honeypot carries a [🚫 Block & Kick] button. Tap it (or run codeguard kick <ip> from a shell) to:
- drop a kick flag at
/run/codeguard-kicks/<ip>— the live fake shell exits on its next loop tick with "Connection reset by peer", - extend the honeypot mark for that IP to 30 days,
- call
fail2ban-client set codeguard banip <ip>so the next connection from that IP is dropped at the firewall.
State at /var/lib/codeguard/honeypot.json (mode 0600); session transcript at /var/log/codeguard-honeypot.log.
codeguard recording on makes every interactive SSH login (including JIT grant sessions) wrap the user's shell in a PTY recorder. Each session is saved as an asciinema v2 JSON file under /var/log/codeguard-sessions/ and shown in the dashboard's Session recordings panel — ▶ play opens an in-browser replay you can scrub.
While the user types, CodeGuard parses each command against a regex catalogue and tags interesting events:
| 🔐 | sudo … |
| 📂 | rm/mv/chmod/chown, > file redirects |
| 📝 | nano/vim/emacs/code |
| ⚙️ | systemctl start/stop/restart/enable |
| 📦 | apt/dnf/pacman/pip install |
| 🌐 | curl/wget/fetch |
| 👥 | useradd/usermod/passwd |
| 🌿 | git commit/push/reset |
| 🔥 | iptables/ufw/nft |
Every flagged command fires a Telegram alert in real time with a [🔚 End session] inline button — you don't have to wait for the session to end to see what they're doing. Events also appear in the player as a click-to-seek timeline.
codeguard recording on # auto-record every SSH login
codeguard recording status # → on / off
codeguard recording off # back to plain login shellAudit log also goes to syslog with tag codeguard-grant-<user> for grant sessions:
journalctl -t codeguard-grant-cg_alice -n 50The Teleport-style "give the contractor 4 hours of staging" workflow, in one command:
codeguard grant alice --hours 4 --mode readonly --path /var/www/htmlThe grant:
- Creates a Linux user
cg_alicewith a one-time random password (printed once on stdout — copy and hand off via Signal/Telegram/etc.). - Adds her to the
cg_grantsgroup so the tight sudoers rule applies (one fixed command, no wildcards). - Generates a per-grant shell wrapper under
/usr/local/lib/codeguard/shells/. - A systemd timer (
codeguard-grant-tick.timer) sweeps every minute and runscodeguard grant tick, whichuserdel -rs anything past its expiry and kills its open sessions. - Telegram alerts on create, on login, and on expire/revoke. The login alert carries a
[🔚 Revoke now]inline button.
| Mode | Allowed commands | Writable? |
|---|---|---|
readonly |
ls, cat, tail, head, less, grep, find, pwd, stat, df, du, free, ps, … |
❌ |
deploy |
readonly + git, systemctl, journalctl, docker, kubectl, rsync, npm, make, ansible, pip, python |
✅ target path only |
full |
plain bash | ✅ |
With bubblewrap installed (auto-installed by install.sh), the grant shell launches the user inside a per-session namespace jail:
/datais the only writable / readable-real-content path (bind-mounted from your--pathargument)/usr,/lib,/bin,/sbinare read-only mounts of the host's system tree (so bash and the allowed binaries work)- Synthetic
/etc/passwd+/etc/group(just root + the cg_* user) — no other users visible - New PID / UTS / IPC / user namespaces;
--die-with-parent no_new_privsis set, so anysudo/ setuid attempt inside the jail is rejected by the kernel- Tab completion cannot enumerate outside the jail — the host filesystem isn't there
What that looks like from the grant user's side:
cg_alice@jail:~$ pwd
/data
cg_alice@jail:~$ ls /etc
group passwd
cg_alice@jail:~$ cat /etc/shadow
cat: /etc/shadow: No such file or directory
cg_alice@jail:~$ cd /var
bash: cd: /var: No such file or directory
cg_alice@jail:~$ sudo whoami
sudo: The "no new privileges" flag is set, which prevents sudo from running as root.
Ubuntu 24.04+ restricts unprivileged user namespaces. After
apt install bubblewrap, also runchmod u+s /usr/bin/bwrap(same approach Flatpak uses). The installer offers to do this;codeguard grant ... --pathwarns + falls back to a soft fence if the setuid bit is missing.
codeguard grant list # show active grants + remaining time
codeguard grant extend alice +2h # add 2 more hours
codeguard grant revoke alice # kill now (userdel -r + kill sessions)
codeguard grant tick # internal: expire-sweep (cron / timer calls this)
codeguard grant is-active cg_alice # exit 0 iff still activeState persists in /etc/codeguard/grants.json (0600). Per-grant shell wrappers + synthetic passwd files live in /usr/local/lib/codeguard/shells/.
The shipped fail2ban filter matches both legacy FAIL lines and the new sftp-gate DENY / exec-gate DENY lines, so any spammer that trips the approval rate limit also trips the jail (default: 3 fails / 10 min → 24 h iptables ban). Tapping 🚫 Block 1h calls fail2ban-client set codeguard banip <ip> directly, so the network drop applies immediately rather than waiting for the next failed attempt. codeguard unblock <ip> reverses both layers.
Edit /etc/codeguard/config.json and set alerts.enabled = true. Use ${VAR} placeholders so credentials live only in /etc/codeguard/env (mode 0400):
"smtp": {
"enabled": true,
"host": "smtp.resend.com",
"port": 465,
"ssl": true,
"user": "resend",
"pass": "${SMTP_PASS}",
"from": "alerts@your-domain.com",
"to": ["you@example.com"]
},
"webhooks": [
{
"name": "telegram",
"enabled": true,
"url": "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body_template": "{\"chat_id\": ${TELEGRAM_CHAT_ID}, \"text\": \"🔐 CodeGuard {event}{geo}\\nHost: {host}\\nUser: {user}\\nIP: {ip}\\nReason: {reason}\"}"
}
]Test:
sudo codeguard alerts-test smtp
sudo codeguard alerts-test telegramSee examples/ for a complete config + env file.
| path | mode | owner | purpose |
|---|---|---|---|
/usr/local/sbin/codeguard.py |
0755 | root | main binary |
/usr/local/sbin/codeguard |
0755 | root | symlink |
/etc/codeguard/config.json |
0600 | root | settings + secret hash |
/etc/codeguard/env |
0400 | root | credentials referenced via ${VAR} |
/etc/codeguard/users/*.json |
0600 | root | per-user mode entries |
/etc/codeguard/grants.json |
0600 | root | active JIT grants + expiry |
/etc/sudoers.d/codeguard-grant |
0440 | root | grant-user sudoers rule (auto-managed) |
/etc/profile.d/codeguard.sh |
0755 | root | login hook |
/usr/local/lib/codeguard/shells/ |
0755 | root | per-grant shell wrappers + synthetic passwd/group |
/usr/local/lib/codeguard/grant-bin/ |
0755 | root | per-mode PATH allowlist (symlinks to allowed binaries) |
/var/log/codeguard.log |
0640 | root | plain-text audit (append-only via chattr +a) |
/var/log/codeguard.jsonl |
0640 | root | JSON Lines audit (SIEM-friendly) |
/var/log/codeguard-honeypot.log |
0640 | root | honeypot session transcripts |
/var/log/codeguard-sessions/*.cast |
0600 | root | asciinema session recordings |
/var/log/codeguard-sessions/*.events.json |
0600 | root | per-recording flagged events |
/var/lib/codeguard/ratelimit.json |
0600 | root | per-IP rate-limit state |
/var/lib/codeguard/honeypot.json |
0600 | root | honeypot IP list (auto + manual) |
/run/codeguard-approvals/ |
0700 | root | per-token approval pending/approved/denied markers |
/run/codeguard-kicks/ |
0700 | root | "kick this IP" flags (live honeypot eject) |
/etc/fail2ban/filter.d/codeguard.conf |
0644 | root | fail2ban filter |
/etc/fail2ban/jail.d/codeguard.conf |
0644 | root | fail2ban jail |
/etc/systemd/system/codeguard-bot.service |
0644 | root | Telegram long-poller |
/etc/systemd/system/codeguard-dashboard.service |
0644 | root | web UI |
/etc/systemd/system/codeguard-grant-tick.timer |
0644 | root | grant expiry sweep (every minute) |
CodeGuard's own gates will block you from ssh host 'cmd' once exec-gate is wired up, so the recovery commands have to run from the VPS provider's web console (DigitalOcean / Hetzner / Linode / Vultr all ship one):
# Option 1 — recovery code at the prompt (XXXX-XXXX format from setup)
ssh root@your-host # then enter your recovery code
# Option 2 — wipe config and re-setup
rm /etc/codeguard/config.json
ssh root@your-host # auto-runs setup on next login
# Option 3 — disable CodeGuard entirely (kills the gate)
rm /etc/profile.d/codeguard.sh
# Also restore stock SFTP if you wired the Subsystem hook:
sed -i 's|^Subsystem sftp /usr/local/sbin/codeguard.py sftp-gate|Subsystem sftp /usr/lib/openssh/sftp-server|' /etc/ssh/sshd_config
systemctl restart sshIf fail2ban banned your laptop's IP while you were recovering:
fail2ban-client unban --all
codeguard unblock <your-ip>- Tailscale SSH bypasses every CodeGuard gate. Tailscale ships its own SSH server inside
tailscaledthat does NOT go through OpenSSHsshd. profile.d, Subsystem and ForceCommand are all skipped. Iftailscale statusshows--sshenabled on the host, you have to eithertailscale set --ssh=falseor rely on Tailscale's own ACLaction: check2FA. - No FIDO2 / WebAuthn yet. TOTP only. WebAuthn is on the roadmap for v1.11.
- Ubuntu 24.04 user-namespace lockdown.
bubblewrapneeds eitherchmod u+s /usr/bin/bwraporsysctl kernel.apparmor_restrict_unprivileged_userns=0. The installer prompts;codeguard grant ... --pathwarns + falls back to soft fence if unfixed. /usr/binis fully visible inside grant jails. bwrap mounts the host's/usrread-only so bash + binaries work. Alice canls /usr/src/linux-headers-*andwhich sudo, but every write fails on the RO mount andsudo/setuid is blocked byno_new_privs. Tighter per-jail bin allowlists are v1.14.- PAM integration is via
pam_exec.so, not a native module — seepam/codeguard.conf. - One-machine product. No federation across hosts yet — each VPS has its own bot, its own grants, its own audit log. Multi-host central approval is v2.0.
codeguard dashboard starts a single-page HTTP UI: active SSH sessions, honeypot list with remaining time, rate-limited / blocked IPs, recent audit log, full honeypot transcript — auto-refreshing every 5 s. One-click end-session, release, kick, unblock, and SFTP/exec approval-window grants.
Localhost only by default. Reach from your laptop via SSH tunnel:
# On the server
sudo systemctl enable --now codeguard-dashboard
# On your laptop
ssh -L 8088:127.0.0.1:8088 root@your-host
# then open http://127.0.0.1:8088Real HTML login page — secret + TOTP + Telegram approval, then a cg_session cookie (1 h, HttpOnly, SameSite=Strict). Failed logins are rate-limited per IP (5 / 10 min → cooldown). Logout button revokes the session both server-side and in the browser. No more permanent Basic-Auth credentials sitting in the browser cache.
To expose on Tailscale instead, override the bind address:
ExecStart=/usr/local/sbin/codeguard.py dashboard 8088 --bind 100.x.x.x
⚠ Do NOT bind the dashboard to
0.0.0.0on a publicly reachable host. Only Basic auth + your CodeGuard secret stand between an attacker and admin APIs (end-session, kick, grant approvals, …). Keep it on localhost behind an SSH tunnel, on Tailscale, or behind a reverse proxy with mTLS / IP allowlist.