9 分で読了 #homelab
このページの目次

サーバーのライフサイクル: 参加と削除

一整套スクリプトで「VPSの購入 → Grafanaダッシュボードにメトリクスが表示されるまで」を1つのコマンドに圧縮します。参加 = bootstrap(公開鍵の配置/ホスト名の設定/SSH設定)→ mTLS証明書の発行 → otel-collectorのデプロイ → DIRECTルールの設定 → 正常性の確認; 削除 = サービスの停止 → 証明書の失効 → ルールの削除。全体で冪等性を保ち、各ステップで独立した判断を行い、途中で失敗しても再試行可能です。本章ではスクリプトの完全なロジックと機密情報を伏せたソースコードを解説します。

1. 使用方法

# IPモード(新規購入したVPS、IPとrootパスワードのみ必要)
./add-server.sh <IP> --region cn|us|jp|eu|sg [--port N] [--dry-run]

# エイリアスモード(既存のSSH設定と鍵認証が利用可能)
./add-server.sh <sshエイリアス> [表示名] [--dry-run]

# 削除
./remove-server.sh <sshエイリアス> [fleet-name] [--purge] [--dry-run]

前提条件⁠: ローカルマシンにCAが構築済み、中継用のratholeが準備完了、DNS otlp.liz6.com が解決可能であること。スクリプトは set -euo pipefail を使用しており、いずれかのステップで失敗すると即座に停止します。完了済みのステップは再実行時に自動的にスキップされます。

2. add-server.sh: 裸のIPからオンラインへ

スクリプトは2つのフェーズに分かれています: bootstrap(IPモードのみ、裸のIPをSSHエイリアスに変換)→ 監視参加(IPモードとエイリアスモードの両方で共通の6ステップ)。

2.1 Bootstrap: 裸のIPの前処理

入力としてIPが指定された場合、まず5つの小さなステップを実行して、リモートマシンをSSH管理下に収めます:

① リージョンの検出⁠: curl ip-api.com → countryCodeのマッピング: CN→cn, JP→jp, US→us, SG→sg など。失敗した場合は手動で --region を指定するよう促します。

② 自動命名⁠: ~/.ssh/config 内の Host li-<region>-node-<N> エントリをスキャンし、max(N)+1 を取得して新しい名前を生成します。まずIPに既存のエイリアスがあるか確認し、あればそれを再利用します。同じHostが同じIPを指している場合はスキップし、異なるIPを指している場合は警告します。

③ 公開鍵の配置⁠: ssh -o PasswordAuthentication=no root@<IP> echo ok → 鍵認証が既に設定されていればスキップします。そうでなければ ssh-copy-id を実行します。

④ ホスト名の設定⁠: ssh root@<IP> hostname で現在の値を確認し、既に目標の名前であればスキップします。そうでなければ hostnamectl set-hostname を実行します。

⑤ SSH設定の書き込み + 指紋の確認⁠: Hostブロック(StrictHostKeyChecking accept-new を含む)を追加し、初回接続時には手動で指紋を確認する必要があります。

_assign_name() {
  local region="$1" ip="$2"
  # まずこのIPに既存のエイリアスがあるか確認
  local existing; existing=$(grep -B5 "$ip" ~/.ssh/config 2>/dev/null \
    | grep '^Host ' | grep -v 'HostName' | sed 's/Host //' | head -1 || true)
  if [[ -n "$existing" ]]; then echo "$existing"; return; fi
  # 自動割り当て li-<region>-node-<N>
  local max_n; max_n=$(grep -oP "Host li-${region}-node-\K\d+" ~/.ssh/config 2>/dev/null \
    | sort -n | tail -1 || echo 0)
  echo "li-${region}-node-$((max_n + 1))"
}

_deploy_key() {
  if ssh -o ConnectTimeout=8 -o PasswordAuthentication=no -p "$PORT" "root@$IP" \
    'echo ok' 2>/dev/null | grep -q ok; then
    echo "鍵認証が既に設定されています、スキップします"; return
  fi
  ssh-copy-id -o ConnectTimeout=15 -p "$PORT" "root@$IP"
}

