概要
さくらのVPS上のUbuntu 26.04 LTS (Resolute Raccoon) に、Nous ResearchのオープンソースAIエージェント HermesAgent を常駐させ、Telegram・Slack・Discord(ボイスチャンネルでの音声対話を含む)から24時間使えるようにするまでの全手順です。
LLM推論・音声認識・音声合成にはすべて さくらのAI Engine(OpenAI互換API)を使い、エージェントが扱うデータの処理を国内で完結させます。
信頼できない入力(Web・受信メッセージ等)を常時取り込む任意コマンド実行エージェントを安心して放置できるよう、受信ポートゼロ運用・sudo無し専用ユーザー+rootless Dockerサンドボックスによる隔離・Falco/Suricataによる侵害検知・更新の全自動化まで含めた「無人運用」をゴールにしています。
なお、本記事の手順の調査・検証と執筆には Devin を使用しています。
前提環境
- さくらのVPS: vCPU 3コア / メモリ2GB / SSD 200GB / インターネット100Mbps共有回線。
リソース系の設定値(4章のsysctl・7章のSuricata・8章のコンテナ資源制限等)はこのスペックを前提に選定している - OS: Ubuntu 26.04 LTS (Resolute Raccoon)
- HermesAgent: v0.19.0(2026-07-20リリース)で動作確認
- さくらのAI Engine: LLMは
preview/Kimi-K2.6(フォールバックgpt-oss-120b)、STTはwhisper-large-v3-turbo、TTSはVOICEVOXエンジンを使用(設定は8章) - プランは基盤モデル無償プランのままで構築・動作できる(本文の設定はプランに依存しない)。
ただし無償枠(チャット補完はモデルごと月3,000リクエスト)超過後はレート制限がかかるため、ツール呼び出し1回=1リクエストを消費する無人常用で応答を止めたくない場合は従量課金プランを推奨する(8章)
本文中のIPアドレス・管理ユーザー名(kotonoha)・各種IDは例示用の値に置き換えてあります
(IPアドレスは文書用アドレスの 203.0.113.10 / 2001:db8:0:1:203:0:113:10)。
ご自身の環境の値に読み替えてください。
構成の全体像
サービス間の構成
VPSの受信ポートはSSHのみで、メッセージング連携・LLM API・DNSを含む他の通信はすべてVPS発のアウトバウンド接続。
ユーザーは各メッセージングサービス経由でエージェントと対話する(構築・管理は手元の端末からSSHで行い、8章のダッシュボードもSSHポートフォワーディング経由で使う)。
図の双方向矢印はメッセージの流れで、TCP接続自体はすべてVPS側から確立する。
VPS内部の構成
sudo可の管理ユーザーとsudo不可のhermesユーザーを分離し、エージェントが実行するコマンドはさらにrootless Docker上のサンドボックスコンテナへ隔離する。
root常駐の検知層(Falco・Suricata)がホスト全体を監視し、更新・バックアップ系はsystemdタイマーで自動化する。
1. OSインストール
ISOイメージ
GA直後の初期不具合を避けたい場合は26.04.1(2026-08-04リリース予定)以降のISOを使用する。
ダウンロード後にチェックサムを検証する。
$ curl -fsSLO https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso
$ curl -fsSLO https://releases.ubuntu.com/26.04/SHA256SUMS
$ sha256sum -c SHA256SUMS --ignore-missing
aptミラー設定(さくらインターネット)
さくらのVPS内からは同社ミラーが高速なため、インストーラーのミラー設定を以下へ向ける。
https://ftp.sakura.ad.jp/pub/linux/ubuntu/
aptは署名検証を行うためhttpでも改竄は検出できるが、さくらのミラーはHTTPSに対応しているのでhttpsを使用する。
2. OS初期設定
Ubuntu Pro有効化
pro attach でアタッチする(個人利用は5台まで無料)。
ESM (esm-infra / esm-apps) により対象パッケージのセキュリティ更新期間が延長される。
Livepatchはカーネル脆弱性修正を再起動なしで実行中カーネルへ適用する機能(canonical-livepatch snapとして導入され、以後snapdが自動更新する)。
$ sudo pro attach
$ pro security-status
$ sudo pro enable livepatch
アップデート
アタッチ直後に更新することで、ESM対象の修正も含めて適用される。
$ sudo apt update
$ sudo apt full-upgrade
ファイアウォール有効化
デフォルトポリシーを明示する(deny incoming / allow outgoing)。
$ sudo apt install ufw vim-tiny
$ sudo ufw default deny incoming
$ sudo ufw default allow outgoing
$ sudo ufw enable
$ sudo ufw status verbose
自動更新 (unattended-upgrades)
Ubuntu Serverでは標準で有効だが、状態を確認しておく。
$ sudo apt install unattended-upgrades needrestart
$ systemctl status unattended-upgrades
$ cat /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
既定の許可オリジンはリリース時点・-security・ESMのみ。
無人運用のため-updatesとサードパーティリポジトリ(Falco。7章)を対象へ加え、再起動要求時の自動再起動と不要依存の自動削除も有効化する。
50unattended-upgradesはパッケージ更新で上書きされうるconffileのため直接編集せず、drop-inで追記する(aptの設定リストはファイルを跨いで加算される):
$ sudo vi /etc/apt/apt.conf.d/52unattended-upgrades-local
// Extend beyond the default release/-security/ESM origins
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-updates";
};
// Third-party apt repositories (Falco), matched by archive site
Unattended-Upgrade::Origins-Pattern {
"site=download.falco.org";
};
// Reboot automatically when /run/reboot-required appears
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
// Autoremove no-longer-needed dependencies (e.g. old kernels)
Unattended-Upgrade::Remove-Unused-Dependencies "true";
ライブラリ更新後に古いライブラリを掴んだままのサービスの再起動(needrestart)も自動化する:
$ sudo vi /etc/needrestart/conf.d/50-autorestart.conf
# Restart outdated services automatically after library upgrades
$nrconf{restart} = 'a';
設定を検証する(許可オリジン一覧と対象パッケージが表示される):
$ sudo unattended-upgrade --dry-run --debug
- Automatic-Reboot-Timeはローカル時刻。
timedatectlでタイムゾーンを確認しておく
(JST運用ならsudo timedatectl set-timezone Asia/Tokyo) - 自動再起動でHermesAgentの実行中セッションは中断されるが、lingering(7章)によりゲートウェイ・rootless Dockerとも起動時に自動復帰する(サンドボックスコンテナ内のバックグラウンドプロセスは消える)。Livepatch有効時は再起動を要する更新自体が減る
シリアルコンソールとzswap (GRUB)
console=ttyS0はさくらのVPSのシリアルコンソール用。
$ sudo vi /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="console=ttyS0,115200 console=tty0 noresume consoleblank=0 zswap.enabled=1 zswap.compressor=zstd zswap.max_pool_percent=20 zswap.shrinker_enabled=Y"
-
zswap.zpoolの指定は不要(zbud/z3foldはカーネル6.15までに削除され、その後zpool層自体も廃止。zsmallocが唯一のアロケータのため) -
zswap.shrinker_enabled=Y(既定N): 圧縮プールに滞留したコールドページをスワップへproactiveに書き戻し、プールを温かいページのために空ける
$ sudo update-grub
再起動後に反映を確認する。
$ sudo reboot
$ cat /proc/cmdline
$ grep -r . /sys/module/zswap/parameters/
基本的なソフトとSSHサーバのインストール
$ sudo apt install tmux acpid apparmor apparmor-profiles irqbalance logrotate openssh-server
- tmux: SSH切断に耐える作業セッション(長時間の手動メンテ用)
- acpid: コントロールパネルからの電源操作(ACPIイベント)に正常シャットダウンで応答する
- apparmor / apparmor-profiles: 強制アクセス制御。本体(7章のrootlesskitプロファイルの提供元)はUbuntu標準導入だが明示し、追加プロファイル集も入れる
- irqbalance: 割り込み処理を3コアへ分散する
- logrotate: journald管理外のプレーンログ(Suricata等)の肥大化防止
3. SSH強化
SSHポートは本章の最後(強化設定の検証後)まで開放しない。
ここまでの作業はさくらのVPSのコンソール経由で行い、パスワード認証が有効なままポートが開いている時間を作らない。
tcp_wrappersによるアクセス制限(多層防御)
$ sudo vi /etc/hosts.allow
sshd: ALL
$ sudo vi /etc/hosts.deny
ALL: ALL
注: 上流OpenSSHはtcp_wrappers対応を削除済みだが、UbuntuのsshdはDebian/Ubuntuパッチにより引き続きlibwrap対応している。上流にない機能のため、主防御はあくまでパケットフィルターとufwとする。
鍵認証のみ許可する
$ vi ~/.ssh/authorized_keys
sshdの設定は「先に読み込まれた値が勝つ」ため、Include(sshd_config先頭)で辞書順最初に読まれるようファイル名を 00- とする。既存drop-in(50-cloud-init.conf等)にPasswordAuthentication yes が残っていないかも確認する。
$ ls /etc/ssh/sshd_config.d/
$ sudo vi /etc/ssh/sshd_config.d/00-hardening.conf
# Authentication: public key only, single authorized_keys file
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
KbdInteractiveAuthentication no
HostbasedAuthentication no
KerberosAuthentication no
GSSAPIAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AuthorizedKeysFile .ssh/authorized_keys
AllowUsers kotonoha
UsePAM yes
StrictModes yes
# Host key: ed25519 only (all modern clients support it)
HostKey /etc/ssh/ssh_host_ed25519_key
# Listen only on this host's static addresses
ListenAddress 203.0.113.10
ListenAddress 2001:db8:0:1:203:0:113:10
# Forwarding: allow local forwards to the loopback dashboard only (section 8)
AllowAgentForwarding no
AllowTcpForwarding yes
PermitOpen localhost:9119 127.0.0.1:9119
PermitListen none
X11Forwarding no
# Brute-force / session limits
MaxAuthTries 3
LoginGraceTime 20
MaxStartups 10:30:100
# Liveness: encrypted application-layer keepalive instead of spoofable
# TCP-level keepalive
TCPKeepAlive no
ClientAliveInterval 20
ClientAliveCountMax 30
ホスト鍵の再生成
HostKeyディレクティブでed25519のみを使用するため、再生成もed25519だけでよい。
$ sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ''
$ sudo rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub \
/etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_rsa_key.pub
設定の検証と反映
設定ミスによる締め出しを防ぐため、反映確認まで既存セッション(またはVNCコンソール)を維持する。
sshd -t が /run/sshd 不存在で失敗する場合は sudo mkdir -p /run/sshd を先に実行する。
$ sudo sshd -t
$ sudo systemctl daemon-reload
$ sudo systemctl restart ssh
$ sudo sshd -T | grep -Ei '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication|allowusers|allowtcpforwarding|permitopen|permitlisten|listenaddress|hostkey)'
ポート開放とレート制限
強化設定の反映を確認できたら、SSHポートをレート制限付きで開放する。
$ sudo ufw limit OpenSSH
$ sudo ufw status verbose
4. カーネルチューニング
sysctl
/etc/sysctl.confの直接編集はパッケージ更新と衝突しうるため、drop-inを使用する。
$ sudo vi /etc/sysctl.d/99-local.conf
# ---------------------------------------------------------------------------
# Security: anti-spoofing / ICMP
# ---------------------------------------------------------------------------
net.ipv4.conf.all.rp_filter = 1 # strict reverse path (systemd default: 2 loose)
net.ipv4.conf.default.rp_filter = 1
net.ipv4.tcp_syncookies = 1 # Default:1
net.ipv4.icmp_ignore_bogus_error_responses = 1 # Default:1
net.ipv4.icmp_echo_ignore_broadcasts = 1 # Default:1
net.ipv4.icmp_echo_ignore_all = 0 # Default:0 (keep answering ping)
# ---------------------------------------------------------------------------
# Security: reject redirects / source routing
# (pinned to 0 explicitly, regardless of kernel/distro defaults)
# ---------------------------------------------------------------------------
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
# ---------------------------------------------------------------------------
# Security: kernel attack surface reduction
# ---------------------------------------------------------------------------
kernel.sysrq = 0 # Default:176; disable all SysRq
kernel.dmesg_restrict = 1 # Default:1 (Ubuntu); pin explicitly
kernel.kptr_restrict = 2 # Default:1
kernel.yama.ptrace_scope = 1 # Default:1 (Ubuntu); pin explicitly
kernel.unprivileged_bpf_disabled = 1 # Default:2; 1 = locked until reboot
kernel.kexec_load_disabled = 1 # Default:0; one-way until reboot
kernel.io_uring_disabled = 2 # Default:0; 2 = disabled for all processes
kernel.apparmor_restrict_unprivileged_userns = 1 # Default:1 (Ubuntu); pin explicitly
# ---------------------------------------------------------------------------
# Stability: self-recovery / memory
# ---------------------------------------------------------------------------
kernel.panic = 60 # Default:0 (auto-reboot 60s after panic)
kernel.panic_on_oops = 1 # Default:0 (escalate oops to panic -> auto-reboot)
vm.swappiness = 60 # Default:60 (keep; do not starve zswap)
# ---------------------------------------------------------------------------
# Performance: congestion control / qdisc
# ---------------------------------------------------------------------------
net.core.default_qdisc = fq # Default:fq_codel
net.ipv4.tcp_congestion_control = bbr # Default:cubic
# ---------------------------------------------------------------------------
# Performance: TCP behavior
# ---------------------------------------------------------------------------
net.ipv4.tcp_timestamps = 1 # Default:1 (required by tcp_tw_reuse; PAWS/RTT)
net.ipv4.tcp_mtu_probing = 1 # Default:0 (recover from ICMP black holes)
net.ipv4.tcp_slow_start_after_idle = 0 # Default:1 (keep cwnd across idle)
net.ipv4.tcp_no_metrics_save = 1 # Default:0
net.ipv4.tcp_fastopen = 1 # Default:1 (client-side TFO)
net.ipv4.tcp_window_scaling = 1 # Default:1
net.ipv4.tcp_moderate_rcvbuf = 1 # Default:1
net.ipv4.tcp_sack = 1 # Default:1
net.ipv4.tcp_syn_retries = 3 # Default:6
net.ipv4.tcp_synack_retries = 3 # Default:5
net.ipv4.tcp_tw_reuse = 1 # Default:2 (loopback only)
net.ipv4.tcp_rfc1337 = 1 # Default:0
net.ipv4.tcp_fin_timeout = 30 # Default:60
net.ipv4.tcp_keepalive_time = 600 # Default:7200
net.ipv4.tcp_keepalive_intvl = 10 # Default:75
net.ipv4.tcp_keepalive_probes = 6 # Default:9
net.ipv4.ip_local_port_range = 32768 65535 # Default:32768 60999 (Sakura: 32768 65535)
# ---------------------------------------------------------------------------
# Performance: queues / buffers (sized for 2GB RAM / 100Mbps shared link)
# ---------------------------------------------------------------------------
net.core.netdev_max_backlog = 1000 # Default:1000; pin explicitly
net.core.somaxconn = 4096 # Default:4096; pin explicitly
net.ipv4.tcp_max_syn_backlog = 1024 # Default:memory-scaled (min 128)
net.ipv4.tcp_max_tw_buckets = 16384 # Default:memory-dependent
net.core.rmem_max = 8388608 # Default:212992
net.core.wmem_max = 8388608 # Default:212992
net.ipv4.tcp_rmem = 4096 212992 8388608 # Default:4096 131072 6291456
net.ipv4.tcp_wmem = 4096 212992 8388608 # Default:4096 16384 4194304
$ sudo sysctl --system
設定意図:
- 明示固定の方針: カーネルデフォルトと同値の行も含めて記載し、ディストリビューションやカーネル更新による既定値の変動(ドリフト)から構成を守る
- BBR + fq: 輻輳制御をBBRにし、ペーシングを行うfqと組み合わせる。txqueuelenを増やす旧来のチューニングはbufferbloat(遅延増大)を招くため行わない
- tcp_slow_start_after_idle=0: LLM APIのストリーミングやTelegramのlong pollingのような「間欠的に使う長寿命接続」でアイドル後も輻輳ウィンドウを維持し、再開直後の転送を速くする
- tcp_timestamps=1: PAWS保護とRTT計測に必要。tcp_tw_reuseの動作もタイムスタンプが前提(0にするとuptime推測は防げるが失うものの方が大きい)
- tcp_keepalive_time=600: 短すぎる値は全アイドル接続に過剰なプローブを発生させ、デフォルトの7200秒は死活検出が遅すぎるためのバランス
- tcp_mtu_probing=1: ICMPブラックホール環境でのMTU問題を自動回復
- panic_on_oops=1 + panic=60: 無人サーバーの自己復旧。カーネル異常(oops)をパニックへ昇格させ、60秒後に自動再起動する
- ptrace_scope=1 / dmesg_restrict=1 / kptr_restrict=2 / secure_redirects=0: 情報漏洩・横移動系のハードニング(Ubuntuはptrace_scope=1が既定。明示して固定する)
- unprivileged_bpf_disabled=1 / kexec_load_disabled=1: 攻撃面の一方向ロック。再起動まで解除できなくなるが、本ホストではいずれの機能も使用しない
- io_uring_disabled=2: io_uringはローカル権限昇格CVEの多発領域で、本ホストの構成要素
(エージェント・rootless Docker・sshd)はいずれも使用しないため全面無効化する
(Dockerの既定seccompプロファイルもコンテナ内のio_uringを遮断している) - apparmor_restrict_unprivileged_userns=1: Ubuntu既定(24.04以降)の明示固定。
7章のrootless DockerはAppArmorプロファイル(rootlesskit)経由でuserns権限を得る前提 - rmem/wmem上限8MB: 100Mbps共有回線×RTT200ms級のBDP(帯域遅延積)は約2.5MBで、8MBはその3倍強。
上限は自動チューニング(tcp_moderate_rcvbuf)の天井であり通常時の消費は増えないが、メモリ2GBの本機ではソケットあたりの最悪値を有界化しておく - backlog系(somaxconn等)は受信面がSSHとループバック待受のみの本ホストでは既定値相当の控えめな値に固定し、過大な上限によるSYNフラッド時のメモリ消費を避ける
ioscheduler
さくらのVPSのvirtio-blkディスクはSSDバックエンドのため、rotational=0を強制しスケジューラをnoneにする(udevの = は代入)。
$ sudo vi /etc/udev/rules.d/60-ioschedulers.rules
ACTION=="add|change", KERNEL=="vd[a-z]", ATTR{queue/rotational}="0", ATTR{queue/scheduler}="none"
5. ネットワーク設定
IPアドレスとDNS (netplan)
DNSにはQuad9(9.9.9.9系)を使用する。マルウェアC2・フィッシング等の既知悪性ドメインを解決段階で遮断するフィルタ付きパブリックリゾルバで、後述「DNS送信の強制」で迂回を塞ぐことで境界型のドメインフィルタとして機能する。
cloud-initによるネットワーク設定の上書きを無効化しておく。
$ sudo vi /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
network: {config: disabled}
$ sudo vi /etc/netplan/50-cloud-init.yaml
network:
version: 2
ethernets:
ens3:
addresses:
- "203.0.113.10/23"
- "2001:db8:0:1:203:0:113:10/64"
nameservers:
addresses:
- 2620:fe::fe
- 2620:fe::9
- 9.9.9.9
- 149.112.112.112
search: []
routes:
- to: "default"
via: "203.0.112.1"
- to: "default"
via: "fe80::1"
dhcp4: false
dhcp6: false
accept-ra: false
optional: false
-
link-local: [ ]は設定しないこと。IPv6リンクローカルアドレスが消え、リンクローカルのデフォルトゲートウェイ(fe80::1)へのNDP/経路解決が不安定になる。
netplanのデフォルト(IPv6リンクローカルのみ有効)が正しい状態 - accept-ra: false でRA/SLAACによる自動設定を無効化(固定IP運用)
$ sudo chmod 600 /etc/netplan/50-cloud-init.yaml
$ sudo netplan generate
$ sudo netplan try # auto-rollback after 120s unless confirmed
$ sudo netplan apply
疎通確認:
$ ip -6 route show default
$ ping -4 -c 3 www.google.com
$ ping -6 -c 3 www.google.com
(オプション) DNS over TLS
経路上のDNS盗聴・改竄対策。netplanの nameservers: を削除し、resolvedのグローバル設定に一本化する。
$ sudo mkdir -p /etc/systemd/resolved.conf.d
$ sudo vi /etc/systemd/resolved.conf.d/10-dot.conf
[Resolve]
DNS=2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net 9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net
DNSOverTLS=yes
DNSSEC=allow-downgrade
$ sudo systemctl restart systemd-resolved
$ resolvectl status
DNS送信の強制 (ufw)
Quad9による悪性ドメイン遮断は「リゾルバとしてQuad9を通ること」が前提であり、侵害されたプロセスが別のリゾルバ(8.8.8.8等)や独自のDoTサーバーへ直接問い合わせると迂回されてしまう。
ufwの送信ルールでDNS系ポート(53・853)の宛先をQuad9の4アドレスに限定し、サンドボックス内を含む全プロセスにQuad9経由の名前解決を強制する。
$ for ip in 9.9.9.9 149.112.112.112 2620:fe::fe 2620:fe::9; do
sudo ufw allow out proto udp to "$ip" port 53 comment 'DNS to Quad9 only'
sudo ufw allow out proto tcp to "$ip" port 53 comment 'DNS to Quad9 only'
sudo ufw allow out proto tcp to "$ip" port 853 comment 'DoT to Quad9 only'
done
$ sudo ufw deny out 53 comment 'block DNS bypass'
$ sudo ufw deny out 853 comment 'block DoT/DoQ bypass'
$ sudo ufw status numbered
動作確認(TCP接続の可否で判定する):
$ resolvectl query www.google.com # resolved -> Quad9: OK
$ nc -zw3 9.9.9.9 853 && echo allowed # DoT to Quad9
$ nc -zw3 1.1.1.1 853 || echo blocked # DoT bypass attempt
$ nc -zw3 8.8.8.8 53 || echo blocked # plain DNS bypass attempt
- ufwのユーザールールは登録順に評価されるため、Quad9への許可→ポート全体の拒否の順で登録する(
deny out 53はUDP/TCP両方・全宛先に一致する) - アプリの名前解決はループバック上のstubリゾルバ(127.0.0.53)経由のため影響を受けない(ufwはループバックを既定で許可)。
resolvedからQuad9への通信は、平文53(本章の基本設定)・DoT 853(前節のオプション)のどちらの構成でも許可済み - サンドボックス(7章のrootless Docker)内のDNSもslirp4netns経由でホスト側の解決にフォワードされる。
コンテナ内から外部リゾルバへ直接向かうパケットも、hermesユーザーの通常の送信としてこのルールで遮断される - 853はUDP(DNS over QUIC)も含めて拒否する。Quad9向けはresolvedが使うDoT(TCP)のみ許可
-
残余リスク: DoH (DNS over HTTPS)。443/tcpは通常のHTTPSと区別できないためポートでは遮断できない。
既知DoHサーバーへのTLS接続を検知するET POLICYルールがあるため、迂回の試みはネットワークIDS(7章)で可視化する
時刻同期 (chrony)
Ubuntu 26.04の標準時刻同期デーモンはchrony。
デフォルトで **NTS(認証・暗号化されたNTP)**によりUbuntuタイムサーバーと同期する(/etc/chrony/sources.d/ubuntu-ntp-pools.sources)。
NTSは経路上のNTP改竄(時刻巻き戻しによる証明書検証回避等)への対策になるため、このデフォルトは維持する(chronyが存在しない場合は sudo apt install chrony)。
時刻ソースの設計:
- chronyは全ソースを同時観測して外れ値(falseticker)を統計的に排除するため、冗長性は個別のフォールバック指定ではなくソース群全体で確保する
- デフォルトのUbuntu NTS群(認証済み・複数台)はそのまま併用し、以下を「追加」する。
認証なしソースを増やしすぎるとNTS多数決の優位性が下がるため、追加は最小限にする- さくらのNTP: 同一ネットワーク内で低遅延・低ジッタ(精度向上用。NTS非対応の一票)
- Cloudflare: NTS対応・anycastで国内POPに到達する別事業者(stratum 3)
- PTB(ドイツ物理工学研究所)/ Netnod(スウェーデン): 国家標準機関系のNTS対応stratum 1サーバー。運営主体の分散を最大化する認証済みの検証票
- chronyは遅延・ジッタで重み付けするため、実際の同期はさくら/Cloudflareが支配し、遠距離ソースは合意形成の票として機能する
$ sudo vi /etc/chrony/sources.d/local.sources
server ntp1.sakura.ad.jp iburst
server time.cloudflare.com iburst nts
server ptbtime1.ptb.de iburst nts
server nts.netnod.se iburst nts
$ sudo systemctl restart chrony
$ chronyc sources
$ sudo chronyc authdata # NTS auth status per source (root-only command)
$ chronyc tracking
6. ストレージ (Btrfs)
マウントオプション
$ sudo vi /etc/fstab
ルートファイルシステム(/)の行のマウントオプション部分を以下に変更する。
subvol= 等の既存オプションがある場合は残すこと。
defaults,noatime,compress=zstd:1
- noatime: 読み取りのたびに発生するatime更新のメタデータ書き込みを止める
(SSD書き込み削減。atime依存のアプリは本構成には無い) - compress=zstd:1: 透明圧縮の最軽量レベル。既定レベルの3より圧縮率は下がるが、vCPU 3コアの本機ではCPU消費の低さを優先する(圧縮はSSDへの実書き込み量も減らす)
再起動
$ sudo reboot
初回圧縮
既存ファイルの再圧縮。
reflink共有を解除してしまうため、HermesAgent導入(運用データが載り始める)前の初回のみ実施すること。
マウントオプション compress=zstd:1 と同じレベルで再圧縮する(defragのレベル指定 -L はカーネル6.15以降+btrfs-progs 6.14以降)。
$ sudo btrfs filesystem defrag -r -v -czstd -L 1 /
$ sudo btrfs filesystem usage /
7. HermesAgent実行環境
HermesAgent本体は公式インストーラーでホストに直接インストールし、エージェントが実行するコマンドは8章で設定するDockerターミナルバックエンドのサンドボックスコンテナへ隔離する。
エージェント本体が到達できる範囲(トラストエンベロープ)を最小化するため、sudo権限を持たない専用ユーザー hermes を作成し、その権限内で実行する。Dockerもrootlessモード(dockerdごとhermesユーザーのユーザー名前空間内で動作)で構築し、root権限をどこにも与えない。
あわせて、エージェント導入前に実行時検知(Falco)を常駐させ、隔離をすり抜けた侵害の早期検知層とする。
専用ユーザーの作成
$ sudo apt install systemd-container
$ sudo adduser --disabled-password --gecos 'HermesAgent' hermes
-
--disabled-passwordによりパスワードログインは不可。
操作は管理ユーザーからsudo machinectl shell hermes@(完全なログインセッションが張られ、systemctl --userがそのまま使える)で行う。
単発コマンドはsudo -iu hermes <コマンド>でもよい - systemd-containerはmachinectlの提供元パッケージ(コンテナ機能は使わない)
- sudoグループには追加しない。sshdの
AllowUsersにも追加しない(3章。SSH直接ログイン不可)
続けてlingering(ログインセッションが無くてもユーザーのsystemdインスタンスを起動時から常駐させる設定)を有効化する。
後続のrootless Dockerデーモンとゲートウェイは、いずれもこの上でsystemdユーザーサービスとして動く。
$ sudo loginctl enable-linger hermes
依存パッケージ
インストーラーの前提はgit・curl・xz-utils。
実行時依存のうちPython 3.11とNode.js 22はインストーラーがhermesユーザーのホーム配下(~/.hermes/ 等)に閉じて導入するため、システム側には常設しない。
一方ripgrep(エージェントのファイル検索高速化)とffmpeg(TTS音声メッセージの変換)はaptでのシステム導入が前提で、hermesユーザーはsudo不可のためインストーラー内の任意導入プロンプトでは導入できない(無くてもgrepフォールバック・TTS制限付きで動作はする)。
ここで併せて導入しておく。
make・g++はNode.jsネイティブモジュールのビルド用。
npm依存のnode-pty(PTYネイティブバインディング)はLinux向けのビルド済みバイナリを同梱せず、導入時・更新時にnode-gypによるソースビルド(要件はpython3・make・C++コンパイラ。
python3はOS標準で導入済み)が走るため、無いと npm install が失敗する。コンパイラのホスト常設はLynis(9章)が指摘する項目だが、サンドボックスイメージ(8章)にフルのビルドツールチェーンが含まれる本構成では排除の実効性が薄く、無人更新(9章)が確実に通ることを優先して常設する。
$ sudo apt install git curl xz-utils ripgrep ffmpeg make g++
ターミナルバックエンド用Docker (rootless)
8章で設定するDockerターミナルバックエンド用に、Dockerをrootlessモードで導入する。
dockerd自体をhermesユーザーのユーザー名前空間内で動かす方式で、コンテナ内のroot=ホストのhermesユーザーとなり、docker経由のroot権限奪取が構造的に不可能になる。
通常のrootful Docker+dockerグループ参加はroot相当の権限付与となり本構成の権限分離が崩れるため採用しない。
管理ユーザーで必要パッケージを導入する。Ubuntuのdocker.ioはrootful側のデーモンも同時にインストール・起動するため、使わないrootful側は無効化しておく。
$ sudo apt install docker.io rootlesskit uidmap slirp4netns dbus-user-session
$ sudo systemctl disable --now docker.service docker.socket
$ grep hermes /etc/subuid /etc/subgid
/etc/subuid / /etc/subgid にhermesの65536個のサブID範囲があることを確認する(Ubuntuのadduserは自動割当する。
無ければ sudo usermod --add-subuids 165536-231071--add-subgids 165536-231071 hermes のように既存ユーザーと重複しない範囲を割り当てる)。
rootlessコンテナの --cpus/--memory 等の資源制限には、hermesユーザーのsystemdインスタンス(user@.service)へのcgroupコントローラ委譲が必要。
systemd 252以降(Ubuntu 26.04を含む)はcpu/memory/pids を既定で委譲するため本構成の設定値はそのままでも機能するが、既定値のドリフトに依存しない明示固定と cpuset/io を含む全コントローラの委譲のため、Docker公式ドキュメントが推奨するdrop-inを置いておく。
$ sudo mkdir -p /etc/systemd/system/user@.service.d
$ cat <<'EOF' | sudo tee /etc/systemd/system/user@.service.d/delegate.conf
[Service]
Delegate=cpu cpuset io memory pids
EOF
$ sudo systemctl daemon-reload
Ubuntu 26.04 では AppArmor による unprivileged user namespace 制限が有効なので、rootlesskit が userns 権限を持つプロファイル下で動作するか確認する。
26.04の apparmor パッケージは /etc/apparmor.d/rootlesskit(および slirp4netns)のプロファイルを同梱しているため、通常は追加作業は不要。
$ sudo apparmor_status | grep -E 'rootlesskit|unprivileged_userns' || true
$ ls /etc/apparmor.d/rootlesskit
hermesユーザーでセットアップツールを実行する。
Ubuntuパッケージは/usr/share/docker.io/contrib/ に同梱されているが、同梱の dockerd-rootless-setuptool.sh はdocker バイナリを自分と同じディレクトリにあると想定するため、~/.local/bin に docker 等の symlink を置いてから実行する。
$ sudo machinectl shell hermes@
$ mkdir -p ~/.local/bin
$ ln -sf /usr/bin/docker ~/.local/bin/docker
$ ln -sf /usr/share/docker.io/contrib/dockerd-rootless.sh ~/.local/bin/dockerd-rootless.sh
$ ln -sf /usr/share/docker.io/contrib/dockerd-rootless-setuptool.sh ~/.local/bin/dockerd-rootless-setuptool.sh
$ export PATH=$HOME/.local/bin:$PATH
$ dockerd-rootless-setuptool.sh install
ストレージ構成を明示する(containerd image store + overlayfsスナップショッター。
旧グラフドライバはbtrfsを正式サポートしないが、overlayfsはbtrfs上で公式サポートされる。
Docker 29以降の新規インストール既定でもあるが、既定値のドリフトに依存しないよう固定する)。
ログドライバはDocker公式が推奨する local(ローテーション・圧縮内蔵)とし、上限も明示する。
$ mkdir -p ~/.config/docker
$ vi ~/.config/docker/daemon.json
{
"features": { "containerd-snapshotter": true },
"log-driver": "local",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
JSON 構文を検証してから起動する。
$ python3 -m json.tool ~/.config/docker/daemon.json >/dev/null
$ systemctl --user restart docker
$ systemctl --user enable docker
$ docker context show # rootless
$ docker info -f '{{ .DriverStatus }}' # [[driver-type io.containerd.snapshotter.v1]]
$ docker run --rm hello-world
- イメージ・コンテナの実体は
~/.local/share/docker(btrfs上。zstd透明圧縮の対象) - rootlessのコンテナネットワークはslirp4netns(ユーザー空間NAT)によるアウトバウンドのみで、ホストのiptables/ufwを改変しない。ポート公開(
-p)は本構成では使用しない - rootlessでも
--cpus/--memory/--pids-limit等の資源制限は、cgroup v2 + systemd かつuser@.serviceへのコントローラ委譲ができていれば機能する
実行時侵害検知 (Falco)
eBPFベースのランタイム検知(CNCF卒業プロジェクト)。「想定外の子プロセス起動」「想定外のアウトバウンド」「機微ファイルへのアクセス」等の実行時挙動を検知する、rkhunter的発想の現代的な到達点。プロンプトインジェクション成功時の早期検知層として機能する。
導入はこの時点(エージェント導入の直前)で行う。再起動を伴うホスト構成変更(2章・6章)が完了して稼働環境が確定した後であり、かつエージェント稼働開始前の静かな期間にアラートのベースライン(正常運用でも発火するルール)を把握できるため。
公式aptリポジトリから導入し、modern eBPFドライバで常駐させる:
$ curl -fsSL https://falco.org/repo/falcosecurity-packages.asc | \
sudo gpg --dearmor -o /usr/share/keyrings/falco-archive-keyring.gpg
$ echo "deb [signed-by=/usr/share/keyrings/falco-archive-keyring.gpg] https://download.falco.org/packages/deb stable main" | \
sudo tee /etc/apt/sources.list.d/falcosecurity.list
$ sudo apt update && sudo apt install falco # select "Modern eBPF" when prompted
$ sudo systemctl enable --now falco-modern-bpf
$ sudo journalctl -u falco-modern-bpf -f # alerts
- falcosecurityリポジトリはunattended-upgradesの既定の許可オリジン(Ubuntu公式・ESM)に含まれないが、2章のOrigins-Pattern(site=download.falco.org)で対象へ加えているため、Falcoの更新も自動適用される
- ドライバ選択に続くfalcoctlのプロンプトではルール自動更新を有効にする(falcoctl-artifact-follow.serviceが常駐し、検知ルールセットを自動追従する)
- 日常のアラート確認は9章「運用メンテナンス」の「セキュリティ監査と侵害検知」を参照
ネットワーク侵入検知 (Suricata)
Falcoがホスト内のプロセス挙動を見るのに対し、Suricataはネットワークトラフィックをシグネチャで検査するIDS(商用NGFWのDPI層に相当)。C2ビーコン・マルウェア配布サイト・フィッシング基盤への接続や、DNS強制(5章)をすり抜けるDoH利用の試みなど、通信面の侵害兆候を検知する。Falcoと同じく「検知に徹する」層としてalert-onlyで常駐させ、エージェント導入前にアラートのベースラインを把握する。
universeのパッケージを使用する(2章で -updates を許可済みのため、Falcoと違いリポジトリ追加なしでunattended-upgradesの自動更新対象になる。jqはアラート点検用):
$ sudo apt install suricata suricata-update jq
$ sudo systemctl stop suricata # configure before the first real start
シグネチャはET Open(Proofpointが無償公開する脅威ルールセット。日次更新)を縮小して使う。
全カテゴリ(4〜5万ルール)は定常1GB超+リロード時に一時ほぼ倍のメモリを要し本機では成立しないため、発火対象のない受信サービス攻撃系(受信面はSSHのみ)と無関係なカテゴリを無効化し、送信面とブラウザ面を残す:
$ sudo vi /etc/suricata/disable.conf
# Inbound service-attack categories: no listening services here except SSH
group: emerging-web_server.rules
group: emerging-web_specific_apps.rules
group: emerging-sql.rules
group: emerging-netbios.rules
group: emerging-rpc.rules
group: emerging-snmp.rules
group: emerging-smtp.rules
group: emerging-imap.rules
group: emerging-pop3.rules
group: emerging-ftp.rules
group: emerging-telnet.rules
group: emerging-tftp.rules
group: emerging-voip.rules
group: emerging-dos.rules
group: emerging-scan.rules
# Inbound-scanner IP reputation: constant noise on a public IP
group: dshield.rules
group: ciarmy.rules
# Not present on this host (no Windows/mobile clients, games, P2P, chat, ICS)
group: emerging-activex.rules
group: emerging-games.rules
group: emerging-chat.rules
group: emerging-p2p.rules
group: emerging-file_sharing.rules
group: emerging-scada.rules
group: emerging-mobile_malware.rules
# Low-signal / large categories not worth the memory here
group: emerging-info.rules
group: emerging-icmp.rules
group: emerging-inappropriate.rules
group: emerging-hunting.rules
group: emerging-dyn_dns.rules
group: emerging-deleted.rules
group: emerging-retired.rules
設定ファイルの以下のキーを変更する(af-packetのinterface以外はすべて省メモリ化):
$ sudo vi /etc/suricata/suricata.yaml
vars:
address-groups:
HOME_NET: "[203.0.113.10,2001:db8:0:1:203:0:113:10]"
af-packet:
- interface: ens3
threads: 1 # single worker: enough for this link, minimal buffers
cluster-type: cluster_flow
tpacket-v3: yes # variable-size ring blocks: large memory savings
detect:
profile: low # merge signature groups: same detections, less memory
flow:
memcap: 64mb # default 128mb
stream:
memcap: 32mb # default 64mb
reassembly:
memcap: 128mb # default 256mb
outputs:
- fast:
enabled: yes
- eve-log:
enabled: yes
types:
- alert # alerts only: no http/dns/tls/flow metadata logging
- stats
ルールを取得して検証し、起動する:
$ sudo suricata-update # fetch ET Open, applying disable.conf
$ sudo suricata -T -c /etc/suricata/suricata.yaml -v
$ sudo systemctl enable --now suricata
$ systemctl status suricata
発報テスト(NIDS確認用の無害な応答を返す公開エンドポイント):
$ curl -s http://testmynids.org/uid/index.html
$ sudo tail -2 /var/log/suricata/fast.log # expect "GPL ATTACK_RESPONSE id check returned root"
HermesAgentのインストール
hermesユーザーになり、公式インストーラーを実行する。
コードとvenvは ~/.hermes/hermes-agent/、hermes コマンドは ~/.local/bin/hermes、データは ~/.hermes/ に配置される。
$ sudo machinectl shell hermes@
$ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
- sudo無しの専用ユーザーでのインストールは公式にサポートされる。
root権限が必須なのはChromium(ブラウザツール)用のシステムライブラリ導入のみで、そこだけがスキップされる(Chromium本体は~/.cache/ms-playwright/へ導入済み)。
続けて次節の手順で管理ユーザー側から導入する - 途中でsudoを要するプロンプト(Pythonビルドツール等)は失敗しても実害がない(Python依存はすべてuv.lockのハッシュ検証付きビルド済みwheelで導入される)。
ripgrep・ffmpegを「依存パッケージ」の項どおり事前導入していれば、その分のプロンプトは表示されない - ブラウザ自動化を使わない場合は
curl ... | bash -s -- --skip-browserでPlaywright/Chromiumごと省略できる(次節の手順も不要になる) - インストーラー末尾でセットアップウィザード(8章)が起動する。中断しても後から
hermes setupで再開できる -
~/.local/binはUbuntu既定の~/.profileでPATHへ追加されるため、インストール直後はログインし直すとhermesコマンドが有効になる
Chromium用システムライブラリの導入
インストーラー末尾の案内は「管理者が sudo npx playwright install-deps chromium を実行」だが、そのままでは実行できない(npxはhermesユーザーの環境内にしか無い)うえ、hermes所有のコードをrootで実行することになり本構成の権限分離にも反する。代わりに、hermesユーザーでは不足パッケージの一覧表示(--dry-run)だけを行い、apt本体は管理ユーザーで実行する2段構えとする。
$ sudo machinectl shell hermes@
$ cd ~/.hermes/hermes-agent
$ PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npx playwright install-deps --dry-run chromium \
| sed -n 's/^ //p' | xargs
$ exit
dry-run本来の出力は Missing system dependencies (件数): +不足パッケージ名の列挙(1行1個・2スペースインデント。既にffmpeg等の依存で入っているライブラリは出てこない)で、後段の sed -n 's/^ //p' | xargs がその列挙だけをコピペ用のスペース区切り1行に整形する。
先頭に出る BEWARE: your OS is not officially supported ... はフォールバック指定による想定内の警告(stderrなので整形結果には混ざらない)。出力された1行を確認し、管理ユーザーでそのままaptに貼り付けて実行する:
$ sudo apt install --no-install-recommends fonts-freefont-ttf fonts-ipafont-gothic ...(1行貼り付け)... xvfb
rootで実行するのはこのaptのみで、hermes所有のファイルには一切触れない。
-
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64は必須。
PlaywrightはUbuntu 26.04をまだ認識せず、無指定では「Cannot install dependencies for ubuntu26.04-x64」となり何も表示されない。
この環境変数はPlaywright公式の未認識プラットフォーム向け回避策で、インストーラー自身もChromium本体の導入に同じフォールバックを内蔵している - 一覧は24.04向けとして生成されるが、パッケージ名(t64系を含む)は26.04にも同名で存在する
-
npxがplaywrightパッケージのダウンロードを提案してくる場合は、インストーラー実行中のnpm installがタイムアウト等で未完了だったということなので、応じずに~/.hermes/hermes-agentでnpm installを再実行してからやり直す
(リポジトリが固定するバージョンのplaywrightで一覧を生成するため)
導入後、hermesユーザーでヘッドレスChromiumの起動まで通ることを確認する:
$ cd ~/.hermes/hermes-agent
$ npx playwright screenshot https://example.com /tmp/pw-check.png && rm /tmp/pw-check.png
8. HermesAgent
HermesAgentはWeb閲覧・受信メッセージ等の信頼できない入力を取り込む任意コマンド実行エージェント。
公式SECURITY.mdは「プロセス内の承認ゲート等は境界ではなく、唯一の境界はOSレベル隔離」と明言し、OSレベル隔離として次の2つの姿勢を定義している:
-
プロセス全体の隔離(公式Dockerイメージ等): エージェントプロセスごとサンドボックス化する。
信頼できない入力面を扱う本番でのフルサポート姿勢 - ターミナルバックエンド隔離: エージェント本体はホストで動かし、terminal・ファイルツール・execute_codeの実行だけをコンテナ等に閉じ込める。公式ドキュメントも本番ゲートウェイ運用ではdocker等のコンテナバックエンドを推奨している
本構成は後者を採用する(本体はネイティブ+コマンド実行はrootless Dockerサンドボックス)。
前者との差分=残余リスクは「エージェントのPythonプロセス自体・MCPサブプロセス・スキル/プラグインのインポートがホスト側(hermesユーザー権限)で動くこと」であり、以下の多層で補う:
- sudo権限を持たない専用ユーザーによる権限分離。rootless Dockerのためdocker経由の権限昇格も構造的に不可能
- hermesユーザーに他用途を同居させない: 管理ユーザーの認証情報・SSH鍵・リポジトリ等をhermesユーザーから読める場所に置かない
- 受信ポートゼロ: メッセージング連携はアウトバウンド(long polling / WebSocket等)のみで、ufw/パケットフィルターの受信全拒否と両立する
- サードパーティスキル/プラグインの導入前レビュー(ホスト側で実行される数少ない経路のため、本構成では特に重要)
- Falcoによる実行時検知(7章): ホスト側で動くエージェント本体プロセスの想定外挙動
(想定外の子プロセス起動・機微ファイルアクセス等)を検知する - DNS強制(5章)とネットワークIDS(7章): 侵害が成立しても、外部への通信はQuad9の悪性ドメインフィルタとSuricataのシグネチャ検知(C2・マルウェア配布先等)にさらされる
初回セットアップ
セットアップウィザード(インストーラー末尾で自動起動)でメッセージングプラットフォーム(Telegram等)を設定する。
認証情報は ~/.hermes/.env に書き込まれる。
LLMプロバイダはウィザードでは設定せず(さくらのAI Engineの組み込みプロバイダは無い)、次節のとおり設定ファイルへ直接記述する。
$ sudo machinectl shell hermes@
$ hermes setup # ウィザードをスキップしていた場合のみ
$ hermes gateway setup # メッセージングプラットフォームの設定
$ chmod 700 ~/.hermes && chmod 600 ~/.hermes/.env
LLMプロバイダ (さくらのAI Engine)
LLM推論にはさくらのAI Engine(OpenAI互換API)を使う。
モデルの実行・通信が国内で完結し、入力がモデル学習に利用されないため、エージェントが扱うデータを国外・モデル提供元へ出さないという点で本構成の方針と整合する。
HermesAgentからは、公式にサポートされる任意のOpenAI互換エンドポイント(named custom provider)として接続する。
事前にさくらのクラウドのコントロールパネルで「さくらのAI Engine」を有効化し、アカウントトークンを発行しておく(手順は公式マニュアル参照)。
- アカウントトークンは
<UUID>:<シークレット>形式で、発行時にしか表示されない - プランはどちらでも動作する。「基盤モデル無償プラン」は完全無料だが、無償枠(チャット補完はモデルごと月3,000リクエスト)超過後はレート制限で応答が止まりうる。
「従量課金プラン」は同じ無償枠+超過分が従量課金となり、止まらない。Hermesはツール呼び出し1回ごとに1リクエストを消費するため無人運用を止めないなら従量課金プランを推奨。
モデルはエージェント要件(ツールコール対応・コンテキスト64K以上。未満はHermesが起動時に拒否する)を満たすものから選ぶ。
2026-07時点の提供モデルでは:
- preview/Kimi-K2.6(既定に採用): 256Kコンテキスト・エージェント用途特化(開発元公表のツール呼び出し成功率96.6%)・画像入力対応。Nous Research自身がHermes Agentでの動作を公表している
- gpt-oss-120b(フォールバックに採用): エージェント要件を満たす唯一のGA(正式提供)モデル。131Kコンテキスト・ツールコール対応・テキスト専用
- preview/Qwen3.6-35B-A3B(256K)・preview/gemma-4-31B-it(256K)も要件を満たす代替候補
- llm-jp-3.1-8x13b-instruct4はコンテキスト4,096かつツールコール用テンプレート非対応、PLaMo 2.0-31B(クローズド・申請必須)は32Kかつツールの自律選択非対応のため、対話品質と無関係にエージェント駆動には使えない
hermesユーザーで設定を記述する:
$ vi ~/.hermes/config.yaml
custom_providers:
- name: sakura
base_url: https://api.ai.sakura.ad.jp/v1
key_env: SAKURA_AI_ENGINE_API_KEY
api_mode: chat_completions
model:
provider: custom:sakura
default: preview/Kimi-K2.6
supports_vision: true # natively multimodal: send images directly
fallback_providers:
- provider: custom
model: gpt-oss-120b # GA model: survives preview retirement / rate limiting
base_url: https://api.ai.sakura.ad.jp/v1
key_env: SAKURA_AI_ENGINE_API_KEY
アカウントトークンを ~/.hermes/.env へ追記する:
$ vi ~/.hermes/.env
SAKURA_AI_ENGINE_API_KEY=<UUID>:<シークレット>
設計メモ:
- プレビューモデルは予告なく提供終了・仕様変更されうるため、同一エンドポイントのGAモデル(gpt-oss-120b)への自動フォールバックを設定する。無償枠超過時のレート制限(429)や過負荷時にも同じ経路で切り替わる。
発動中はコンテキストが256K→131Kに狭まるがHermesの文脈圧縮が吸収する - 補助モデル(auxiliary)は既定のautoを維持する。visionを含む補助タスクはメインのKimi-K2.6に流れ、そのまま処理できる(
supports_vision: trueにより添付画像もvision_analyzeの前処理を経ずネイティブに渡される)。ただしフォールバック発動中はテキスト専用のgpt-oss-120bになるため画像解析は失敗する - コンテキスト長はHermesがエンドポイントの
/v1/modelsから自動検出する。検出に失敗して起動を拒否された場合のみmodel.context_lengthを明示する - モデルの追加・提供終了はコントロールパネルの「利用可能なモデル」または
/v1/modelsAPIで確認できる。既定モデルを差し替える場合はmodel.defaultを書き換える(チャット内では/model custom:sakura:<モデル名>で一時切替できる)
動作確認(トークン単体→Hermes経由の順):
$ . ~/.hermes/.env
$ curl -sS https://api.ai.sakura.ad.jp/v1/chat/completions \
-H "Authorization: Bearer $SAKURA_AI_ENGINE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"model": "preview/Kimi-K2.6", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 50}'
$ hermes doctor # no "Unknown provider" warnings expected
$ hermes # verify a tool call works (e.g. ask it to run `date`)
ターミナルバックエンド (Docker) の設定
エージェントのterminal・ファイルツール・execute_codeの実行先を、7章で構築したrootless Docker上のサンドボックスコンテナへ切り替える。
$ vi ~/.hermes/config.yaml
terminal:
backend: docker
cwd: "/workspace"
docker_image: "nikolaik/python-nodejs:python3.14-nodejs26"
docker_forward_env: [] # keep secrets out of the sandbox
env_passthrough: [] # keep secrets out of the sandbox
container_cpu: 2
container_memory: 1024 # MB
挙動と設計メモ:
- Hermesはラベル付きの単一コンテナを初回ツール実行時に起動し(イメージは自動pull)、以後の全terminal・ファイルツール・execute_code呼び出しを
docker execで実行する。コンテナはセッション・/new・サブエージェント・Hermesプロセスの再起動をまたいで再利用される - サンドボックスイメージは公式既定の
python3.11-nodejs20から差し替えている(Node.js 20は2026-04-30にEOL済みでセキュリティパッチが止まっているため。docker_imageは任意のイメージを指定できる設定項目で、バージョン制約はない) - コンテナには
--cap-drop ALL(DAC_OVERRIDE/CHOWN/FOWNERのみ復帰)、--security-opt no-new-privileges、--pids-limit 256、サイズ制限付きtmpfsのハードニングが自動適用される -
~/.hermesはコンテナにマウントされない(例外は~/.hermes/skillsの読み取り専用自動マウントと、スキルが宣言した環境変数の転送のみ)。docker_forward_envとterminal.env_passthroughは空を維持し、APIキー等をサンドボックスへ持ち込まない - 作業データはコンテナ内
/workspaceと/root(実体はホスト側~/.hermes/sandboxes/docker/のバインドマウント。container_persistent: trueが既定) -
docker_mount_cwd_to_workspaceは既定のfalseを維持する(ホスト側ディレクトリをサンドボックスへ渡さない) - ネットワークは既定で有効(エージェントの作業に必要)。完全に遮断する場合は
docker_network: false(--network=none)を設定する - dockerバックエンドでは危険コマンドの承認チェックはスキップされる(コンテナ自体が境界であり、コンテナ内の破壊的コマンドはホストに届かないという公式設計)
- 資源制限(CPU 2コア・メモリ1GB)は予約ではなくcgroupの上限で、暴走したサンドボックスがホスト側の常駐プロセス群(エージェント本体・Chromium・ゲートウェイ・Falco・Suricata)を飽和させないためのキャップ。vCPU 3コア・メモリ2GBの本機で、ホスト側に常時1コアとメモリの半分を残す
ゲートウェイ常駐化 (systemd)
Telegram等のメッセージング連携はすべてアウトバウンド接続(long polling / WebSocket等)のため、受信ポートの開放は不要。hermes gateway install がsystemdユーザーサービス(~/.config/systemd/user/hermes-gateway.service)を作成する。
$ hermes gateway install
$ mkdir -p ~/.config/systemd/user/hermes-gateway.service.d
$ cat <<'EOF' > ~/.config/systemd/user/hermes-gateway.service.d/after-docker.conf
[Unit]
After=docker.service
Wants=docker.service
EOF
$ systemctl --user daemon-reload
$ hermes gateway restart
$ hermes gateway status
$ journalctl --user -u hermes-gateway -f
- 7章でlingeringを有効化済みのため、OS再起動後もrootless Dockerとともに自動起動する
- 公式ユニットは
Restart=always+KillMode=mixed等を設定済みで、クラッシュ・更新・チャット内/restartからの再起動に対応する。ExecStopPostを追加するようなdrop-in改変は再起動ループを招くため行わない(公式ドキュメントの明示的な注意事項) - HTTP面は既定ですべて無効。ダッシュボード(9119)は別プロセスのsystemdユーザーサービスとして常駐させ、SSHポートフォワーディング経由でアクセスする(手順は次節)
手元のブラウザからの操作 (SSHポートフォワーディング)
ダッシュボード(9119)はループバック待受のため、受信ポートを追加開放せず、SSHのローカルフォワード(-L)で手元のMacBookNeoへトンネルして使う(公式ドキュメントもループバックbind+SSHトンネルを推奨する形態)。3章のsshdは AllowTcpForwarding yes + PermitOpen によりこのポート宛のローカル転送だけを許可済みで、ufw・さくらのパケットフィルターの変更も不要(既存のSSH 22/tcp許可の中を通る)。
なお、ゲートウェイ内蔵のOpenAI互換APIサーバー(8642)は本構成では使わないため有効化しない(API_SERVER_ENABLED 未設定=無効のまま。ダッシュボードは同一ホスト上でゲートウェイの状態を直接参照するため、APIサーバーがなくても動作する)。
サーバー側: ダッシュボードの常駐化
ダッシュボードはゲートウェイとは別プロセスで、既定では起動していない(ゲートウェイが稼働しているだけでは9119は待ち受けず、転送してもchannel N: open failed: connect failed: Connection refused になる)。
既定インストールにはダッシュボードのHTTPスタックが含まれないため、初回のみ依存を導入する(webはFastAPI/Uvicorn、ptyはChatタブ=ブラウザ内TUI用。不足したまま起動するとhermes dashboard が導入コマンドを案内する)。
hermesユーザーで:
$ cd ~/.hermes/hermes-agent && ./venv/bin/pip install -e ".[web,pty]" # first time only
(公式ドキュメントは uv pip install -e ".[web,pty]" を案内しているが、本環境のインストールにuvは含まれないため、venvのpipを直接使う)
ゲートウェイと違い install 系の専用コマンドは無いため、systemdユーザーサービスを作成して常駐させる(7章のlingeringによりOS再起動後も自動起動する)。
hermesユーザーで:
$ vi ~/.config/systemd/user/hermes-dashboard.service
[Unit]
Description=Hermes Agent Web Dashboard (loopback only)
[Service]
EnvironmentFile=%h/.hermes/.env
ExecStart=%h/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main dashboard --host 127.0.0.1 --port 9119 --no-open
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
$ systemctl --user daemon-reload
$ systemctl --user enable --now hermes-dashboard
$ systemctl --user status hermes-dashboard
$ ss -tln | grep 9119 # expect 127.0.0.1:9119
フロントエンド未ビルド時は初回起動時に自動ビルドが走るため、表示可能になるまで時間がかかることがある(進捗は journalctl --user -u hermes-dashboard -f で確認)。
手元のMacBookNeo側: トンネルとブラウザ
手元のMacBookNeoのターミナルで実行する(-N は転送専用でリモートコマンドを実行しない。
終了はCtrl-C):
$ ssh -N -L 9119:127.0.0.1:9119 kotonoha@203.0.113.10
トンネルを維持したまま、手元のブラウザで http://127.0.0.1:9119 を開くとダッシュボードにつながる。
ループバックbindのダッシュボードは認証ゲートが無効(公式のローカル/SSHトンネル想定の形態)で、.env の読み書きを含む管理UIのため、--host を変えて外部bindにはしない。
頻用するなら手元の ~/.ssh/config にエントリを作っておくと、以後は ssh -N hermes-tunnelだけで済む:
# Tunnel-only entry for the HermesAgent dashboard
Host hermes-tunnel
HostName 203.0.113.10
User kotonoha
LocalForward 127.0.0.1:9119 127.0.0.1:9119
# Fail fast if a forward cannot be set up (e.g. local port already in use)
ExitOnForwardFailure yes
# Detect dead tunnels (e.g. after laptop sleep / VPS reboot) and exit
ServerAliveInterval 30
ServerAliveCountMax 3
-
-Lの手元側待受は既定でループバックのみにバインドされ、同一LAN内の他端末からトンネルは使えない。
0.0.0.0:バインドや-gでLANへ開放しない(sshd側で転送先を絞った意味がなくなる) -
PermitOpenにより9119以外への転送要求はsshdが拒否する。リモート転送(-R)もPermitListen noneにより不可 - 手元側ポートが他アプリと衝突する場合は手前側だけ変えられる(例:
-L 19119:127.0.0.1:9119→ ブラウザではhttp://127.0.0.1:19119を開く) - 常駐する管理UIだが、待受はループバックのみ。サンドボックスコンテナからホストのループバックへは到達できず(rootless Dockerの既定
--disable-host-loopback)、エージェント本体のWebツールもsecurity.allow_private_urls: false(本章)がローカル宛URLを遮断するため、実質的な到達経路はSSHトンネル経由の管理者のみ
ユーザー許可リスト / DMペアリング(必須)
ゲートウェイは許可リスト未設定の相手からのメッセージを拒否する。
自分のアカウントはDMペアリングコードで承認する(GATEWAY_ALLOW_ALL_USERS=true は本番では絶対に使用しない)。
$ hermes pairing approve telegram <CODE>
無人運用ガードレール
ツールコールループの暴走は強制停止(hard_stop)する。
承認ゲートの設定も維持する(mode: smart、無人cronジョブでの危険コマンドは拒否=cron_mode: deny)。
前節のとおりdockerバックエンド使用中のシェルコマンドには承認チェックが働かないが、バックエンドを一時的にlocalへ戻した場合に既定値のまま無防備にならないための保険として設定しておく。
あわせてセッションの自動プルーニングを有効化する(既定はオフ)。
セッションDB(state.db)は容量の約70%が全文検索インデックスで、日本語はトークン分割の性質上さらに膨らみやすく、無人運用では年数GB規模まで成長した報告がある。
365日保持で成長を1年分に有界化する(代償として1年より古い会話は session_search の回想対象から外れる)。
容量は9章「定期点検」の du -sh ~/.hermes で追い、問題になるようなら保持日数を縮める。
$ vi ~/.hermes/config.yaml
approvals:
mode: smart
cron_mode: deny
tool_loop_guardrails:
hard_stop_enabled: true
hard_stop_after:
exact_failure: 5
idempotent_no_progress: 5
sessions:
auto_prune: true
retention_days: 365
CLIで対話する場合
$ sudo machinectl shell hermes@
$ hermes
セキュリティ上の注意
-
~/.hermes/.env(APIキー)はパーミッション600を維持し、リポジトリ等にコミットしない。サンドボックスにはマウントされないが、ホスト側のエージェントプロセスからは見える -
--yolo/approvals.mode: offを恒久設定にしない(dockerバックエンド使用中は元々シェル検査がスキップされるため得るものがなく、localへ戻した時の保険だけを失う) - サードパーティのスキル/プラグインは導入前にPythonコード・スクリプトまで読んで確認する(SKILL.mdの説明文だけでは不十分。インポート時に任意コードが実行され、プラグインはエージェントプロセス内=サンドボックスの外でフル権限を持つ)
- SSRF保護(RFC 1918・メタデータアドレス遮断)とWebサイトブロックリストはデフォルト有効。
security.allow_private_urlsはfalseのまま維持する - 本体の更新は
hermes update+ ゲートウェイ再起動。開発が非常に活発(週次リリース)で、セキュリティ修正も随時取り込まれる(例: WebSocketエンドポイントのDNSリバインディング脆弱性CVE-2026-53869はv0.16.0で修正)ため、9章のタイマーで日次チェックの自動更新を行う(ゲートウェイ再起動は更新があった時のみ)。自動更新はリリースノートの事前確認を省くトレードオフ(破壊的変更・サプライチェーンの残余リスク)を伴うが、信頼できない入力を常時取り込む本エージェントでは修正の適用遅延の方が大きいリスクと判断する。
手動で即時更新する場合:
$ hermes update
$ hermes gateway restart
(参考) Slack連携
Telegram以外の連携例として、Slackで使う場合の手順。
ゲートウェイはSocket Mode(アウトバウンドWebSocket)で接続するため公開URLも受信ポートの開放も不要で、本構成の受信ポートゼロ方針とそのまま両立する。
スコープ等を手動設定する手順や挙動・設定の詳細は公式ドキュメント参照。
Slackアプリの作成には、Hermesが生成するアプリマニフェストを使う(公式推奨。必要なOAuthスコープ・イベント購読・全スラッシュコマンド定義・Socket Mode有効化が一括で設定される)。
hermesユーザーで:
$ hermes slack manifest --agent-view --write # writes ~/.hermes/slack-manifest.json
Slackのアプリ管理画面の「Create New App」→「From an app manifest」でワークスペースを選択し、生成されたJSONを貼り付けてアプリを作成したら、トークンを2つ取得する:
- App-Level Token(
xapp-): Settings → Basic Information → App-Level Tokens → Generate(スコープはconnections:write) - Bot Token(
xoxb-): Settings → Install App → Install to Workspace の承認後に表示される
hermesユーザーで ~/.hermes/.env へ追記し(hermes gateway setup の対話設定でも可)、
ゲートウェイを再起動する:
# Slack gateway (Socket Mode)
SLACK_BOT_TOKEN=xoxb-<トークン>
SLACK_APP_TOKEN=xapp-<トークン>
$ hermes gateway restart
Slackでも未認可ユーザーからのメッセージは既定で拒否される。
ボットへDMを送るとペアリングコードが返るので、本章のDMペアリングと同様に承認する(Member IDの許可リストで管理する場合は SLACK_ALLOWED_USERS=U01ABC2DEF3 形式(カンマ区切り)を .env へ追記する。
Member IDはSlackのプロフィール → その他 → 「メンバーIDをコピー」で確認できる):
$ hermes pairing approve slack <CODE>
運用メモ:
- DMは全メッセージに応答する。チャンネルでは
/invite @Hermes Agentで招待したうえで@メンションされた時のみ応答し、返信はスレッドに付く(ボットのセッションがアクティブなスレッド内では以後メンション不要) - スレッド内ではSlackの仕様によりスラッシュコマンドが使えない(Hermesへ配送されない)。
!stop!newのように!プレフィックスを付けた通常返信で代替する - スコープ・イベント購読・スラッシュコマンド定義を後から変更した場合は、Slackアプリのワークスペースへの再インストールが必要(設定変更が反映されない場合の筆頭原因)
-
hermes updateで新しいコマンドが追加されたらhermes slack manifest --writeで再生成し、Slackアプリの「App Manifest」へ貼り直す(9章の日次自動更新が更新するのはHermes本体のみで、Slackアプリ側の定義は追従しない) - トークンは他の認証情報と同様
~/.hermes/.env(パーミッション600)のみに保存する
(参考) Discord連携
同じくTelegram以外の連携例として、Discordで使う場合の手順。
ボットはDiscord側のGateway(アウトバウンドWebSocket)へ接続するため公開URLも受信ポートの開放も不要で、本構成の受信ポートゼロ方針とそのまま両立する。
挙動・設定の詳細は公式ドキュメント参照。
ボットはDiscord Developer Portalで作成する:
- 「New Application」でアプリを作成し、General Informationの「Application ID」を控える(招待URLで使う)
- Botページの「Privileged Gateway Intents」で「Server Members Intent」と「Message Content Intent」を有効化する(最重要。後者が無効だとボットはオンラインになるがメッセージ本文を読めず、一切応答しない)
- 同じBotページの Token → 「Reset Token」でBot Tokenを取得する(表示は一度きり。紛失時は再リセット)
- 個人利用ではPublic BotをOFFにすると第三者がボットを招待できなくなる(招待は下の手動URL方式を使う。InstallationタブのDiscord提供リンクを使う場合はONのままにする)
サーバーへの招待は、次のURLの YOUR_APP_ID をApplication IDへ置き換えてブラウザで開き、対象サーバーを選んで承認する(サーバー側の「サーバー管理」権限が必要):
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=309240908864
permissions=309240908864 は公式ドキュメントの「テキスト+ボイス」セット(View Channels / Send Messages / Read Message History / Embed Links / Attach Files / スレッド返信・作成 / リアクションに加え、次節のVC用に Connect / Speak)。テキストだけで使うなら 274878286912でよい(後からVCを足す場合は同じURLで再招待すれば権限だけ更新される)。
hermesユーザーで ~/.hermes/.env へ追記し(hermes gateway setup の対話設定でも可)、
ゲートウェイを再起動する:
# Discord gateway
DISCORD_BOT_TOKEN=<トークン>
$ hermes gateway restart
Discordでも未認可ユーザーからのメッセージは既定で拒否される(許可設定が何も無ければ接続は成功しても全ユーザー拒否のfail-closed)。ボットへDMを送るとペアリングコードが返るので、本章のDMペアリングと同様に承認する(User IDの許可リストで管理する場合はDISCORD_ALLOWED_USERS=123456789012345678 形式(カンマ区切り)を .env へ追記する。
User IDはDiscordの設定 → 詳細設定 → 「開発者モード」を有効化したうえで、自分の名前を右クリック → 「ユーザーIDをコピー」で確認できる):
$ hermes pairing approve discord <CODE>
運用メモ:
- DMは全メッセージに応答する。サーバーチャンネルでは@メンションされた時のみ応答し、メンションごとに会話用スレッドが自動作成される(auto_thread。ボットが参加済みのスレッド内では以後メンション不要)
- スラッシュコマンドはDiscordネイティブのApplication Commandsとしてゲートウェイ起動時に自動登録・差分同期される。Slackと違いマニフェストの貼り直しは不要で、
hermes updateでコマンドが増えてもゲートウェイ再起動だけで追従する - ボット応答内の
@everyone/@here/ ロール宛pingは既定でブロックされる(LLM出力がサーバー全体への通知を誤発火させないための安全既定。discord.allow_mentionsで変更可) - オンラインなのに無反応な場合はMessage Content Intentの無効が筆頭原因(Developer Portalで有効化 → Save Changes後、ゲートウェイを再起動)
- トークンは他の認証情報と同様
~/.hermes/.env(パーミッション600)のみに保存する
VC(ボイスチャンネル)音声対話
ボットがVCへ参加し、発話をSTT(音声認識)で文字起こし → 通常のエージェント処理 → TTS(音声合成)でVCへ読み上げ返答する。文字起こしと応答テキストは /voice join を実行したテキストチャンネルにも流れる。
挙動・設定の詳細は公式ドキュメント参照。
Opusコーデックを導入する(ffmpegは7章で導入済み):
$ sudo apt install libopus0
hermesユーザーでPython拡張(discord.pyのボイス対応・PyNaCl等)を導入する:
$ sudo machinectl shell hermes@
$ cd ~/.hermes/hermes-agent && ./venv/bin/pip install -e ".[messaging]"
STT/TTSはどちらも本章のさくらのAI Engineを使う(LLMと同一のアカウントトークンで完結し、音声データの処理も国内で完結する):
- STT:
whisper-large-v3-turbo(OpenAI互換/v1/audio/transcriptions)。HermesのOpenAISTTプロバイダの接続先を環境変数で上書きして使う - TTS: VOICEVOXエンジンによる音声合成(OpenAI互換
/v1/audio/speech)。話者はzundamon等8モデル(コントロールパネルの「利用可能な音声モデル」で確認)。各音声モデルの利用規約への同意が必要で、生成音声を公開する場合は「VOICEVOX:ずんだもん」等のクレジット表記義務がある
$ vi ~/.hermes/.env
# Sakura AI Engine speech APIs (same account token as the LLM provider)
VOICE_TOOLS_OPENAI_KEY=<UUID>:<シークレット>
STT_OPENAI_BASE_URL=https://api.ai.sakura.ad.jp/v1
STT_OPENAI_MODEL=whisper-large-v3-turbo
$ vi ~/.hermes/config.yaml
stt:
provider: openai # resolves to Sakura via STT_OPENAI_BASE_URL
use_gateway: false # never route speech through the Nous managed gateway
openai:
model: whisper-large-v3-turbo # keep in sync with STT_OPENAI_MODEL
tts:
provider: openai # OpenAI-compatible endpoint = Sakura AI Engine TTS
use_gateway: false # never route speech through the Nous managed gateway
openai:
base_url: https://api.ai.sakura.ad.jp/v1
model: zundamon
voice: normal
use_gateway はOpenAI音声をNous Portalの管理ツールゲートウェイ経由にする設定で、セットアップ経緯によっては既存のconfig.yamlに true で入っていることがある。
true のままだとVOICE_TOOLS_OPENAI_KEY を無視してNous用トークンで認証してしまい、さくら側が401 (Invalid token) を返すため、stt / tts とも明示的に false へ倒しておく。
$ hermes gateway restart
使い方: 自分がVCへ入った状態で、ボットの見えるテキストチャンネルから /voice join を送ると同じVCへボットが参加する。退出は /voice leave。
テキストメッセージにも音声で返答させる場合は /voice tts(受信したボイスメッセージへの返答だけ音声にするなら /voice on)。
運用メモ:
- VCでボットが反応しない場合は、自分のUser IDが許可済みか(未認可ユーザーの音声は黙って無視される)、Discord側でミュートになっていないかを確認する
9. 運用メンテナンス
aptパッケージ(Falco・Suricata含む)の更新・自動再起動・サービス再起動は2章のunattended-upgrades + needrestartで自動化済み。残る更新(rootlessデーモンへの反映・サンドボックスイメージ・HermesAgent本体)はhermesユーザーのsystemdタイマーで、Suricataルール更新と定期メンテナンス(Btrfsスクラブ・パッケージ整合性検証・日次スナップショット)はシステムタイマーで自動化する。
手動作業は「異常が可視化される場所を見る」点検とレビュー系の監査に集約する。
更新の自動化 (systemdユーザータイマー)
- rootless Dockerデーモンの再起動(日次チェック): docker.io / runcは2025〜2026年にコンテナエスケープ級のCVE修正が続いており(runcのCVE-2025-31133等。26.04のrunc 1.3.3で修正済み)、更新を放置しない。apt更新自体は自動化済みだが、rootlessデーモン(ユーザーサービス)はパッケージ更新では再起動されず古いバイナリのまま動き続けるため、ランタイムバイナリの更新を検知した日のみ再起動する。サンドボックスコンテナはExitedになるが、次回のツール実行時に自動で再開される(ファイルシステム状態は保持、コンテナ内バックグラウンドプロセスは消える)
- サンドボックスイメージの更新(日次チェック): コンテナはイメージから再作成しない限り古い層のまま動き続けるため、毎日pullしてイメージが更新された場合のみHermes管理コンテナを削除し、次回ツール実行時に新イメージで再作成させる(/workspaceと/rootはホスト側バインドマウントのため保持される。コンテナ内に直接インストールしたパッケージやバックグラウンドプロセスは消える)。上流(nikolaik/python-nodejs)がタグを更新するのはPython/Nodeのパッチリリース時のみのため、再作成が起きるのは実測で月1〜3回程度
-
HermesAgent本体の更新(日次チェック): セキュリティ修正が週次リリースで随時取り込まれるため、更新遅延を作らない(トレードオフは8章)。ゲートウェイ再起動は実際に更新があった時のみ行い、
hermes updateが失敗した場合は再起動まで進まず旧バージョンのまま動き続ける - 実行時刻はローカルタイム(2章のタイムゾーン確認を参照)。深夜〜早朝に寄せて実行中タスクへの影響を減らす。全タイマーPersistent=trueのため、停止していた期間の分は次回起動時に追いつき実行される
hermesユーザーでスクリプトを配置する:
$ sudo machinectl shell hermes@
$ mkdir -p ~/.local/bin ~/.local/state ~/.config/systemd/user
$ touch ~/.local/state/docker-restart-on-update.stamp
$ vi ~/.local/bin/docker-restart-on-update
#!/bin/sh
# Restart rootless dockerd only when container runtime binaries were updated
set -eu
stamp="$HOME/.local/state/docker-restart-on-update.stamp"
for f in /usr/bin/dockerd /usr/bin/containerd /usr/sbin/runc /usr/bin/rootlesskit; do
if [ "$f" -nt "$stamp" ]; then
systemctl --user restart docker
touch "$stamp"
exit 0
fi
done
$ vi ~/.local/bin/sandbox-image-refresh
#!/bin/sh
# Pull the sandbox image and recreate Hermes-managed containers if it changed
set -eu
image="nikolaik/python-nodejs:python3.14-nodejs26"
before=$(docker image inspect -f '{{.Id}}' "$image" 2>/dev/null || echo none)
docker pull -q "$image" >/dev/null
after=$(docker image inspect -f '{{.Id}}' "$image")
if [ "$before" != "$after" ]; then
docker ps -aq --filter label=hermes-agent=1 | xargs -r docker rm -f
docker image prune -f
fi
$ vi ~/.local/bin/hermes-update
#!/bin/sh
# Update HermesAgent; restart the gateway only when new code was pulled
set -eu
repo="$HOME/.hermes/hermes-agent"
before=$(git -C "$repo" rev-parse HEAD)
"$HOME/.local/bin/hermes" update
after=$(git -C "$repo" rev-parse HEAD)
if [ "$before" != "$after" ]; then
"$HOME/.local/bin/hermes" gateway restart
fi
$ chmod +x ~/.local/bin/docker-restart-on-update ~/.local/bin/sandbox-image-refresh \
~/.local/bin/hermes-update
hermes-updateは ~/.hermes/hermes-agent がgitチェックアウトであること(7章のとおりインストーラーはgitを前提とする)を利用して更新の有無を判定する。将来更新方式が変わった場合はrev-parseが失敗し、ユニットのfailedとして可視化される。
ユニットとタイマーを定義する。dockerd再起動チェックの07:45はunattended-upgradesの実行枠(06:00開始+ランダム遅延最大60分)の後を狙った時刻で、当日の更新を当日中に反映する:
$ cat <<'EOF' > ~/.config/systemd/user/docker-restart-on-update.service
[Unit]
Description=Restart rootless dockerd after container runtime updates
[Service]
Type=oneshot
ExecStart=%h/.local/bin/docker-restart-on-update
EOF
$ cat <<'EOF' > ~/.config/systemd/user/docker-restart-on-update.timer
[Unit]
Description=Daily check for updated container runtime binaries
[Timer]
OnCalendar=*-*-* 07:45
Persistent=true
[Install]
WantedBy=timers.target
EOF
$ cat <<'EOF' > ~/.config/systemd/user/sandbox-image-refresh.service
[Unit]
Description=Refresh the Hermes sandbox image
[Service]
Type=oneshot
TimeoutStartSec=30min
ExecStart=%h/.local/bin/sandbox-image-refresh
EOF
$ cat <<'EOF' > ~/.config/systemd/user/sandbox-image-refresh.timer
[Unit]
Description=Daily sandbox image refresh check
[Timer]
OnCalendar=*-*-* 05:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
$ cat <<'EOF' > ~/.config/systemd/user/hermes-update.service
[Unit]
Description=Update HermesAgent and restart the gateway if updated
[Service]
Type=oneshot
TimeoutStartSec=30min
ExecStart=%h/.local/bin/hermes-update
EOF
$ cat <<'EOF' > ~/.config/systemd/user/hermes-update.timer
[Unit]
Description=Daily HermesAgent self-update check
[Timer]
OnCalendar=*-*-* 05:30
Persistent=true
[Install]
WantedBy=timers.target
EOF
有効化して初回スケジュールを確認する:
$ systemctl --user daemon-reload
$ systemctl --user enable --now docker-restart-on-update.timer \
sandbox-image-refresh.timer hermes-update.timer
$ systemctl --user list-timers
タイマーを待たず手動で即時適用する場合:
$ sudo machinectl shell hermes@
$ systemctl --user restart docker # after docker.io / runc updates
$ ~/.local/bin/sandbox-image-refresh # sandbox image
$ ~/.local/bin/hermes-update # HermesAgent itself
定期メンテナンスの自動化 (システムタイマー)
システム側に残る更新(Suricataルール)と定期メンテナンスも管理側のシステムタイマーで自動化する。
異常はユニットのfailed状態として現れる(systemctl --failed)ため、点検は結果確認に集約される。
- Suricataルール更新(日次): ET Openを取得し(7章のdisable.confによる縮小が自動適用される)、実行中エンジンへライブリロードする。取得・リロードの失敗時はユニットがfailedになり、旧ルールのまま動き続ける
- Btrfsスクラブ(月次): 全データ・メタデータのチェックサム検証(btrfs-progsの推奨周期は月次)。単一デバイスのため自動修復はされず検出のみだが、サイレントなデータ破損の早期発見層になる。エラー検出時は非0で終了しユニットがfailedになる
- パッケージ整合性検証 debsums(週次): dpkgチェックサムと実ファイルを照合し、バイナリ・ライブラリの改変を検出する。差分があればexit 2でユニットがfailedになる(conffileは既定で対象外のため、意図した/etc編集では発火しない)
- SSDのTRIMはUbuntu標準の
fstrim.timer(週次)で自動化済みのため追加作業はない
$ sudo apt install debsums
$ sudo tee /etc/systemd/system/btrfs-scrub.service <<'EOF' >/dev/null
[Unit]
Description=Scrub the root Btrfs filesystem
[Service]
Type=oneshot
TimeoutStartSec=6h
ExecStart=/usr/bin/btrfs scrub start -Bd /
EOF
$ sudo tee /etc/systemd/system/btrfs-scrub.timer <<'EOF' >/dev/null
[Unit]
Description=Monthly Btrfs scrub
[Timer]
OnCalendar=*-*-15 05:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
$ sudo tee /etc/systemd/system/debsums-check.service <<'EOF' >/dev/null
[Unit]
Description=Verify installed package checksums (tamper detection)
[Service]
Type=oneshot
ExecStart=/usr/bin/debsums -s
EOF
$ sudo tee /etc/systemd/system/debsums-check.timer <<'EOF' >/dev/null
[Unit]
Description=Weekly package integrity check
[Timer]
OnCalendar=Sun *-*-* 02:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
$ sudo tee /etc/systemd/system/suricata-rules-update.service <<'EOF' >/dev/null
[Unit]
Description=Update Suricata rules (ET Open) and live-reload the engine
[Service]
Type=oneshot
TimeoutStartSec=30min
ExecStart=/usr/bin/suricata-update
ExecStartPost=/usr/bin/suricatasc -c reload-rules
EOF
$ sudo tee /etc/systemd/system/suricata-rules-update.timer <<'EOF' >/dev/null
[Unit]
Description=Daily Suricata rule update
[Timer]
OnCalendar=*-*-* 05:45
Persistent=true
[Install]
WantedBy=timers.target
EOF
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now btrfs-scrub.timer debsums-check.timer suricata-rules-update.timer
$ systemctl list-timers fstrim.timer btrfs-scrub.timer debsums-check.timer suricata-rules-update.timer
ローカルスナップショット (システムタイマー)
ルートサブボリュームの読み取り専用スナップショットを日次で取得し、誤削除・設定ミス・不良更新からのファイル単位の復元点を確保する(同一ディスク上のためハードウェア障害には無力。オフサイトバックアップの代替ではない)。
- 04:00は自動再起動(04:30)・各種更新(05:00以降)より前のため、毎日「更新適用前」の状態が復元点として残る
- 取得対象は「/にマウントされているサブボリューム」であり、インストーラーのレイアウト(
subvol=の有無)に依存しない。格納先/.snapshotsはサブボリュームとして作成し、サブボリューム境界でスナップショットは再帰しないため入れ子に複製されることはない - 保持は14世代(約2週間)。スナップショットが掴む差分量は
btrfs filesystem usage /(監視の基本)で追う -
chmod 700により、hermesユーザー(=エージェント本体プロセス)から復元点の列挙・読み取りを不可にする
$ sudo btrfs subvolume create /.snapshots
$ sudo chmod 700 /.snapshots
$ sudo vi /usr/local/sbin/btrfs-snapshot
#!/bin/sh
# Take a read-only snapshot of / and keep only the most recent $keep
set -eu
dir=/.snapshots
keep=14
btrfs subvolume snapshot -r / "$dir/root-$(date +%Y-%m-%d_%H%M)"
find "$dir" -mindepth 1 -maxdepth 1 -name 'root-*' | sort -r | tail -n +$((keep + 1)) |
while read -r snap; do
btrfs subvolume delete "$snap"
done
$ sudo chmod 755 /usr/local/sbin/btrfs-snapshot
$ sudo tee /etc/systemd/system/btrfs-snapshot.service <<'EOF' >/dev/null
[Unit]
Description=Daily read-only snapshot of the root subvolume
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/btrfs-snapshot
EOF
$ sudo tee /etc/systemd/system/btrfs-snapshot.timer <<'EOF' >/dev/null
[Unit]
Description=Daily Btrfs snapshot
[Timer]
OnCalendar=*-*-* 04:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now btrfs-snapshot.timer
$ sudo systemctl start btrfs-snapshot.service # take the first snapshot now
$ sudo ls /.snapshots
復元はファイル単位を基本とする(スナップショットは通常のディレクトリとして読める):
$ sudo ls /.snapshots
$ sudo cp -a --reflink=always \
/.snapshots/<SNAPSHOT>/home/hermes/.hermes/config.yaml /home/hermes/.hermes/
定期点検(手動)
- unattended-upgradesの対象外(パッケージの削除・入れ替えを伴う更新等)が溜まっていないか月次目安で確認する:
$ sudo apt update && sudo apt full-upgrade
$ pro security-status
- HermesAgentの状態確認(hermesユーザーで実行):
$ sudo machinectl shell hermes@
$ hermes gateway status
$ hermes doctor # incl. supply-chain advisory check
$ journalctl --user -u hermes-gateway --since -24h
$ systemctl --user status hermes-dashboard
$ systemctl --user status docker
$ systemctl --user list-timers # update timers: last/next run
$ systemctl --user --failed # failed update runs, if any
$ docker ps -a --filter label=hermes-agent=1 # sandbox container
$ docker system df
$ du -sh ~/.hermes ~/.local/share/docker
管理ユーザーから直接ログを見る場合は sudo journalctl -f _SYSTEMD_USER_UNIT=hermes-gateway.service。
hermes doctor が未導入のNode依存(例: agent-browser)を警告した場合は、本体更新で追加されたnpm依存の取りこぼしなので、hermesユーザーで cd ~/.hermes/hermes-agent && npm install を実行して解消する(failedユニットにはならず、doctorの警告としてのみ現れる。ビルドに必要なmake・g++は7章の依存パッケージで導入済み)。
- 監視の基本:
$ sudo ss -tlnp # check for unexpected listening ports
$ sudo ufw status verbose
$ journalctl -p err -b # check error logs since boot
$ systemctl --failed # scrub / debsums findings surface here
$ sudo btrfs device stats --check / # device error counters (nonzero exit if any)
$ sudo btrfs filesystem usage /
セキュリティ監査と侵害検知
パッケージ整合性検証(改変検出)
バイナリ・ライブラリの照合は週次のdebsums-check.timer(本章前半)で自動実行され、検出時はユニットがfailedになる。見覚えのないバイナリの改変が要調査対象。conffileの差分確認(意図した/etc編集の棚卸し)は必要時に手動で行う:
$ sudo journalctl -u debsums-check --since -30d # findings from weekly runs
$ sudo debsums -ce # list modified conffiles (intended /etc edits are expected)
ホスト設定監査 (Lynis) — 月次目安
活発に保守されている監査ツール(旧rkhunter作者が開発)。ハードニング指数と改善提案を出力する。
ホストへの常設はせず、実行時にtarballを取得して使い捨てる(バージョンとSHA256はhttps://cisofy.com/downloads/ で確認):
$ curl -fsSLO https://downloads.cisofy.com/lynis/lynis-3.1.7.tar.gz
$ tar xzf lynis-3.1.7.tar.gz && cd lynis
$ sudo ./lynis audit system
実行時検知アラートの確認 (Falco) — 週次目安
Falco本体は7章でエージェント導入前に常駐させている。
アラートはjournalへ出力されるため、稼働状態とあわせて定期的に確認する:
$ systemctl status falco-modern-bpf
$ sudo journalctl -u falco-modern-bpf --since -7d | grep -E 'Emergency|Alert|Critical|Error|Warning|Notice'
ネットワークIDSアラートの確認 (Suricata) — 週次目安
Suricata本体は7章でエージェント導入前に常駐させている。
アラートと取りこぼしカウンタ、日次ルール更新の結果を確認する:
$ systemctl status suricata
$ sudo jq -r 'select(.event_type=="alert") | [.timestamp, .alert.signature, .src_ip, .dest_ip] | @tsv' \
/var/log/suricata/eve.json | tail -50 # rotated files: eve.json.1 etc.
$ sudo suricatasc -c dump-counters | jq '.message.capture' # kernel_drops should stay near zero
$ sudo journalctl -u suricata-rules-update --since -7d
恒常的に発火する誤検知はsid単位で /etc/suricata/disable.conf へ追記して抑制する(次回のルール更新から適用される)。