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

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

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

## 1. 使用方法

```bash
# 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` を含む）を追加し、初回接続時には手動で指紋を確認する必要があります。

```bash
_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` を使用し、各ステップは冪等性を持ちます。スクリプトの冒頭で入力タイプを判断します:

```bash
_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 クライアント証明書の発行**

```bash
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 リモートアーキテクチャの検出 + バイナリのダウンロード**

```bash
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プロキシを通過してしまいます:

```bash
# 解決: 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
}
```

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

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

```bash
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のインストールと起動**

```bash
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 正常性の確認**

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

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

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

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

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

```bash
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 証明書の失効**

```bash
# 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ルールの削除（両側で冪等）**

```bash
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記録のクリーンアップ**

```bash
# ~/.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

```ini
[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

```bash
#!/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

```bash
#!/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ルール同期補助）

```bash
#!/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](monitoring.md) — 監視アーキテクチャ（metrics-fleet リモートプッシュパスの詳細解説）
- [network-architecture.md](network-architecture.md) — ネットワークトポロジとnftables構造
- [rathole-tunnel.md](rathole-tunnel.md) — トンネルによる接続のmihomo DIRECTルール迂回の原理