_set_hostname() {
  local cur; cur=$(ssh root@$IP hostname 2>/dev/null | tr -d '\r\n' || true)
  if [[ "$cur" == "$1" ]]; then echo "既に $1 です、スキップします"; return; fi
  ssh root@$IP "hostnamectl set-hostname '$1'"
}

2.2 監視参加 6ステップ(IPモードとエイリアスモード共通)

set -euo pipefail を使用し、各ステップは冪等性を持ちます。スクリプトの冒頭で入力タイプを判断します:

_is_ip() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
if _is_ip "$INPUT"; then
  BOOTSTRAP=1                            # まずbootstrapを実行
else
  HOST="$INPUT"; BOOTSTRAP=0             # bootstrapをスキップし、直接参加
fi

1/6 クライアント証明書の発行

CERTD="$CA_DIR/out/clients/$FLEET_NAME"
if [[ -d "$CERTD" ]]; then
  echo "証明書が既に存在します、スキップします"
else
  "$CA_DIR/issue-client-cert.sh" "$FLEET_NAME"  # EC P-256, clientAuth, 825日
fi

2/6 リモートアーキテクチャの検出 + バイナリのダウンロード

ARCH=$(ssh $HOST uname -m)
case "$ARCH" in
  x86_64|amd64) GOARCH=amd64;;
  aarch64|arm64) GOARCH=arm64;;
esac
# GitHub releaseから otelcol-contrib をダウンロード → ~/distfiles/ → scpでリモートに転送
# ローカルの ~/distfiles/ にキャッシュし、バージョンが変更されていない場合は再ダウンロードしない

3/6 mihomo DIRECTルール(ローカルマシン + N100)

重要な防御的なロジック——リモートVPSのパブリックIPは、ローカルマシンとN100の両方のmihomo DIRECTルールに追加する必要があります。そうしないと、SSHやOTLPの接続がTUNプロキシを通過してしまいます:

# 解決: SSHエイリアス → HostName → getentで実際のIPを解決
resolve_host_ip() {
  local hostname; hostname=$(ssh -G "$1" 2>/dev/null | awk '/^hostname / {print $2; exit}')
  # HostNameがドメイン名の場合、getentで解決
  if [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "$hostname"; return
  fi
  getent ahosts "$hostname" 2>/dev/null | awk '{print $1; exit}'
}

# パブリックIPかどうかを判断(プライベートネットワークはネットワークルールでカバーされているため、冗長な/32は追加しない)
is_public_ip() {
  local ip="$1"
  [[ "$ip" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|224\.) ]] && return 1
  return 0
}

mihomo_add_direct() {
  local ip="$1" desc="$2"
  # ローカルマシン: grepで確認→既に存在すればスキップ; そうでなければsedで挿入 + APIでホットリロード
  if ! grep -qF "${ip}/32" "$DESKTOP_MIHOMO_CONFIG" 2>/dev/null; then
    sed -i "/255\.255\.255\.255\/32,DIRECT,no-resolve/a\  - IP-CIDR,${ip}/32,DIRECT,no-resolve   # $desc" \
      "$DESKTOP_MIHOMO_CONFIG"
    curl -sf -X PUT "http://127.0.0.1:9999/configs?force=true" \
      -H "Authorization: Bearer ${DESKTOP_MIHOMO_SECRET}" \
      -d "{\"path\":\"${DESKTOP_MIHOMO_CONFIG}\"}" >/dev/null
  fi
  # N100: ssh + grep→既に存在すればスキップ; そうでなければsshでsed + nikkiリロード
  if ! ssh $N100_HOST "grep -qF '${ip}/32' $N100_PROFILE" 2>/dev/null; then
    ssh $N100_HOST "sed -i '/255\.255\.255\.255\/32,DIRECT,no-resolve/a\  - IP-CIDR,${ip}/32,DIRECT,no-resolve   # $desc' \
      $N100_PROFILE && /etc/init.d/nikki reload" 2>/dev/null || \
      echo "[!] N100に到達できません、後で手動で追加してください"
  fi
}

両側で冪等性を確認: 既に存在すればスキップし、存在しなければ sed255.255.255.255/32,DIRECT ルールの下に挿入(ルール位置を統一)、その後サービス再起動なしでAPIホットリロードを行います。

4/6 バイナリ + 設定 + 証明書のプッシュ

scp "$BIN" "$HOST:/usr/local/bin/otelcol-contrib"
scp "$TEMPLATE_DIR/config.yaml" "$HOST:/etc/otelcol-fleet/config.yaml"
scp "$CERTD/client.crt" "$CERTD/client.key" "$CERTD/ca.crt" "$HOST:/etc/otelcol-fleet/"
ssh $HOST 'chmod 0600 /etc/otelcol-fleet/client.key'

# envの書き込み: 3つの変数を設定に注入
ssh $HOST "cat > /etc/otelcol-fleet/env" <<EOF
FLEET_HOSTNAME=$FLEET_NAME
FLEET_OTLP_ENDPOINT=$OTLP_ENDPOINT
FLEET_COLLECT_INTERVAL=$FLEET_COLLECT_INTERVAL
EOF

config.yamlはテンプレートであり、envを使用してエンドポイント/ホスト名を注入します——複数のデプロイで同じテンプレート + 異なるenvを使用します。

5/6 systemdのインストールと起動

scp "$TEMPLATE_DIR/otelcol-fleet.service" "$HOST:/etc/systemd/system/"
ssh $HOST 'systemctl daemon-reload && systemctl enable --now otelcol-fleet'

ユニット設計: host_metrics は /proc//sys を読む必要があるため、User=root が最も簡単です。Restart=on-failure + RestartSec=5 を使用。RuntimeDirectory + StateDirectory でディレクトリを自動作成します。

6/6 正常性の確認

sleep 35  # 収集間隔30秒 + バッファ5秒
N=$(curl -s "http://127.0.0.1:9090/api/v1/query" \
    --data-urlencode "query=count({host_name=\"$FLEET_NAME\"})" \
    | grep -oP '"value":\[[^]]*,"\K[0-9]+' | head -1 || true)
if [[ -n "$N" && "$N" -gt 0 ]]; then
  echo "[+] 完了! Prometheusは $N 件のシリアルを受信しました。Grafanaのドロップダウンに $FLEET_NAME が自動的に表示されます。"
else
  echo "[!] メトリクスを受信していません。確認: ssh $HOST journalctl -u otelcol-fleet -n50"
fi

2.3 一般的な失敗例

ステップ症状原因解決策
2/6SSHタイムアウトmihomoのエグジットが一時的に海外ルートを迂回数秒待ってから再試行
5/6active(auto-restart)キューディレクトリが作成されていない修正済み: create_directory:true
6/6activeだがシリアル=0TLSハンドシェイクの失敗 / DNS不通journalctl -u otelcol-fleet でOTLPのエラーを確認

3. remove-server.sh: 完全な削除

# サービスの停止と設定の削除 → 証明書の失効 → DIRECTルールの削除 → SSH設定の削除 → 完了
./remove-server.sh <ssh-host> [fleet-name] [--purge]

--purge を指定すると、リモートのバイナリとディスク上のキューも追加で削除されます。fleet-name を指定しない場合、SSH設定のHostNameから逆引きします。

1/4 サービスの停止と設定の削除

ssh $HOST 'systemctl disable --now otelcol-fleet 2>/dev/null || true
rm -f /etc/systemd/system/otelcol-fleet.service
rm -rf /etc/otelcol-fleet
systemctl daemon-reload'
# --purge: 追加で rm /usr/local/bin/otelcol-contrib + /var/lib/otelcol-fleet

2/4 証明書の失効

# CNを ca/out/revoked.txt に追記(重複除去)
# sync-revoked.sh: 各行ごとに "abort CN <name>" を生成 → revoked.caddy
# systemctl reload caddy → 即時有効化、証明書が有効期限内でも403を返す
"$CA_DIR/revoke-client-cert.sh" "$NAME"

3/4 DIRECTルールの削除(両側で冪等)

mihomo_remove_direct() {
  local ip="$1"
  # ローカルマシン: grep→存在しなければスキップ; 存在すればsedで削除 + APIでホットリロード
  grep -qF "${ip}/32" "$DESKTOP_MIHOMO_CONFIG" 2>/dev/null || return
  sed -i "/${ip//./\\.}\\/32.*DIRECT,no-resolve/d" "$DESKTOP_MIHOMO_CONFIG"
  curl -sf -X PUT "http://127.0.0.1:9999/configs?force=true" \
    -H "Authorization: Bearer ${DESKTOP_MIHOMO_SECRET}" \
    -d "{\"path\":\"${DESKTOP_MIHOMO_CONFIG}\"}" >/dev/null
  # N100も同様、ssh grep+sed+reload
}

4/4 SSH記録のクリーンアップ

# ~/.ssh/config: Host <name> ブロック(子インデント行を含む)を削除
sed -i '/^Host '"$name"'$/,/^[^[:space:]]/d' ~/.ssh/config
# known_hosts: ssh-keygen -R <name> + ssh-keygen -R <ip>

Prometheusの履歴データ

既存のシリアルはstaleness(5分)により自動的に期限切れとなり、Grafanaのドロップダウンから自動的に消えます。即座に削除する必要がある場合は、admin APIを有効にして POST /api/v1/admin/tsdb/delete_series を実行します。

4. 関連ファイル

otelcol-fleet.service

[Unit]
Description=OpenTelemetry Collector (metrics-fleet agent)
After=network-online.target

[Service]
Type=simple
EnvironmentFile=/etc/otelcol-fleet/env
ExecStart=/usr/local/bin/otelcol-contrib --config /etc/otelcol-fleet/config.yaml
Restart=on-failure
RestartSec=5
User=root
RuntimeDirectory=otelcol-fleet
StateDirectory=otelcol-fleet

[Install]
WantedBy=multi-user.target

host_metrics は /proc//sys を読む必要があるため、User=root が最も簡単です。権限を降下させる場合は、CAP_SYS_PTRACE + CAP_DAC_READ_SEARCH を追加で設定する必要があります。

命名規則

プレフィックスリージョン番号
li-home-ホームli-home-0(ローカルマシン), li-home-1(Windows), li-home-router(N100のサイドルート)
li-cn-node-中国国内N
li-us-node-米国N
li-jp-node-日本N

現在の対応状況: ホーム3台(ローカルマシン/Windows/N100)、中国国内2台(旧中継+腾讯云)、米国3台(VPS)、日本1台。

5. 付録: 完全なスクリプト

add-server.sh

#!/usr/bin/env bash
# 監視fleetにサーバーを追加(プッシュモデル: リモートで otel-collector hostmetrics を実行し、mTLSで自宅にプッシュ)。
# 裸のIPからのフルフロー対応、既存のSSHエイリアスも対応。全ステップ冪等: 完了済みのステップは静かにスキップ。
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
cd "$HERE"
source ./fleet.env
source ./mihomo-sync.sh

# ── パラメータ ──
DRY_RUN=0; PORT=22; REGION="auto"
INPUT=""; FLEET_NAME=""
for a in "$@"; do
  case "$a" in
    --port)   shift; PORT="$1";;
    --region) shift; REGION="$1";;
    --dry-run) DRY_RUN=1;;
    -*) echo "[!] 不明なオプション: $a" >&2; exit 1;;
    *) [[ -z "$INPUT" ]] && INPUT="$a" || FLEET_NAME="$a";;
  esac
done
[[ -n "$INPUT" ]] || { echo "使用方法: add-server.sh <IP|sshエイリアス> [表示名] [--port N] [--region auto|cn|us|jp]" >&2; exit 1; }

# ── 入力タイプ: IPかSSHエイリアスか ──
_is_ip() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
if _is_ip "$INPUT"; then
  IP="$INPUT"; BOOTSTRAP=1
else
  HOST="$INPUT"; BOOTSTRAP=0
fi
FLEET_NAME="${FLEET_NAME:-$INPUT}"

# ── 補助: 冪等な公開鍵の配置 ──
_deploy_key() {
  echo "==> 公開鍵の配置"
  if ssh -o ConnectTimeout=8 -o PasswordAuthentication=no -p "$PORT" "root@$IP" \
    'echo ok' 2>/dev/null | grep -q ok; then
    echo "    鍵認証が既に設定されています、スキップします"; return
  fi
  ssh-copy-id -o ConnectTimeout=15 -p "$PORT" "root@$IP"
  echo "    公開鍵のデプロイ完了"
}

# ── 補助: 冪等なホスト名の設定 ──
_set_hostname() {
  local name="$1"
  echo "==> リモートホスト名の設定: $name"
  local cur
  cur=$(ssh -o ConnectTimeout=10 -p "$PORT" "root@$IP" 'hostname' 2>/dev/null | tr -d '\r\n' || true)
  if [[ "$cur" == "$name" ]]; then echo "    既に $name です、スキップします"; return; fi
  ssh -o ConnectTimeout=10 -p "$PORT" "root@$IP" "hostnamectl set-hostname '$name'"
  echo "    $name に設定しました"
}

# ── 補助: 冪等なSSH設定の書き込み ──
_add_ssh_config() {
  local name="$1" ip="$2" port="$3"
  echo "==> ~/.ssh/config の同期"
  if grep -q "^Host $name$" ~/.ssh/config 2>/dev/null; then
    local existing_ip; existing_ip=$(grep -A5 "^Host $name$" ~/.ssh/config | awk '/HostName / {print $2; exit}')
    if [[ "$existing_ip" == "$ip" ]]; then
      echo "    Host $name$ip は既に存在します、スキップします"; return
    else
      echo "    [!] Host $name は存在しますが $existing_ip を指しています($ip ではありません)、書き込みをスキップします"; return
    fi
  fi
  cat >> ~/.ssh/config <<EOF

# metrics-fleet $(date +%Y-%m-%d)
Host $name
    HostName $ip
    Port $port
    User root
    IdentityFile ~/.ssh/id_ed25519
    StrictHostKeyChecking accept-new
EOF
  echo "    Host $name ($ip:$port) を追記しました"
}

# ── 補助: 自動命名の割り当て ──
_assign_name() {
  local region="$1" ip="$2"
  local existing; existing=$(grep -B5 "$ip" ~/.ssh/config 2>/dev/null \
    | grep '^Host ' | grep -v 'HostName' | sed 's/Host //' | head -1 || true)
  if [[ -n "$existing" ]]; then echo "$existing"; return; fi
  local max_n; max_n=$(grep -oP "Host li-${region}-node-\K\d+" ~/.ssh/config 2>/dev/null \
    | sort -n | tail -1 || echo 0)
  echo "li-${region}-node-$((max_n + 1))"
}

# ═══ Bootstrap: IP入力の場合のみ ═══
if [[ "$BOOTSTRAP" == 1 ]]; then
  echo "=== Bootstrap: 裸のIPからの開始 ==="
  [[ "$REGION" != "auto" ]] || { echo "[!] IPモードでは --region の指定が必要です" >&2; exit 1; }
  HOST=$(_assign_name "$REGION" "$IP")
  echo "    リージョン: $REGION | エイリアス: $HOST"
  FLEET_NAME="${FLEET_NAME:-$HOST}"
  _deploy_key
  _set_hostname "$HOST"
  _add_ssh_config "$HOST" "$IP" "$PORT"
  ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "$HOST" 'echo ok' >/dev/null
else
  echo "=== SSHエイリアスモード: $HOST ==="
  FLEET_NAME="${FLEET_NAME:-$HOST}"
fi

if [[ "$DRY_RUN" == 1 ]]; then
  echo; echo "[dry-run] 実行予定: 証明書の発行 + アーキテクチャの検出 + DIRECT + プッシュ + systemd + 正常性確認"
  exit 0
fi

# ═══ 監視参加 6ステップ(冪等) ═══
SSH="ssh -o ConnectTimeout=10 $HOST"
OTLP_ENDPOINT="https://${RELAY_HOST}:${RELAY_PORT}${OTLP_PATH}"

echo "==> [1/6] クライアント証明書の発行: $FLEET_NAME"
CERTD="$CA_DIR/out/clients/$FLEET_NAME"
if [[ -d "$CERTD" ]]; then echo "    証明書が既に存在します、スキップします"
else "$CA_DIR/issue-client-cert.sh" "$FLEET_NAME"; fi

echo "==> [2/6] リモートアーキテクチャの検出"
ARCH="$($SSH uname -m)"
case "$ARCH" in
  x86_64|amd64) GOARCH=amd64;;
  aarch64|arm64) GOARCH=arm64;;
  *) echo "[!] サポートされていないアーキテクチャ: $ARCH" >&2; exit 1;;
esac

echo "==> [3/6] mihomo DIRECTルール(ローカルマシン + N100)"
REMOTE_IP=$(resolve_host_ip "$HOST")
if [[ -n "$REMOTE_IP" ]] && is_public_ip "$REMOTE_IP"; then
  mihomo_add_direct "$REMOTE_IP" "metrics-fleet:$FLEET_NAME"
else
  echo "    プライベートネットワーク/解決不可、DIRECTをスキップします"
fi

echo "==> [4/6] バイナリ + 設定 + 証明書のプッシュ"
DISTFILES="$HOME/distfiles"
BIN="$DISTFILES/otelcol-contrib_${OTELCOL_VERSION}_linux_${GOARCH}"
if [[ ! -x "$BIN" ]]; then
  mkdir -p "$DISTFILES"
  TARURL="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTELCOL_VERSION}/otelcol-contrib_${OTELCOL_VERSION}_linux_${GOARCH}.tar.gz"
  tmp="$(mktemp -d)"
  curl -fSL "$TARURL" -o "$tmp/o.tgz"
  tar -xzf "$tmp/o.tgz" -C "$tmp" otelcol-contrib
  mv "$tmp/otelcol-contrib" "$BIN"
  chmod +x "$BIN"; rm -rf "$tmp"
fi
$SSH 'install -d -m 0750 /etc/otelcol-fleet'
scp -q "$BIN" "$HOST:/usr/local/bin/otelcol-contrib"
scp -q "$HERE/../otel/config.yaml" "$HOST:/etc/otelcol-fleet/config.yaml"
scp -q "$CERTD/client.crt" "$CERTD/client.key" "$CERTD/ca.crt" "$HOST:/etc/otelcol-fleet/"
$SSH 'chmod 0600 /etc/otelcol-fleet/client.key'
$SSH "cat > /etc/otelcol-fleet/env" <<EOF
FLEET_HOSTNAME=$FLEET_NAME
FLEET_OTLP_ENDPOINT=$OTLP_ENDPOINT
FLEET_COLLECT_INTERVAL=$FLEET_COLLECT_INTERVAL
EOF

echo "==> [5/6] systemdサービスのインストールと起動"
scp -q "$HERE/../otel/otelcol-fleet.service" "$HOST:/etc/systemd/system/otelcol-fleet.service"
$SSH 'systemctl daemon-reload && systemctl enable --now otelcol-fleet'

echo "==> [6/6] 正常性の確認"
sleep 3
if $SSH 'systemctl is-active --quiet otelcol-fleet'; then echo "    リモートサービス: active"
else echo "[!] 起動していません: ssh $HOST journalctl -u otelcol-fleet -n50" >&2; exit 1; fi
echo "    メトリクスが自宅に届くのを待っています..."
sleep 35
N="$(curl -s "http://127.0.0.1:9090/api/v1/query" \
  --data-urlencode "query=count({host_name=\"$FLEET_NAME\"})" \
  | grep -oP '"value":\[[^]]*,"\K[0-9]+' | head -1 || true)"
if [[ -n "${N:-}" && "$N" -gt 0 ]]; then
  echo "[+] 完了! Prometheusは $N 件のシリアルを受信しました。Grafanaのドロップダウンに $FLEET_NAME が自動的に表示されます。"
else
  echo "[!] リモートは起動していますがメトリクスを受信していません。確認: ssh $HOST journalctl -u otelcol-fleet -n50"
fi

remove-server.sh

#!/usr/bin/env bash
# サーバーの削除: collectorの停止 → 証明書の失効 → DIRECTルールの削除 → SSH設定の削除 → 完了。
# 全ステップ冪等、既に削除済みのものは静かにスキップ。
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
cd "$HERE"
source ./fleet.env
source ./mihomo-sync.sh

HOST="${1:?使用方法: remove-server.sh <ssh-host> [fleet-name] [--purge] [--dry-run]}"
NAME="$HOST"; PURGE=0; DRY_RUN=0
shift
for a in "$@"; do
  case "$a" in
    --purge) PURGE=1;;
    --dry-run) DRY_RUN=1;;
    *) NAME="$a";;
  esac
done
SSH="ssh -o ConnectTimeout=10 $HOST"
IP=$(resolve_host_ip "$HOST")

# ── 補助: SSH設定のクリーンアップ ──
_remove_ssh_config() {
  local name="$1"
  if ! grep -q "^Host $name$" ~/.ssh/config 2>/dev/null; then
    echo "    ~/.ssh/config: Host $name が見つかりません、スキップします"; return
  fi
  sed -i '/^Host '"$name"'$/,/^[^[:space:]]/{/^Host '"$name"'$/d;/^[[:space:]]/d}' ~/.ssh/config
  echo "    ~/.ssh/config から Host $name を削除しました"
}

# ═══ メインフロー ═══
echo "=== サーバーの削除: $NAME ($HOST) ==="

echo "==> [1/4] リモートcollectorの停止とアンインストール"
if [[ "$DRY_RUN" == 0 ]]; then
  $SSH 'systemctl disable --now otelcol-fleet 2>/dev/null || true
  rm -f /etc/systemd/system/otelcol-fleet.service
  rm -rf /etc/otelcol-fleet
  systemctl daemon-reload' 2>/dev/null || true
  echo "    collectorを停止し、設定をアンインストールしました"
  if [[ "$PURGE" == 1 ]]; then
    $SSH 'rm -f /usr/local/bin/otelcol-contrib; rm -rf /var/lib/otelcol-fleet' 2>/dev/null || true
    echo "    バイナリとキューをpurgeしました"
  fi
fi

echo "==> [2/4] クライアント証明書の失効: $NAME"
[[ "$DRY_RUN" == 0 ]] && "$CA_DIR/revoke-client-cert.sh" "$NAME"

echo "==> [3/4] mihomo DIRECTルールの削除"
if [[ -n "$IP" ]] && is_public_ip "$IP"; then
  mihomo_remove_direct "$IP"
else
  echo "    プライベートネットワーク/解決不可、スキップします"
fi

echo "==> [4/4] SSH記録のクリーンアップ"
_remove_ssh_config "$NAME"
ssh-keygen -R "$NAME" 2>/dev/null || true

echo "[+] $NAME は完全に削除されました (collector + 証明書 + DIRECT + SSH記録)。"
echo "    既存のメトリクスはstalenessにより自動的に期限切れになります。即座に削除する必要がある場合: Prometheus admin API delete_series。"

mihomo-sync.sh(DIRECTルール同期補助)

#!/usr/bin/env bash
# mihomo DIRECT IPルールの同期: ローカルマシン + N100のサイドルート。
# add-server.sh / remove-server.sh によってsourceされます。冪等。

# SSHエイリアス → IPの解決
resolve_host_ip() {
  local hostname; hostname=$(ssh -G "$1" 2>/dev/null | awk '/^hostname / {print $2; exit}')
  [[ -z "$hostname" ]] && { echo ""; return; }
  if [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "$hostname"; return
  fi
  getent ahosts "$hostname" 2>/dev/null | awk '{print $1; exit}'
}

# パブリックIPかどうかの判断
is_public_ip() {
  local ip="$1"
  [[ -z "$ip" ]] && return 1
  [[ "$ip" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|224\.) ]] && return 1
  return 0
}

# DIRECTルールの追加(両側で冪等)
mihomo_add_direct() {
  local ip="$1" desc="${2:-}"
  local comment=""; [[ -n "$desc" ]] && comment="   # $desc"
  local line="  - IP-CIDR,${ip}/32,DIRECT,no-resolve${comment}"
  # ローカルマシン
  if ! grep -qF "${ip}/32" "$DESKTOP_MIHOMO_CONFIG" 2>/dev/null; then
    echo "$SUDO_PASSWORD" | sudo -S sed -i \
      "/255\.255\.255\.255\/32,DIRECT,no-resolve/a\\${line}" "$DESKTOP_MIHOMO_CONFIG"
    curl -sf -X PUT "http://127.0.0.1:9999/configs?force=true" \
      -H "Authorization: Bearer ${DESKTOP_MIHOMO_SECRET}" \
      -d "{\"path\":\"${DESKTOP_MIHOMO_CONFIG}\"}" >/dev/null \
      && echo "    ローカルマシン: ${ip}/32 DIRECTを追加しました" || echo "    ローカルマシン: 設定は書き込み済み(ホットリロードに失敗)"
  else echo "    ローカルマシン: ${ip}/32 は既に存在します、スキップします"; fi
  # N100
  if ! ssh -o ConnectTimeout=8 "$N100_HOST" "grep -qF '${ip}/32' $N100_PROFILE" 2>/dev/null; then
    ssh -o ConnectTimeout=8 "$N100_HOST" \
      "sed -i \"/255\\.255\\.255\\.255\\/32,DIRECT,no-resolve/a\\${line}\" $N100_PROFILE \
       && (/etc/init.d/nikki reload 2>/dev/null || true)" 2>/dev/null \
      && echo "    N100: ${ip}/32 DIRECTを追加しました" || echo "    [!] N100に到達できません、後で手動で追加してください"
  else echo "    N100: ${ip}/32 は既に存在します、スキップします"; fi
}

# DIRECTルールの削除(両側で冪等)
mihomo_remove_direct() {
  local ip="$1"; local escaped; escaped=$(echo "$ip" | sed 's/\./\\./g')
  # ローカルマシン
  if grep -qF "${ip}/32" "$DESKTOP_MIHOMO_CONFIG" 2>/dev/null; then
    echo "$SUDO_PASSWORD" | sudo -S sed -i "/${escaped}\\/32.*DIRECT,no-resolve/d" "$DESKTOP_MIHOMO_CONFIG"
    curl -sf -X PUT "http://127.0.0.1:9999/configs?force=true" \
      -H "Authorization: Bearer ${DESKTOP_MIHOMO_SECRET}" \
      -d "{\"path\":\"${DESKTOP_MIHOMO_CONFIG}\"}" >/dev/null \
      && echo "    ローカルマシン: ${ip}/32 を削除しました" || echo "    ローカルマシン: 設定は書き込み済み(ホットリロードに失敗)"
  fi
  # N100
  if ssh -o ConnectTimeout=8 "$N100_HOST" "grep -qF '${ip}/32' $N100_PROFILE" 2>/dev/null; then
    ssh -o ConnectTimeout=8 "$N100_HOST" \
      "sed -i \"/${escaped}\\/32.*DIRECT,no-resolve/d\" $N100_PROFILE \
       && (/etc/init.d/nikki reload 2>/dev/null || true)" 2>/dev/null \
      && echo "    N100: ${ip}/32 を削除しました" || echo "    [!] N100に到達できません、後で手動で削除してください"
  fi
}

関連ドキュメント

  • monitoring.md — 監視アーキテクチャ(metrics-fleet リモートプッシュパスの詳細解説)
  • network-architecture.md — ネットワークトポロジとnftables構造
  • rathole-tunnel.md — トンネルによる接続のmihomo DIRECTルール迂回の原理