Ubuntu26.04+LXDでセットアップ2026年4月版

色々な環境で構築しているので、パスが分散していて少し分かりにくくなってきました。LXDコンテナ内にマウントする場所も、/mnt/lxd-dataになっているものや、/opt/lxd-dataになってたりと。Linuxの標準的なディレクトリ構成のルール(FHS: Filesystem Hierarchy Standard)に照らし合わせると/mnt/lxd-dataが正しいような気もしますが、大容量外部ディスクをマウントしているわけでもなく、永続的データを保存するという観点では/opt以下に全てまとめるのも理にかなっている気もします。
環境によって変わるのも混乱なので/opt以下に統一しようかと思います。
このブログ内もごちゃごちゃしていますが、いつかまとめられれば。

ここではUbuntu 26.04 betaにセットアップしています。基本的にLXDコンテナに閉じ込める設定にしています。

Tailscale

# アップデート
sudo apt update
sudo apt upgrade -y

# Tailscale
sudo apt install curl 
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

SSH

sudo apt install -y openssh-server

以降はSSHで。

SSH接続時にエラーの場合

下記の表示でエラーが出た場合。何度も接続先をやり直しているとローカルにある情報と違うための警告が表示されることがあります。

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! 

ローカルにある情報を削除します。そのあと接続すれば繋がるはず。

ssh-keygen -R ホスト名やIPアドレス

ノートPCをサーバにする場合

ロック画面でもデスクトップ共有

デスクトップ共有の場合、ロックされてもアクセス出来るように。
まずGNOMEのシェル拡張機能をインストール。

sudo apt install gnome-shell-extension-manager

「探す」で「Allow locked Remote Desktop」を検索してインストールすれば有効に。

Allow locked Remote Desktop

画面が閉じてもスリープしない設定

sudo sed -i 's/#HandleLidSwitch=suspend/HandleLidSwitch=ignore/' /etc/systemd/logind.conf
sudo reboot

Taildrop(ホストにインストール)

これはホスト側に入れておくと便利でしょう。受け取ったデータを/opt/lxd-data/taildropに保存することで、どのコンテナからも参照出来ます。サービス化で再起動しても自動実行されます。

TARGET_DIR="/opt/lxd-data/taildrop"

# ディレクトリが存在しない場合は作成
sudo mkdir -p "$TARGET_DIR"

# サービスファイル作成(sudo tee を使う)
sudo tee /etc/systemd/system/taildrop.service > /dev/null <<EOF
[Unit]
Description=Tailscale File Receiver (Taildrop)
After=network.target tailscaled.service
Requires=tailscaled.service

[Service]
Type=simple
ExecStart=tailscale file get --loop --conflict=rename $TARGET_DIR
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

# 有効化・起動
sudo systemctl daemon-reload
sudo systemctl enable taildrop
sudo systemctl start taildrop

# 確認(Ctrl+Cで終了)
sudo systemctl status taildrop

よく使うコマンド

# ログ確認
journalctl -u taildrop -f

# 停止・再起動
sudo systemctl stop taildrop
sudo systemctl restart taildrop

LXD-UI

# LXD インストール
sudo snap install lxd --channel=latest/stable

# 初期化
sudo lxd init --minimal

# HTTPS API を有効化
sudo lxc config set core.https_address :8443

# UI 有効化
sudo snap set lxd ui.enable=true
sudo systemctl reload snap.lxd.daemon

# ユーザーを lxd グループに追加
sudo usermod -aG lxd $USER

一度再起動

sudo reboot

LXD-UI https://ホスト名:8443

ベースコンテナを作成(最小、Docker入り)

lxd-base-minimal

lxd-base-minimalという名前でコンテナを作成。ディスクの追加でlxd-dataをマウント。

/opt/lxd-dataを作成してコンテナに追加

コンテナを作成したら、ホストとの連携用にDiskでマウント先を追加

/opt/lxd-data
/opt/lxd-data

パーミッションを設定

lxc config set lxd-base-minimal raw.idmap "both 1000 1000"
lxc restart lxd-base-minimal
sudo chown -R 1000:1000 /opt/lxd-data

コンテナに入って最小限のインストール

次のコマンドでコンテナに入る。

lxc exec lxd-base-minimal -- bash

コンテナ内で作業。

apt update && apt upgrade -y
apt install -y nano curl

# Tailscaleインストール
curl -fsSL https://tailscale.com/install.sh | sh
shutdown now

lxd-base-docker

lxd-base-minimalコンテナをコピーして作業。
こちらはNestingAllowにしてから起動します。

# 権限設定
lxc config set lxd-base-docker raw.idmap "both 1000 1000"
lxc restart lxd-base-docker
# コンテナに入る
lxc exec lxd-base-docker -- bash
# Dockerインストール
curl -fsSL https://get.docker.com | sh

#!/bin/bash
# Docker 関連用ディレクトリを作成
mkdir -p /opt/docker

# docker グループが存在する場合のみグループ設定
if getent group docker > /dev/null 2>&1; then
  chown -R "${USER:-root}:docker" /opt/docker
  chmod -R 775 /opt/docker
  chmod -R g+s /opt/docker

  # root以外のユーザーの場合のみ usermod を実行
  if [ "${USER:-root}" != "root" ]; then
    usermod -aG docker "$USER"
    echo "グループ変更を反映するには、newgrp docker を実行してください(再起動不要)"
  else
    echo "rootユーザーのため usermod はスキップしました"
  fi
else
  # docker グループがない場合はシンプルな権限設定
  chown -R "${USER:-root}:${USER:-root}" /opt/docker
  chmod -R 755 /opt/docker
  echo "docker グループが存在しないため、オーナー権限のみ設定しました"
fi

echo "/opt/docker のセットアップ完了"

ベースコンテナをコピーして使用

これでベースコンテナの作成は終了。必要に応じて、どちらかをコピーして利用。
共有フォルダが不要なら、ディスクから外す。
共有フォルダを利用するなら権限設定再起動が必要。

# 権限設定
lxc config set コンテナ名 raw.idmap "both 1000 1000"
lxc restart コンテナ名
# コンテナに入る
lxc exec コンテナ名 -- bash

コンテナに入ったら、Tailscaleを有効にする。何度もTailscaleの認証が必要になるのでAuthキーを取得しておくと作業が楽。Tailscaleの管理画面で「Settings」-「Keys」を選択。「Generate auth key…」を押して作成。何も入力せず作成すれば90日間有効で一度限りのキーが作成される。何度も作成するのは手間なので「Reusable」で再利用可能に。初回のみなので期限は最短にしておくと良いでしょう。

tailscale up --authkey=tskey-auth-xxxx

code-server

8089番ポートに変更

mkdir /opt/script -p
cd /opt/script
nano install-codeserver.sh
bash install-codeserver.sh
#!/bin/bash
# ============================================================
#  Code-Server ネイティブインストール
#  対象OS: Ubuntu 24.04 (LXD,Docker不使用)
#  実行方法: sudo bash install-codeserver.sh
#
#  起動後にブラウザで Japanese Language Pack をインストール後、
#  Ctrl+Shift+R でリロードすれば日本語UIになります。
# ============================================================

set -euo pipefail

GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info()    { echo -e "${CYAN}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC}   $*"; }
error()   { echo -e "${RED}[ERR]${NC}  $*"; exit 1; }

[[ $EUID -ne 0 ]] && error "root権限で実行してください: sudo bash $0"

REAL_USER="${SUDO_USER:-$(logname 2>/dev/null || echo $USER)}"
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
info "インストール対象ユーザー: $REAL_USER ($REAL_HOME)"

# --- パスワード自動生成 ---
info "パスワードを自動生成中..."
PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)

# --- 依存パッケージ ---
info "依存パッケージをインストール中..."
apt-get update -qq
apt-get install -y -qq curl wget locales

# --- 日本語ロケール ---
info "日本語ロケールを設定中..."
locale-gen ja_JP.UTF-8
update-locale LANG=ja_JP.UTF-8 LC_ALL=ja_JP.UTF-8
success "日本語ロケール設定完了"

# --- 最新バージョン取得 ---
info "最新バージョンを確認中..."
LATEST=$(curl -fsSL https://api.github.com/repos/coder/code-server/releases/latest \
  | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/')
info "最新バージョン: v${LATEST}"

# --- ダウンロード & インストール ---
DEB_URL="https://github.com/coder/code-server/releases/download/v${LATEST}/code-server_${LATEST}_amd64.deb"
info "ダウンロード中..."
wget -q --show-progress -O /tmp/code-server.deb "$DEB_URL"
dpkg -i /tmp/code-server.deb
rm /tmp/code-server.deb
success "code-server v${LATEST} インストール完了"

# --- config.yaml 生成 ---
CONFIG_DIR="$REAL_HOME/.config/code-server"
mkdir -p "$CONFIG_DIR"
cat > "$CONFIG_DIR/config.yaml" <<EOF
bind-addr: 0.0.0.0:8089
auth: password
password: ${PASSWORD}
cert: false
EOF
chown -R "$REAL_USER:$REAL_USER" "$CONFIG_DIR"
chmod 600 "$CONFIG_DIR/config.yaml"
success "config.yaml 生成完了"

# --- argv.json: 日本語化のキモ ---
info "argv.json を配置中..."
VSCODE_DIR="$REAL_HOME/.local/share/code-server/User"
mkdir -p "$VSCODE_DIR"
cat > "$VSCODE_DIR/argv.json" <<'EOF'
{
  "locale": "ja"
}
EOF
chown -R "$REAL_USER:$REAL_USER" "$REAL_HOME/.local/share/code-server"
success "argv.json 配置完了"

# --- systemd サービス登録 ---
info "systemd サービスを登録中..."
cat > /etc/systemd/system/code-server.service <<EOF
[Unit]
Description=code-server (VS Code in Browser)
After=network.target

[Service]
Type=exec
User=${REAL_USER}
WorkingDirectory=${REAL_HOME}
ExecStart=/usr/bin/code-server
Restart=always
RestartSec=5
Environment=LANG=ja_JP.UTF-8
Environment=LC_ALL=ja_JP.UTF-8
Environment=HOME=${REAL_HOME}

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable code-server
systemctl restart code-server
success "systemd サービス登録・起動完了"

# --- ファイアウォール ---
if ufw status 2>/dev/null | grep -q "Status: active"; then
  info "ufw でポート 8089 を開放中..."
  ufw allow 8089/tcp
  success "ポート開放済み"
fi

# --- 起動確認 ---
info "起動確認中..."
sleep 4
systemctl is-active --quiet code-server && success "code-server 正常起動" \
  || { echo ""; journalctl -u code-server -n 20 --no-pager; }

# --- 完了メッセージ ---
HOST_IP=$(hostname -I | awk '{print $1}')
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}  Code-Server インストール完了!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "  🌐 アクセスURL : ${CYAN}http://${HOST_IP}:8089${NC}"
echo -e "  🔑 パスワード  : ${YELLOW}${PASSWORD}${NC}"
echo ""
echo -e "  日本語化手順:"
echo -e "    1. Extensions で 'Japanese Language Pack' をインストール"
echo -e "    2. Ctrl+Shift+R でリロード → 日本語UIになります"
echo ""
echo -e "  起動    : ${CYAN}sudo systemctl start code-server${NC}"
echo -e "  停止    : ${CYAN}sudo systemctl stop code-server${NC}"
echo -e "  ログ確認: ${CYAN}journalctl -u code-server -f${NC}"
echo ""
echo -e "${YELLOW}  ⚠ 本番公開時はリバースプロキシ + HTTPS を推奨します${NC}"
echo ""
Japanese Language Pack

パスワードを変更したい場合は下記ファイルを編集してリスタート。

nano ~/.config/code-server/config.yaml
systemctl restart code-server

/optを追加。以降はcodeserverで。

TSDProxy

bash -c '
INSTALL_DIR="/opt/docker/tsdproxy"

echo ""
echo "========================================="
echo " TSDProxy セットアップ"
echo "========================================="
echo " Tailscale Auth Key を入力してください"
echo " (https://login.tailscale.com/admin/settings/keys)"
echo "========================================="
read -rp " Auth Key: " TS_AUTHKEY
if [ -z "$TS_AUTHKEY" ]; then
  echo "エラー: Auth Key が入力されていません。処理を中止します。"
  exit 1
fi

echo ""
echo "[1/4] ディレクトリを作成中..."
mkdir -p "${INSTALL_DIR}/config"
mkdir -p "${INSTALL_DIR}/data"

echo "[2/4] Auth Key ファイルを書き込み中..."
echo "${TS_AUTHKEY}" > "${INSTALL_DIR}/config/authkey"
chmod 600 "${INSTALL_DIR}/config/authkey"

echo "[3/4] docker-compose.yml を生成中..."
cat > "${INSTALL_DIR}/docker-compose.yml" << EOF
services:
  tsdproxy:
    image: almeidapaulopt/tsdproxy:1
    container_name: tsdproxy
    restart: unless-stopped
    ports:
      - "8081:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ${INSTALL_DIR}/data:/data
      - ${INSTALL_DIR}/config:/config
    environment:
      - TSDPROXY_AUTHKEYFILE=/config/authkey
EOF

echo "[4/4] TSDProxy を起動中..."
cd "${INSTALL_DIR}"
docker compose up -d

echo ""
sleep 3
if docker ps --filter "name=tsdproxy" --filter "status=running" | grep -q tsdproxy; then
  echo "========================================="
  echo " ✅ TSDProxy が正常に起動しました!"
  echo "========================================="
  echo " ダッシュボード: http://$(hostname -I | awk '"'"'{print $1}'"'"'):8081"
  echo " 設定ファイル:   ${INSTALL_DIR}/config/tsdproxy.yaml"
  echo " ログ確認:       docker logs -f tsdproxy"
  echo "========================================="
else
  echo "❌ 起動に失敗した可能性があります。ログを確認してください:"
  docker logs tsdproxy 2>&1 | tail -20
fi
'

基本的な書き方

services:
  hoarder:
    image: ghcr.io/hoarder-app/hoarder:latest
    container_name: hoarder
    restart: unless-stopped
    ports:
      - "3000:3000"
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "hoarder"          # Tailscale上のホスト名 → hoarder.<tailnet>.ts.net
      tsdproxy.containerport: "3000"    # コンテナ内部のポート番号

よく使うラベル一覧

ラベル必須説明
tsdproxy.enable: "true"TSDProxy管理下に置く
tsdproxy.name: "xxxxx"推奨Tailscaleホスト名(省略するとコンテナ名)
tsdproxy.containerport: "3000"推奨コンテナのポート(省略すると最初に公開されているポート)
tsdproxy.scheme: "http"任意http or https(デフォルトはhttps
tsdproxy.funnel: "true"任意Tailscale外のインターネットにも公開する場合

注意点

ports: の記載はなくてもTSDProxy経由ではアクセスできます。ホストから直接アクセスする必要がない場合は省略してセキュリティを高めることもできます。

Linkwarden

#!/bin/bash
set -euo pipefail

INSTALL_DIR="/opt/docker/linkwarden"
PORT=3300

# ── TailscaleのMagicDNS名を自動取得 ──────────────
TAILSCALE_URL=$(tailscale status --json | python3 -c "
import json, sys
data = json.load(sys.stdin)
dns = data.get('MagicDNSSuffix', '')
name = data.get('Self', {}).get('HostName', '')
print(f'https://linkwarden.{dns}' if dns else f'http://localhost:${PORT}')
")
echo "🌐 NEXTAUTH_URL: $TAILSCALE_URL"

# ── シークレットキー自動生成 ──────────────────────
NEXTAUTH_SECRET=$(openssl rand -base64 36)
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)

# ── インストールディレクトリ ──────────────────────
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"

# ── .env 生成 ────────────────────────────────────
cat > .env <<EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
NEXTAUTH_URL=${TAILSCALE_URL}
EOF
chmod 600 .env

# ── docker-compose.yml 生成 ──────────────────────
cat > docker-compose.yml <<EOF
services:
  postgres:
    image: postgres:16-alpine
    container_name: linkwarden-postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: linkwarden
      POSTGRES_USER: linkwarden
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U linkwarden -d linkwarden"]
      interval: 10s
      timeout: 5s
      retries: 5

  linkwarden:
    image: ghcr.io/linkwarden/linkwarden:latest
    container_name: linkwarden
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL: postgresql://linkwarden:\${POSTGRES_PASSWORD}@postgres:5432/linkwarden
    ports:
      - "${PORT}:3000"
    volumes:
      - linkwarden_data:/data/data
    depends_on:
      postgres:
        condition: service_healthy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "linkwarden"
      tsdproxy.containerport: "3000"

volumes:
  postgres_data:
  linkwarden_data:
EOF

# ── 起動 ─────────────────────────────────────────
docker compose pull
docker compose up -d

echo ""
echo "════════════════════════════════════════"
echo "  ✅  Linkwarden 起動完了"
echo "════════════════════════════════════════"
echo ""
echo "  🌐 Tailscale URL : $TAILSCALE_URL"
echo "  🌐 ローカル URL  : http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "  📂 インストール先: $INSTALL_DIR"
echo ""
echo "  ログ確認 : docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
echo "  停止     : docker compose -f $INSTALL_DIR/docker-compose.yml down"
echo "  更新     : docker compose -f $INSTALL_DIR/docker-compose.yml pull && docker compose -f $INSTALL_DIR/docker-compose.yml up -d"
echo ""

Vaultwarden

#!/bin/bash
set -euo pipefail

INSTALL_DIR="/opt/docker/vaultwarden"
PORT=8222

# ── TailscaleのMagicDNS名を自動取得 ──────────────
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
d = json.load(sys.stdin)
dns = d.get('MagicDNSSuffix', '')
print(f'vaultwarden.{dns}' if dns else '')
")
if [ -z "$TAILSCALE_DOMAIN" ]; then
    echo "ERROR: Tailscale MagicDNS名を取得できませんでした"
    exit 1
fi
echo "🌐 ドメイン: https://${TAILSCALE_DOMAIN}"

# ── インストールディレクトリ ──────────────────────
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"

# ── docker-compose.yml 生成 ──────────────────────
cat > docker-compose.yml <<EOF
services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: "https://${TAILSCALE_DOMAIN}"
      SIGNUPS_ALLOWED: "true"
    volumes:
      - ./data:/data
    ports:
      - "${PORT}:80"
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "vaultwarden"
      tsdproxy.containerport: "80"
EOF

# ── 起動 ─────────────────────────────────────────
docker compose pull
docker compose up -d

echo ""
echo "════════════════════════════════════════"
echo "  ✅  Vaultwarden 起動完了"
echo "════════════════════════════════════════"
echo ""
echo "  🌐 Tailscale URL : https://${TAILSCALE_DOMAIN}"
echo "  🌐 ローカル URL  : http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "  📂 インストール先: $INSTALL_DIR"
echo ""
echo "  ⚠️  アカウント作成後はSIGNUPS_ALLOWEDをfalseに変更してください"
echo "  sed -i 's/SIGNUPS_ALLOWED: \"true\"/SIGNUPS_ALLOWED: \"false\"/' $INSTALL_DIR/docker-compose.yml"
echo "  docker compose -f $INSTALL_DIR/docker-compose.yml up -d"
echo ""
echo "  ログ確認 : docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
echo "  停止     : docker compose -f $INSTALL_DIR/docker-compose.yml down"
echo "  更新     : docker compose -f $INSTALL_DIR/docker-compose.yml pull && docker compose -f $INSTALL_DIR/docker-compose.yml up -d"
echo ""

Outline

#!/bin/bash
set -euo pipefail
# =============================================================
#  Outline セットアップスクリプト (TSDProxy版)
#
#  構成:
#   - Outline     : https://outline.<tailnet>.ts.net      (TSDProxy)
#   - Dex (OIDC)  : https://outline-auth.<tailnet>.ts.net (TSDProxy)
#   - コンテナ間通信はhttp://dex:5556で直接通信
#
#  前提条件:
#   - Tailscale管理コンソールでHTTPS Certificatesを有効化済み
#     https://login.tailscale.com/admin/dns
# =============================================================

INSTALL_DIR="/opt/docker/outline"
OUTLINE_PORT=3900
DEX_PORT=5556

# ── TailscaleのMagicDNS名を自動取得 ──────────────
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
d = json.load(sys.stdin)
dns = d.get('MagicDNSSuffix', '')
print(dns if dns else '')
")
if [ -z "$TAILSCALE_DOMAIN" ]; then
    echo "ERROR: Tailscale MagicDNS名を取得できませんでした"
    exit 1
fi

BASE_URL="https://outline.${TAILSCALE_DOMAIN}"
DEX_URL="https://outline-auth.${TAILSCALE_DOMAIN}"
DEX_INTERNAL="http://dex:${DEX_PORT}"

echo "🌐 Outline URL : $BASE_URL"
echo "🌐 Dex URL     : $DEX_URL"

# ── ユーザー登録 ──────────────────────────────────
if ! command -v htpasswd &>/dev/null; then
    apt-get install -y apache2-utils &>/dev/null
fi

USERS_YAML=""
USER_COUNT=0
while true; do
    USER_COUNT=$((USER_COUNT + 1))
    echo ""
    echo "── ユーザー ${USER_COUNT} ──────────────────────"
    read -rp "ユーザー名(例: yamada): " U_NAME
    read -rsp "パスワード: " U_PASS
    echo ""
    U_HASH=$(htpasswd -bnBC 10 "" "${U_PASS}" | tr -d ':\n' | sed 's/\$2y/\$2a/')
    U_UUID=$(cat /proc/sys/kernel/random/uuid)
    U_EMAIL="${U_NAME}@local.invalid"
    USERS_YAML="${USERS_YAML}
  - email: \"${U_EMAIL}\"
    hash: \"${U_HASH}\"
    username: \"${U_NAME}\"
    userID: \"${U_UUID}\""
    read -rp "もう1人追加しますか? (y/N): " ADD_MORE
    [[ "$ADD_MORE" =~ ^[Yy]$ ]] || break
done

# ── シークレットキー生成 ──────────────────────────
SECRET_KEY=$(openssl rand -hex 32)
UTILS_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 16)
DEX_CLIENT_SECRET=$(openssl rand -hex 16)

# ── インストールディレクトリ ──────────────────────
mkdir -p "$INSTALL_DIR/dex/config"
mkdir -p "$INSTALL_DIR/data/storage"
chown -R 1001:1001 "$INSTALL_DIR/data/storage"
chown -R 1001:1001 "$INSTALL_DIR/dex"
cd "$INSTALL_DIR"

# ── Dex設定ファイル生成 ───────────────────────────
cat > dex/config/config.yaml <<EOF
issuer: ${DEX_URL}
storage:
  type: sqlite3
  config:
    file: /config/dex.db
web:
  http: 0.0.0.0:${DEX_PORT}
oauth2:
  skipApprovalScreen: true
  responseTypes:
    - code
staticClients:
  - id: outline
    name: "Outline Wiki"
    secret: "${DEX_CLIENT_SECRET}"
    redirectURIs:
      - "${BASE_URL}/auth/oidc.callback"
enablePasswordDB: true
staticPasswords:
${USERS_YAML}
logger:
  level: info
  format: text
EOF

# ── .env 生成 ────────────────────────────────────
cat > .env <<EOF
SECRET_KEY=${SECRET_KEY}
UTILS_SECRET=${UTILS_SECRET}
URL=${BASE_URL}
PORT=3000
FORCE_HTTPS=false
DATABASE_URL=postgres://outline:${POSTGRES_PASSWORD}@postgres:5432/outline?sslmode=disable
REDIS_URL=redis://redis:6379
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400
OIDC_CLIENT_ID=outline
OIDC_CLIENT_SECRET=${DEX_CLIENT_SECRET}
OIDC_AUTH_URI=${DEX_URL}/auth
OIDC_TOKEN_URI=${DEX_INTERNAL}/token
OIDC_USERINFO_URI=${DEX_INTERNAL}/userinfo
OIDC_DISPLAY_NAME=ログイン
OIDC_SCOPES=openid profile email offline_access
DEFAULT_LANGUAGE=ja_JP
LOG_LEVEL=info
EOF
chmod 600 .env

# ── docker-compose.yml 生成 ──────────────────────
cat > docker-compose.yml <<EOF
services:
  outline:
    image: outlinewiki/outline:latest
    container_name: outline
    restart: unless-stopped
    env_file: .env
    ports:
      - "${OUTLINE_PORT}:3000"
    volumes:
      - ./data/storage:/var/lib/outline/data
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      dex:
        condition: service_healthy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "outline"
      tsdproxy.containerport: "3000"

  dex:
    image: ghcr.io/dexidp/dex:latest
    container_name: outline-dex
    restart: unless-stopped
    command: dex serve /config/config.yaml
    ports:
      - "${DEX_PORT}:${DEX_PORT}"
    volumes:
      - ./dex/config:/config
    healthcheck:
      test: ["CMD-SHELL", "echo OK"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "outline-auth"
      tsdproxy.containerport: "${DEX_PORT}"

  postgres:
    image: postgres:16-alpine
    container_name: outline-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: outline
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: outline
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U outline"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: outline-redis
    restart: unless-stopped
    command: redis-server --save 60 1 --loglevel warning
    volumes:
      - ./data/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
EOF

# ── 起動 ─────────────────────────────────────────
docker compose pull
docker compose up -d

# ── TSDProxyをoutlineネットワークに接続 ───────────
echo ""
echo "⏳ TSDProxyをoutlineネットワークに接続中..."
sleep 5
docker network connect outline_default tsdproxy 2>/dev/null || true
docker restart tsdproxy
sleep 8

# ── 起動確認 ─────────────────────────────────────
echo ""
if docker compose ps | grep -E "^outline\s" | grep -q "Up"; then
    echo "════════════════════════════════════════"
    echo "  ✅  Outline 起動完了"
    echo "════════════════════════════════════════"
    echo ""
    echo "  🌐 Outline URL : $BASE_URL"
    echo "  🌐 Dex URL     : $DEX_URL"
    echo "  📂 インストール先: $INSTALL_DIR"
    echo ""
    echo "  ▼ 登録済みユーザー:"
    echo "$USERS_YAML" | grep 'email:' | sed 's/.*email: "\(.*\)"/    ログインID: \1/'
    echo ""
    echo "  ⏳ outline / outline-auth がTailscaleに登録されるまで"
    echo "     初回は1〜2分ほどかかります"
    echo "════════════════════════════════════════"
else
    echo "❌ 起動確認に失敗しました。ログを確認してください:"
    docker compose logs --tail=20
fi

# ── tsdproxy compose.ymlにネットワーク永続化 ──────
echo ""
echo "⏳ TSDProxy設定にoutlineネットワークを永続化中..."
TSDPROXY_COMPOSE="/opt/docker/tsdproxy/docker-compose.yml"

# networksブロックをservicesのtsdproxyに追加(未追加の場合のみ)
if ! grep -q "outline_default" "$TSDPROXY_COMPOSE"; then
    python3 - <<PYEOF
import re

with open("$TSDPROXY_COMPOSE", "r") as f:
    content = f.read()

# servicesブロック内のtsdproxyにnetworksを追加
content = re.sub(
    r'(  tsdproxy:(?:.*\n)*?)(  \w)',
    lambda m: m.group(1) + '    networks:\n      - default\n      - outline_default\n' + m.group(2)
    if 'networks:' not in m.group(1) else m.group(0),
    content
)

# ファイル末尾にnetworksブロックを追加
if 'outline_default:' not in content:
    content = content.rstrip() + '''

networks:
  default:
  outline_default:
    external: true
'''

with open("$TSDPROXY_COMPOSE", "w") as f:
    f.write(content)
print("✅ tsdproxy docker-compose.yml を更新しました")
PYEOF
else
    echo "✅ outline_defaultネットワークは既に設定済みです"
fi

echo ""
echo "════════════════════════════════════════"
echo "  ✅  セットアップ完了"
echo "════════════════════════════════════════"
echo ""
echo "  ログ確認 : docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
echo "  停止     : docker compose -f $INSTALL_DIR/docker-compose.yml down"
echo "  更新     : docker compose -f $INSTALL_DIR/docker-compose.yml pull && docker compose -f $INSTALL_DIR/docker-compose.yml up -d"
echo ""

ユーザー名@local.invalid

FreshRSS

#!/bin/bash
set -euo pipefail

INSTALL_DIR="/opt/docker/freshrss"
PORT=6060

# ── インストールディレクトリ & compose生成 ──────
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"

cat > docker-compose.yml <<EOF
services:
  freshrss:
    image: freshrss/freshrss:latest
    container_name: freshrss
    restart: unless-stopped
    ports:
      - "${PORT}:80"
    volumes:
      - freshrss_data:/var/www/FreshRSS/data
      - freshrss_extensions:/var/www/FreshRSS/extensions
    environment:
      TZ: Asia/Tokyo
      CRON_MIN: '*/15'
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "freshrss"
      tsdproxy.containerport: "80"

volumes:
  freshrss_data:
  freshrss_extensions:
EOF

# ── 起動 ────────────────────────────────────────
docker compose pull
docker compose up -d

echo "⏳ コンテナ起動待機中..."
sleep 10

# ── 拡張機能インストール ─────────────────────────
echo "📦 拡張機能をインストール中..."
apt-get install -y unzip &>/dev/null

# Three Panes View
curl -sL "https://framagit.org/nicofrand/xextension-threepanesview/-/archive/master/xextension-threepanesview-master.zip" -o /tmp/tpv.zip
unzip -q /tmp/tpv.zip -d /tmp/
docker cp /tmp/xextension-threepanesview-master freshrss:/var/www/FreshRSS/extensions/xExtension-ThreePanesView
docker exec freshrss chown -R www-data:www-data /var/www/FreshRSS/extensions/xExtension-ThreePanesView
rm -rf /tmp/tpv.zip /tmp/xextension-threepanesview-master

# AF Readability
curl -sL "https://github.com/Niehztog/freshrss-af-readability/archive/refs/heads/master.zip" -o /tmp/af.zip
unzip -q /tmp/af.zip -d /tmp/
docker cp /tmp/freshrss-af-readability-master freshrss:/var/www/FreshRSS/extensions/xExtension-af_readability
docker exec freshrss chown -R www-data:www-data /var/www/FreshRSS/extensions/xExtension-af_readability
rm -rf /tmp/af.zip /tmp/freshrss-af-readability-master

# ── data ディレクトリの権限を一括修正 ────────────
echo "🔐 権限を修正中..."
docker exec freshrss chown -R www-data:www-data /var/www/FreshRSS/data/
docker exec freshrss chown -R www-data:www-data /var/www/FreshRSS/extensions/

echo ""
echo "════════════════════════════════════════"
echo "  ✅  FreshRSS 起動完了"
echo "════════════════════════════════════════"
echo ""
echo "  🌐 Tailscale URL : https://freshrss.$(tailscale status --json | python3 -c "import json,sys; print(json.load(sys.stdin).get('MagicDNSSuffix',''))")"
echo "  🌐 ローカル URL  : http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "  📂 インストール先: $INSTALL_DIR"
echo ""
echo "  ⚠️  初回セットアップ時のURL設定は Tailscale URL を入力してください"
echo ""
echo "  ログ確認 : docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
echo "  停止     : docker compose -f $INSTALL_DIR/docker-compose.yml down"
echo "  更新     : docker compose -f $INSTALL_DIR/docker-compose.yml pull && docker compose -f $INSTALL_DIR/docker-compose.yml up -d"
echo ""

CSS

#stream {
  width: 280px !important;
  min-width: 280px !important;
}

ディスプレイ
・テーマ:Swage
・コンテンツ幅:最大幅
・Webサイト:アイコンのみ
・記事のアイコン:出版された日のチェックを両方外す

リーディング
・1ページ当たりの記事の数:50
・カテゴリを非表示 & 未読の記事がないフィード:チェックを外す
・記事を既読にする…すでに同一タイトルがフィード内上位n件の最新記事に存在するとき:チェック

ショートカット
・次の記事を開く:g
・次の未読の記事を開く:f
・前の記事を表示する:d

もしインポートしたフィードが既読にならない場合。エクスポートデータはrootが作成しているので権限の問題(新規作成記事は問題なし)なので、コンテナ内で以下を実行。

docker exec freshrss chown -R www-data:www-data /var/www/FreshRSS/data/
docker exec freshrss chown -R www-data:www-data /var/www/FreshRSS/extensions/

Dockhand

#!/bin/bash
set -euo pipefail

INSTALL_DIR="/opt/docker/dockhand"
PORT=3333

mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"

cat > docker-compose.yml <<EOF
services:
  dockhand:
    image: fnsys/dockhand:latest
    container_name: dockhand
    restart: unless-stopped
    ports:
      - "${PORT}:3000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - dockhand_data:/app/data
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "dockhand"
      tsdproxy.containerport: "3000"

volumes:
  dockhand_data:
EOF

docker compose pull
docker compose up -d

echo ""
echo "════════════════════════════════════════"
echo "  ✅  Dockhand 起動完了"
echo "════════════════════════════════════════"
echo ""
echo "  🌐 Tailscale URL : https://dockhand.$(tailscale status --json | python3 -c "import json,sys; print(json.load(sys.stdin).get('MagicDNSSuffix',''))")"
echo "  🌐 ローカル URL  : http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "  📂 インストール先: $INSTALL_DIR"
echo ""
echo "  ログ確認 : docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
echo "  停止     : docker compose -f $INSTALL_DIR/docker-compose.yml down"
echo "  更新     : docker compose -f $INSTALL_DIR/docker-compose.yml pull && docker compose -f $INSTALL_DIR/docker-compose.yml up -d"
echo ""

Immich(/opt/lxd-data使用)

重いので単独のコンテナにします。

# 権限設定
lxc config set immich raw.idmap "both 1000 1000"
lxc restart immich
# コンテナに入る
lxc exec immich -- bash
tailscale up --authkey=tskey-auth-xxxx
nano setup-immich.sh
# 下記スクリプトを貼り付け
bash setup-immich.sh
#!/bin/bash
set -e
WORK_DIR="/opt/docker/immich"
DATA_DIR="/opt/lxd-data/docker/immich"
mkdir -p "${WORK_DIR}"
mkdir -p "${DATA_DIR}"
cd "${WORK_DIR}"
# --- .env 生成(既存があればスキップ)---
if [ ! -f .env ]; then
  DB_PASSWORD=$(openssl rand -hex 16)
  cat > .env <<EOF
UPLOAD_LOCATION=${DATA_DIR}/library
DB_DATA_LOCATION=${DATA_DIR}/postgres
IMMICH_VERSION=release
DB_PASSWORD=${DB_PASSWORD}
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
EOF
  chmod 600 .env
  echo "[OK] .env を新規作成"
else
  echo "[SKIP] .env は既存のものを使用"
  DB_PASSWORD=$(grep DB_PASSWORD .env | cut -d= -f2)
fi
# --- docker-compose.yml 生成 ---
cat > docker-compose.yml <<'EOF'
name: immich
services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    volumes:
      - ${UPLOAD_LOCATION}:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    ports:
      - '127.0.0.1:2283:2283'
    depends_on:
      - redis
      - database
    restart: always
    healthcheck:
      disable: false
  immich-machine-learning:
    container_name: immich_machine_learning
    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
    volumes:
      - model-cache:/cache
    env_file:
      - .env
    restart: always
    healthcheck:
      disable: false
  redis:
    container_name: immich_redis
    image: docker.io/redis:6.2-alpine
    healthcheck:
      test: redis-cli ping || exit 1
    restart: always
  database:
    container_name: immich_postgres
    image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_DB: ${DB_DATABASE_NAME}
    volumes:
      - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
    healthcheck:
      test: >
        pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1;
        Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}'
        --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0)
        FROM pg_stat_database')";
        echo "checksum failure count is $$Chksum";
        [ "$$Chksum" = '0' ] || exit 1
      interval: 5m
      start_interval: 30s
      start_period: 5m
    restart: always
volumes:
  model-cache:
EOF
# --- Tailscale Serve 設定(冪等) ---
tailscale serve --bg 2283
# --- フルドメイン名取得 ---
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d['Self']['DNSName'].rstrip('.'))
")
# --- 起動 ---
docker compose up -d
echo ""
echo "======================================"
echo "  Immich 起動完了"
echo "======================================"
echo "  URL        : https://${TAILSCALE_DOMAIN}"
echo "  DB_PASSWORD: ${DB_PASSWORD}"
echo "  設定ファイル: ${WORK_DIR}/.env"
echo "  データ保存先: ${DATA_DIR}"
echo "======================================"
パス内容
/opt/docker/immich/docker-compose.ymlCompose定義
/opt/docker/immich/.env設定・パスワード
/opt/lxd-data/docker/immich/library/写真・動画データ
/opt/lxd-data/docker/immich/postgres/DBデータ

Nextcloud & Onlyoffice(/opt/lxd-data使用)

こちらも単独のコンテナ

# 権限設定
lxc config set nextcloud raw.idmap "both 1000 1000"
lxc restart nextcloud
# コンテナに入る
lxc exec nextcloud -- bash
tailscale up --authkey=tskey-auth-xxxx
#!/bin/bash
set -e

# === Tailscale フルドメイン自動取得 ===
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d['Self']['DNSName'].rstrip('.'))
")
echo "[OK] Tailscale ドメイン: ${TAILSCALE_DOMAIN}"

# === JWT シークレット自動生成 ===
JWT_SECRET=$(openssl rand -hex 32)

# === 共有ネットワーク作成(既存なら再利用) ===
docker network inspect onlyoffice_net >/dev/null 2>&1 \
  || docker network create onlyoffice_net
echo "[OK] ネットワーク onlyoffice_net を確認しました"

# === Tailscale Serve 設定 ===
tailscale serve --bg --https=443 8080 || true
tailscale serve --bg --https=444 9000 || true
echo "[OK] Tailscale Serve 設定完了 (443 → 8080)"

# ============================================================
# Nextcloud
# ============================================================
NEXTCLOUD_WORK_DIR="/opt/docker/nextcloud"
NEXTCLOUD_DATA_DIR="/opt/lxd-data/docker/nextcloud"
mkdir -p "${NEXTCLOUD_WORK_DIR}"
mkdir -p "${NEXTCLOUD_DATA_DIR}"/{userdata,db}

cat > "${NEXTCLOUD_WORK_DIR}/docker-compose.yml" <<EOF
services:
  db:
    image: mariadb:10.6
    container_name: nextcloud-db
    restart: always
    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
    volumes:
      - ${NEXTCLOUD_DATA_DIR}/db:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_PASSWORD: nextcloudpass
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
    networks:
      - default
  app:
    image: nextcloud:latest
    container_name: nextcloud-app
    restart: always
    ports:
      - "127.0.0.1:8080:80"
    depends_on:
      - db
    volumes:
      - nextcloud_app:/var/www/html
      - ${NEXTCLOUD_DATA_DIR}/userdata:/var/www/html/data
    environment:
      MYSQL_PASSWORD: nextcloudpass
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_HOST: nextcloud-db
      NEXTCLOUD_TRUSTED_DOMAINS: "${TAILSCALE_DOMAIN}"
      OVERWRITEHOST: "${TAILSCALE_DOMAIN}"
      OVERWRITEPROTOCOL: https
    networks:
      - default
      - onlyoffice_net
volumes:
  nextcloud_app:
networks:
  default:
  onlyoffice_net:
    external: true
EOF

cd "${NEXTCLOUD_WORK_DIR}"
docker compose up -d
echo "[1/2] Nextcloud 起動完了"

# ============================================================
# OnlyOffice
# ============================================================
ONLYOFFICE_WORK_DIR="/opt/docker/onlyoffice"
mkdir -p "${ONLYOFFICE_WORK_DIR}"

cat > "${ONLYOFFICE_WORK_DIR}/.env" <<EOF
JWT_SECRET=${JWT_SECRET}
EOF
chmod 600 "${ONLYOFFICE_WORK_DIR}/.env"

cat > "${ONLYOFFICE_WORK_DIR}/docker-compose.yml" <<EOF
services:
  onlyoffice-docs:
    image: onlyoffice/documentserver:latest
    container_name: onlyoffice-docs
    restart: always
    stdin_open: true
    tty: true
    ports:
      - "127.0.0.1:9000:80"
    environment:
      JWT_ENABLED: "true"
      JWT_SECRET: "${JWT_SECRET}"
    volumes:
      - onlyoffice_logs:/var/log/onlyoffice
      - onlyoffice_data:/var/www/onlyoffice/Data
      - onlyoffice_lib:/var/lib/onlyoffice
      - onlyoffice_db:/var/lib/postgresql
    networks:
      - onlyoffice_net
volumes:
  onlyoffice_logs:
  onlyoffice_data:
  onlyoffice_lib:
  onlyoffice_db:
networks:
  onlyoffice_net:
    external: true
EOF

cd "${ONLYOFFICE_WORK_DIR}"
docker compose up -d
echo "[2/2] OnlyOffice 起動完了"

# ============================================================
# 完了メッセージ
# ============================================================
echo ""
echo "======================================"
echo "  起動完了"
echo "======================================"
echo ""
echo "  Nextcloud  : https://${TAILSCALE_DOMAIN}"
echo "  OnlyOffice : 内部ネットワークのみ (http://onlyoffice-docs)"
echo ""
echo "======================================"
echo "  Nextcloud 管理画面 → ONLYOFFICE 設定"
echo "======================================"
echo ""
echo "  ONLYOFFICE Docs アドレス:"
echo "    https://${TAILSCALE_DOMAIN}:444"
echo ""
echo "  JWT シークレット: ${JWT_SECRET}"
echo ""
echo "  認証ヘッダー:(空白のまま)"
echo ""
echo "  サーバーから内部リクエストに利用されるONLYOFFICE Docs アドレス:"
echo "    http://onlyoffice-docs"
echo ""
echo "  ONLYOFFICE Docsから内部リクエストに利用されるサーバーアドレス:"
echo "    http://nextcloud-app"
echo ""
echo "======================================"
echo "  個別アップデート手順"
echo "======================================"
echo ""
echo "  Nextcloud のみ更新:"
echo "    cd ${NEXTCLOUD_WORK_DIR} && docker compose pull && docker compose up -d"
echo ""
echo "  OnlyOffice のみ更新:"
echo "    cd ${ONLYOFFICE_WORK_DIR} && docker compose pull && docker compose up -d"
echo ""

フォント追加

/opt/lxd-data/taildrop/fonts.zipがあると仮定。

cd /opt/lxd-data/taildrop
apt install unzip
unzip Fonts.zip
mv Fonts /opt/lxd-data

コンテナで作業

# 設定ファイル編集。下記1行を追加
cd /opt/docker/onlyoffice/
nano docker-compose.yml

OnlyOffice がフォントを探す標準パス(/usr/share/fonts 以下)にマウントします。

volumes:
      - /opt/lxd-data/Fonts:/usr/share/fonts/custom

そして、コンテナを再作成すれば多数のフォントを使えるようになります。

# コンテナ再作成(ボリューム変更を反映)
docker compose up -d --force-recreate onlyoffice-docs

# フォント再生成
docker exec -it onlyoffice-docs documentserver-generate-allfonts.sh

# フォント一覧確認
docker exec -it onlyoffice-docs fc-list

Syncthings

#!/bin/bash
# =============================================================================
# Syncthing 直インストールスクリプト
# 使い方: bash install-syncthing.sh  (rootで実行)
# =============================================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info()    { echo -e "${BLUE}[INFO]${NC}  $*"; }
success() { echo -e "${GREEN}[OK]${NC}    $*"; }
warn()    { echo -e "${YELLOW}[WARN]${NC}  $*"; }
error()   { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }

# ── root確認 ───────────────────────────────────────────────
[[ $EUID -ne 0 ]] && error "このスクリプトはrootで実行してください(sudo bash install-syncthing.sh)"

# ── Syncthing実行ユーザーの作成 ───────────────────────────
SYNCTHING_USER="syncthing"
if id "$SYNCTHING_USER" &>/dev/null; then
    info "ユーザー '$SYNCTHING_USER' はすでに存在します。スキップします。"
else
    info "ユーザー '$SYNCTHING_USER' を作成します..."
    useradd -r -m -s /bin/bash "$SYNCTHING_USER"
    success "ユーザー '$SYNCTHING_USER' を作成しました。"
fi

# www-dataグループに追加(Nextcloudファイルへのアクセス用)
usermod -aG www-data "$SYNCTHING_USER"
success "ユーザー '$SYNCTHING_USER' を www-data グループに追加しました。"

# ── config.xml パスを解決する関数 ─────────────────────────
find_config() {
    local home
    home=$(getent passwd "$SYNCTHING_USER" | cut -d: -f6)
    local candidates=(
        "$home/.local/state/syncthing/config.xml"
        "$home/.local/share/syncthing/config.xml"
        "$home/.config/syncthing/config.xml"
    )
    for p in "${candidates[@]}"; do
        [[ -f "$p" ]] && echo "$p" && return
    done
    find "$home" -name "config.xml" -path "*/syncthing/*" 2>/dev/null | head -1
}

# ── apt リポジトリ追加 & インストール ─────────────────────
info "Syncthing 公式リポジトリを追加します..."
mkdir -p /etc/apt/keyrings
curl -fsSL https://syncthing.net/release-key.gpg \
    | tee /etc/apt/keyrings/syncthing-archive-keyring.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/syncthing-archive-keyring.gpg] https://apt.syncthing.net/ syncthing stable" \
    | tee /etc/apt/sources.list.d/syncthing.list > /dev/null
apt-get update -q || true
apt-get install -y syncthing || error "Syncthing のインストールに失敗しました。"
success "Syncthing をインストールしました。"

# ── systemd サービス登録 ───────────────────────────────────
info "systemd サービスを登録します(ユーザー: $SYNCTHING_USER)..."
systemctl enable syncthing@$SYNCTHING_USER
systemctl start syncthing@$SYNCTHING_USER
success "Syncthing サービスを起動しました。"

# ── config.xml 生成待機(最大60秒)────────────────────────
info "config.xml の生成を待機中..."
CONFIG_FILE=""
for i in $(seq 1 30); do
    CONFIG_FILE=$(find_config)
    [[ -n "$CONFIG_FILE" ]] && break
    sleep 2
done
[[ -z "$CONFIG_FILE" ]] && error "config.xml が見つかりませんでした。"
success "config.xml を検出しました: $CONFIG_FILE"

# ── GUI をリモートアクセス可能に変更 ──────────────────────
info "GUI をリモートアクセス可能に設定します..."
systemctl stop syncthing@$SYNCTHING_USER
python3 - "$CONFIG_FILE" << 'PYEOF'
import xml.etree.ElementTree as ET, sys
tree = ET.parse(sys.argv[1])
root = tree.getroot()
gui = root.find('gui')
def set_or_create(parent, tag, text):
    el = parent.find(tag)
    if el is None:
        el = ET.SubElement(parent, tag)
    el.text = text
set_or_create(gui, 'address', '0.0.0.0:8384')
tree.write(sys.argv[1], encoding='unicode', xml_declaration=True)
print("GUIアドレスを 0.0.0.0:8384 に変更しました。")
PYEOF

# ── Nextcloud ディレクトリのパーミッション設定 ────────────
info "Nextcloud ディレクトリのパーミッションを設定します..."
NC_DATA="/opt/lxd-data/docker/nextcloud/data/data"

# Nextcloudのユーザーディレクトリを動的に検出
if [ -d "$NC_DATA" ]; then
    chmod o+rx /opt/lxd-data/docker/nextcloud
    chmod o+rx /opt/lxd-data/docker/nextcloud/data
    chmod o+rx "$NC_DATA"

    # NC_DATA配下のユーザーディレクトリを全て処理
    for USER_DIR in "$NC_DATA"/*/; do
        NC_USER=$(basename "$USER_DIR")
        # システムディレクトリはスキップ
        [[ "$NC_USER" == "appdata_"* ]] && continue
        [[ "$NC_USER" == "__groupfolders" ]] && continue
        [[ "$NC_USER" == "files_external" ]] && continue
        if [ -d "$USER_DIR/files" ]; then
            info "Nextcloudユーザー '$NC_USER' のパーミッションを設定中..."
            chmod o+rx "$USER_DIR"
            chmod -R o+rX "$USER_DIR/files"
            chmod o+w "$USER_DIR/files"
            success "  $USER_DIR/files のパーミッション設定完了"
        fi
    done
    success "Nextcloud パーミッション設定が完了しました。"
else
    warn "Nextcloudデータディレクトリが見つかりません: $NC_DATA"
    warn "Nextcloudセットアップ後に手動でパーミッションを設定してください。"
fi

# ── Syncthing 再起動 ───────────────────────────────────────
info "Syncthing を起動します..."
systemctl start syncthing@$SYNCTHING_USER
sleep 3

# ── Device ID 取得 ─────────────────────────────────────────
DEVICE_ID=$(sudo -u "$SYNCTHING_USER" syncthing --device-id 2>/dev/null \
    || echo "(GUIの「情報」から確認してください)")
HOST_IP=$(hostname -I | awk '{print $1}')

# ── 完了メッセージ ─────────────────────────────────────────
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}   Syncthing インストール完了!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "   Web UI        : ${BLUE}http://$HOST_IP:8384${NC}"
echo -e "   設定ファイル  : $CONFIG_FILE"
echo -e "   実行ユーザー  : $SYNCTHING_USER"
echo ""
echo -e "   Device ID(バックアップPCへの接続時に使用):"
echo -e "   ${YELLOW}$DEVICE_ID${NC}"
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "   次のステップ:"
echo -e "  1. Web UI にアクセスしてパスワードを設定"
echo -e "     (右上メニュー → Settings → GUI → GUI Authentication)"
echo -e "  2. フォルダを追加(ホストの絶対パスをそのまま入力)"
echo -e "     例: /opt/lxd-data/docker/immich/library/library/admin"

# 検出されたNextcloudユーザーを表示
if [ -d "$NC_DATA" ]; then
    for USER_DIR in "$NC_DATA"/*/; do
        NC_USER=$(basename "$USER_DIR")
        [[ "$NC_USER" == "appdata_"* ]] && continue
        [[ "$NC_USER" == "__groupfolders" ]] && continue
        [[ "$NC_USER" == "files_external" ]] && continue
        [ -d "$USER_DIR/files" ] && \
            echo -e "         /opt/lxd-data/docker/nextcloud/data/data/${NC_USER}/files"
    done
fi

echo -e "  3. 各フォルダのタイプを ${YELLOW}Send Only${NC} に設定"
echo -e "  4. バックアップPCと Device ID を交換して接続"
echo -e "  5. バックアップPC側を ${YELLOW}Receive Only${NC} + ${YELLOW}階段状バージョニング${NC} に設定"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""

# ── UFW 案内 ───────────────────────────────────────────────
if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then
    echo -e "${YELLOW} UFW が有効です。以下でポートを開放してください:${NC}"
    echo ""
    echo "  ufw allow 8384/tcp   # Web UI"
    echo "  ufw allow 22000/tcp  # Sync (TCP)"
    echo "  ufw allow 22000/udp  # Sync (QUIC)"
    echo "  ufw allow 21027/udp  # ローカル探索"
    echo ""
fi

直インストールのSyncthingアンインストール

nano uninstall-syncthing.sh
sudo bash uninstall-syncthing.sh
#!/bin/bash
# =============================================================================
# Syncthing アンインストールスクリプト
# 使い方: bash uninstall-syncthing.sh  (rootで実行)
# =============================================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info()    { echo -e "${BLUE}[INFO]${NC}  $*"; }
success() { echo -e "${GREEN}[OK]${NC}    $*"; }
warn()    { echo -e "${YELLOW}[WARN]${NC}  $*"; }
error()   { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }

[[ $EUID -ne 0 ]] && error "このスクリプトはrootで実行してください(sudo bash uninstall-syncthing.sh)"

SYNCTHING_USER="syncthing"

# ── サービス停止・無効化 ───────────────────────────────────
info "Syncthing サービスを停止します..."
systemctl stop syncthing@$SYNCTHING_USER 2>/dev/null && success "サービスを停止しました。" || warn "サービスはすでに停止しています。"
systemctl disable syncthing@$SYNCTHING_USER 2>/dev/null && success "サービスを無効化しました。" || warn "サービスはすでに無効化されています。"

# ── パッケージ削除 ─────────────────────────────────────────
info "Syncthing パッケージを削除します..."
apt-get remove -y syncthing
apt-get autoremove -y
success "パッケージを削除しました。"

# ── リポジトリ削除 ─────────────────────────────────────────
info "Syncthing リポジトリを削除します..."
rm -f /etc/apt/sources.list.d/syncthing.list
rm -f /etc/apt/keyrings/syncthing-archive-keyring.gpg
apt-get update -q || true
success "リポジトリを削除しました。"

# ── ユーザー・設定ファイル削除 ────────────────────────────
USER_HOME=$(getent passwd "$SYNCTHING_USER" | cut -d: -f6 2>/dev/null || echo "")

info "設定ファイルを削除します..."
if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then
    rm -rf "$USER_HOME/.local/state/syncthing"
    rm -rf "$USER_HOME/.local/share/syncthing"
    rm -rf "$USER_HOME/.config/syncthing"
    success "設定ファイルを削除しました。"
fi

info "ユーザー '$SYNCTHING_USER' を削除します..."
if id "$SYNCTHING_USER" &>/dev/null; then
    userdel -r "$SYNCTHING_USER" 2>/dev/null || userdel "$SYNCTHING_USER"
    success "ユーザーを削除しました。"
else
    warn "ユーザー '$SYNCTHING_USER' は存在しません。スキップします。"
fi

# ── 完了メッセージ ─────────────────────────────────────────
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}   Syncthing アンインストール完了!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${YELLOW}   注意: 以下は削除していません${NC}"
echo -e "   - 同期対象のデータファイル(/opt/lxd-data 配下など)"
echo -e "   - 各ディレクトリ内の .stfolder / .stignore"
echo -e "   必要に応じて手動で削除してください。"
echo ""

データは削除しないようにしています。.stfolderなど残骸を消したい場合は手動で。

find /opt/lxd-data -name ".stfolder" -type d -exec rm -rf {} + 2>/dev/null
find /opt/lxd-data -name ".stignore" -exec rm -f {} + 2>/dev/null

KonomiTV(/opt/lxd-data使用)

ホストにチューナードライバ適用

OS:Ubuntu 26.04 beta
TVチューナー:DTV02A-1T1S-UCOPY

mkdir -p /opt/dtv
cd /opt/dtv
DRIVER_VERSION="0.5.5"
DRIVER_DEB="px4-drv-dkms_${DRIVER_VERSION}_all.deb"
DRIVER_URL="https://github.com/tsukumijima/px4_drv/releases/download/v${DRIVER_VERSION}/${DRIVER_DEB}"

if [ ! -f "${DRIVER_DEB}" ]; then
    curl -L -o "${DRIVER_DEB}" "${DRIVER_URL}"
fi
sudo apt install -y "./${DRIVER_DEB}"
sudo modprobe px4_drv || true

# 認識しているか確認
sudo dmesg | grep -i isdbt

tvコンテナを作成

続いてコンテナを作成します。ここでは「tv」という名前のコンテナを作成しています。Dockerは使っていないので、lxd-base-minimalコンテナをコピーして作成します。パーミッションを設定します。

# 権限設定
lxc config set tv raw.idmap "both 1000 1000"
lxc restart tv
# コンテナに入る
lxc exec tv -- bash
# Serve機能は不使用
tailscale up --authkey=tskey-auth-xxxx

USB接続のTVチューナーをコンテナにパススルー

Tailscaleを有効にしたら、次はUSBチューナーをパススルーします。
ホスト側で下記を実行。

#!/bin/bash
# =============================================================
# チューナーデバイスをLXDコンテナ「tv」にパススルーするスクリプト
# 実行方法: sudo bash lxd_passthrough.sh
# =============================================================
set -e
CONTAINER="tv"

# ============================================================
# USB デバイス(vendorid / productid)
# ============================================================
echo "=== USB チューナーを検出中 ==="
LSUSB_LINE=$(lsusb | grep -i "ISDBT2056" || true)
if [ -z "$LSUSB_LINE" ]; then
    echo "エラー: ISDBT2056 デバイスが見つかりません。接続を確認してください。"
    exit 1
fi
echo "検出: $LSUSB_LINE"

# ID xxxx:xxxx を抽出
IDS=$(echo "$LSUSB_LINE" | grep -oP 'ID \K[0-9a-fA-F]{4}:[0-9a-fA-F]{4}')
VENDOR_ID=$(echo "$IDS" | cut -d: -f1)
PRODUCT_ID=$(echo "$IDS" | cut -d: -f2)
echo "  vendorid : $VENDOR_ID"
echo "  productid: $PRODUCT_ID"

# すでに登録済みなら上書き(remove → add)
if lxc config device show "$CONTAINER" | grep -q "usb-tuner"; then
    echo "usb-tuner は登録済みのため上書きします"
    lxc config device remove "$CONTAINER" usb-tuner
fi
lxc config device add "$CONTAINER" usb-tuner usb \
    vendorid="$VENDOR_ID" \
    productid="$PRODUCT_ID"
echo "usb-tuner を追加しました"

# ============================================================
# /dev/bus/usb/BUS/DEV パスを動的に解決してパススルー
# (バス番号がOS更新で変わっても自動追従)
# ============================================================
echo ""
echo "=== USB バスデバイスノードを検出中 ==="

# lsusb の出力からバス番号とデバイス番号を抽出
# 例: "Bus 003 Device 002: ID 0511:004b ..."
BUS_NUM=$(echo "$LSUSB_LINE" | grep -oP 'Bus \K[0-9]+')
DEV_NUM=$(echo "$LSUSB_LINE" | grep -oP 'Device \K[0-9]+')

if [ -n "$BUS_NUM" ] && [ -n "$DEV_NUM" ]; then
    # ゼロ埋め3桁に正規化(例: 3 -> 003)
    BUS_PATH=$(printf "%03d" "$BUS_NUM")
    DEV_PATH=$(printf "%03d" "$DEV_NUM")
    USB_NODE="/dev/bus/usb/${BUS_PATH}/${DEV_PATH}"

    if [ -e "$USB_NODE" ]; then
        echo "  検出: $USB_NODE"
        NAME="usb-bus-node"
        if lxc config device show "$CONTAINER" | grep -q "^${NAME}:"; then
            lxc config device remove "$CONTAINER" "$NAME"
        fi
        lxc config device add "$CONTAINER" "$NAME" unix-char \
            source="$USB_NODE" \
            path="$USB_NODE"
        echo "  $NAME を追加しました ($USB_NODE)"
    else
        echo "  警告: $USB_NODE が存在しません。スキップします。"
    fi
else
    echo "  警告: バス/デバイス番号を特定できませんでした。スキップします。"
fi

# ============================================================
# isdb2056videoN デバイス
# ============================================================
echo ""
echo "=== isdb2056video デバイスを検出中 ==="
ISDB_DEVS=$(ls /dev/isdb2056video* 2>/dev/null || true)
if [ -z "$ISDB_DEVS" ]; then
    echo "警告: /dev/isdb2056video* が見つかりません。スキップします。"
else
    IDX=0
    for DEV in $ISDB_DEVS; do
        NAME="isdb2056-${IDX}"
        echo "  追加: $DEV -> コンテナ内 $DEV ($NAME)"
        if lxc config device show "$CONTAINER" | grep -q "^${NAME}:"; then
            lxc config device remove "$CONTAINER" "$NAME"
        fi
        lxc config device add "$CONTAINER" "$NAME" unix-char \
            source="$DEV" \
            path="$DEV"
        IDX=$(( IDX + 1 ))
    done
fi

# ============================================================
# DVB デバイス
# ============================================================
echo ""
echo "=== DVB デバイスを検出中 ==="
DVB_DEVS=$(find /dev/dvb -type c 2>/dev/null || true)
if [ -z "$DVB_DEVS" ]; then
    echo "DVB デバイスは見つかりませんでした。スキップします。"
else
    IDX=0
    for DEV in $DVB_DEVS; do
        # /dev/dvb/adapter0/frontend0 -> dvb-adapter0-frontend0
        NAME="dvb-$(echo "$DEV" | sed 's|/dev/dvb/||; s|/|-|g')"
        echo "  追加: $DEV -> コンテナ内 $DEV ($NAME)"
        if lxc config device show "$CONTAINER" | grep -q "^${NAME}:"; then
            lxc config device remove "$CONTAINER" "$NAME"
        fi
        lxc config device add "$CONTAINER" "$NAME" unix-char \
            source="$DEV" \
            path="$DEV"
        IDX=$(( IDX + 1 ))
    done
fi

# ============================================================
# 結果確認
# ============================================================
echo ""
echo "=== 登録済みデバイス一覧 ==="
lxc config device show "$CONTAINER"
echo ""
echo "完了しました。"

コンテナ内で確認

コンテナに入って認識しているか確認します。

# コンテナに入る
lxc exec tv -- bash
# チェックスクリプト作成
nano check_tuner.sh
# 下記を貼り付け
bash check_tuner.sh
#!/bin/bash
# =============================================================
# チューナーデバイスの存在確認スクリプト
# コンテナ内・ホスト両方で使用可能
# 実行方法: bash check_tuner.sh
# =============================================================

EXIT_CODE=0

# ============================================================
# isdb2056videoN デバイス確認
# ============================================================
echo "=== isdb2056video デバイス確認 ==="
ISDB_DEVS=$(ls /dev/isdb2056video* 2>/dev/null || true)
if [ -z "$ISDB_DEVS" ]; then
    echo "  NG: /dev/isdb2056video* が見つかりません"
    EXIT_CODE=1
else
    for DEV in $ISDB_DEVS; do
        echo "  OK: $DEV"
    done
fi

# ============================================================
# /dev/bus/usb 確認(バス番号不定に対応)
# ============================================================
echo ""
echo "=== USB バスデバイスノード確認 ==="

# ISDBT2056 の vendor / product
# udevadm の PRODUCT= は先頭ゼロなし16進数 (例: PRODUCT=511/4b/100)
VENDOR="511"
PRODUCT="4b"

FOUND_USB=""

if [ -d /dev/bus/usb ]; then
    for BUS_DIR in /dev/bus/usb/*/; do
        for DEV_NODE in "${BUS_DIR}"*; do
            [ -c "$DEV_NODE" ] || continue

            # E: PRODUCT=vvvv/pppp/rrrr 形式から vendor/product を取得
            PRODUCT_LINE=$(udevadm info "$DEV_NODE" 2>/dev/null \
                | grep '^E: PRODUCT=' || true)
            V=$(echo "$PRODUCT_LINE" | grep -oP 'PRODUCT=\K[^/]+')
            P=$(echo "$PRODUCT_LINE" | grep -oP 'PRODUCT=[^/]+/\K[^/]+')

            if [ "$(echo "$V" | tr '[:upper:]' '[:lower:]')" = "$VENDOR" ] && \
               [ "$(echo "$P" | tr '[:upper:]' '[:lower:]')" = "$PRODUCT" ]; then
                FOUND_USB="$DEV_NODE"
                break 2
            fi
        done
    done
fi

if [ -n "$FOUND_USB" ]; then
    echo "  OK: $FOUND_USB  (vendor=$VENDOR product=$PRODUCT)"
else
    echo "  NG: ISDBT2056 に対応する /dev/bus/usb ノードが見つかりません"
    EXIT_CODE=1
fi

# ============================================================
# 結果サマリ
# ============================================================
echo ""
if [ "$EXIT_CODE" -eq 0 ]; then
    echo "=== 結果: すべてのデバイスが確認できました ✓ ==="
else
    echo "=== 結果: 一部のデバイスが見つかりません ✗ ==="
fi

exit "$EXIT_CODE"

Mirakurun、EDCB、KonomiTVセットアップ

認識が問題なければセットアップを開始。

#!/bin/bash
# =============================================================
# DTV環境セットアップスクリプト
# 対応環境: Ubuntu Desktop / LXDコンテナ (Ubuntu 25.10)
# 実行方法: sudo bash install_dtv.sh
# =============================================================
set -e
# root チェック
if [ "$(id -u)" -ne 0 ]; then
    echo "エラー: このスクリプトは root で実行してください。"
    echo "  sudo bash $0"
    exit 1
fi
IS_LXD=false
if systemd-detect-virt --container 2>/dev/null | grep -qi lxc; then
    IS_LXD=true
fi
echo "実行環境: $([ "$IS_LXD" = true ] && echo 'LXDコンテナ' || echo 'Ubuntu Desktop/VM')"
REAL_USER="${SUDO_USER:-$(logname 2>/dev/null || id -un)}"
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
DTV_DIR="$REAL_HOME/dtv"
echo "作業ユーザー: $REAL_USER  ホーム: $REAL_HOME"
echo "=== 1/8: キー設定 ==="
echo "キーの内容を貼り付けてください(入力後、Enter を 2 回押すと確定します):"
BCAS_CONTENT=$(sed '/^$/q')
echo "=== 2/8: 依存パッケージをインストール中 ==="
apt update
apt install -y \
    autoconf automake cmake libtool libpcsclite-dev \
    git build-essential pkg-config curl wget \
    libclang-dev libdvbv5-dev libudev-dev \
    nodejs npm ffmpeg liblua5.2-dev lua-zlib g++ make gcc
export NVM_DIR="/root/.nvm"
if [ ! -d "$NVM_DIR" ]; then
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
fi
source "$NVM_DIR/nvm.sh"
nvm install 18
nvm use 18
nvm alias default 18
NODE18_BIN=$(dirname "$(nvm which 18)")
export PATH="$NODE18_BIN:$PATH"
echo "Node.js バージョン確認: $(node -v)  npm: $(npm -v)"
npm install -g pm2 || true
echo "=== 3/8: ドライバをセットアップ中 ==="
mkdir -p "$DTV_DIR" && cd "$DTV_DIR"
DRIVER_VERSION="0.5.5"
DRIVER_DEB="px4-drv-dkms_${DRIVER_VERSION}_all.deb"
[ ! -f "$DRIVER_DEB" ] && curl -L -o "$DRIVER_DEB" \
    "https://github.com/tsukumijima/px4_drv/releases/download/v${DRIVER_VERSION}/${DRIVER_DEB}"
apt install -y "./${DRIVER_DEB}" || echo "警告: px4_drv dkms インストール失敗(LXDでは正常)"
modprobe px4_drv 2>/dev/null || echo "警告: px4_drv modprobe スキップ(LXDでは正常)"
echo "デバイスパーミッションを設定中..."
if [ "$IS_LXD" = true ]; then
    chmod 666 /dev/isdb2056video* 2>/dev/null && echo "isdb2056: OK" || echo "isdb2056: デバイスなし"
    chmod 666 /dev/px4video*      2>/dev/null && echo "px4video: OK"  || echo "px4video: デバイスなし"
else
    tee /etc/udev/rules.d/99-dtv.rules > /dev/null <<'UDEV'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0511", MODE="0666"
KERNEL=="isdb2056video*", MODE="0666", GROUP="video"
KERNEL=="px4video*",      MODE="0666", GROUP="video"
UDEV
    udevadm control --reload-rules
    udevadm trigger
    sleep 2
    chmod 666 /dev/isdb2056video* 2>/dev/null && echo "isdb2056: OK" || echo "isdb2056: デバイスなし"
    chmod 666 /dev/px4video*      2>/dev/null && echo "px4video: OK"  || echo "px4video: デバイスなし"
    usermod -aG video "$REAL_USER" || true
fi
if ! ls /dev/isdb2056video* /dev/px4video* 2>/dev/null | grep -q .; then
    echo "警告: TVチューナーデバイスが見つかりません。"
    if [ "$IS_LXD" = true ]; then
        echo "  LXDのプロファイルでデバイスパススルーが設定されているか確認してください。"
        echo "  例: lxc config device add <container> isdb2056video0 unix-char path=/dev/isdb2056video0"
    else
        echo "  チューナーが正しく接続・認識されているか確認してください。"
    fi
fi
echo "=== 4/8: 復号ライブラリのビルド ==="
cd "$DTV_DIR"
for repo in libyakisoba libsobacas; do
    [ ! -d "$repo" ] && git clone "https://github.com/tsunoda14/${repo}.git"
    cd "$repo"
    autoreconf -i
    mkdir -p build && cd build
    [ "$repo" = "libyakisoba" ] && ../configure --sysconfdir=/usr/local/etc || ../configure
    make -j"$(nproc)"
    make install
    cd "$DTV_DIR"
done
ldconfig
echo "=== 5/8: Rust と recisdb のビルド ==="
export CARGO_HOME="/root/.cargo"
export RUSTUP_HOME="/root/.rustup"
if ! command -v cargo &>/dev/null; then
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
fi
source "$CARGO_HOME/env" 2>/dev/null || export PATH="$CARGO_HOME/bin:$PATH"
mkdir -p /usr/local/lib/pkgconfig
tee /usr/local/lib/pkgconfig/libsobacas.pc > /dev/null <<'EOF'
prefix=/usr/local
libdir=/usr/local/lib
includedir=/usr/include
Name: libsobacas
Description: PCSC compatible ECM decoder library
Version: 0.0.0
Libs: -L${libdir} -lsobacas
Cflags: -I${includedir}/PCSC
EOF
cd "$DTV_DIR"
[ ! -d recisdb-rs ] && git clone --recursive https://github.com/kazuki0824/recisdb-rs.git
cd recisdb-rs
sed -i 's/pcsclite/sobacas/g' b25-sys/build.rs
cargo build -F dvb --release
cp target/release/recisdb /usr/local/bin/
mkdir -p /usr/local/etc
echo "$BCAS_CONTENT" | tee /usr/local/etc/bcas_keys > /dev/null
echo "=== 6/8: チャンネルスキャンと Mirakurun 設定 ==="
wget -q https://github.com/tsukumijima/ISDBScanner/releases/download/v1.3.3/isdb-scanner \
    -O /usr/local/bin/isdb-scanner
chmod +x /usr/local/bin/isdb-scanner
npm install -g \
    --unsafe-perm \
    --foreground-scripts \
    --production \
    --ignore-engines \
    mirakurun@3.9.0-rc.4
mkdir -p /usr/local/etc/mirakurun
mirakurun start || true
sleep 5
mirakurun stop  || true
mkdir -p "$DTV_DIR/scanned"
if [ -z "$(ls -A "$DTV_DIR/scanned/" 2>/dev/null)" ]; then
    isdb-scanner "$DTV_DIR/scanned/"
fi
if [ ! -f "$DTV_DIR/scanned/Mirakurun/channels.yml" ]; then
    echo "エラー: チャンネルスキャンが完了しませんでした。"
    echo "デバイスのパススルー設定を確認してから再実行してください。"
    exit 1
fi
cp -a "$DTV_DIR/scanned/Mirakurun/channels.yml" /usr/local/etc/mirakurun/channels.yml
cp -a "$DTV_DIR/scanned/Mirakurun/tuners.yml"   /usr/local/etc/mirakurun/tuners.yml
chown -R root:root /usr/local/etc/mirakurun/
mirakurun start
echo "=== 7/8: EDCB セットアップ ==="
cd "$DTV_DIR"
[ ! -d EDCB ] && git clone https://github.com/xtne6f/EDCB
cd EDCB/Document/Unix
make -j"$(nproc)"
make install
make extra
make install_extra
mkdir -p /var/local/edcb
chown -R "$REAL_USER:$REAL_USER" /var/local/edcb
make setup_ini
cd "$DTV_DIR"
[ ! -d EDCB_Material_WebUI ] && git clone https://github.com/EMWUI/EDCB_Material_WebUI
cp -r EDCB_Material_WebUI/HttpPublic /var/local/edcb/
cp -r EDCB_Material_WebUI/Setting    /var/local/edcb/
[ ! -d BonDriver_LinuxMirakc ] && \
    git clone https://github.com/matching/BonDriver_LinuxMirakc.git --recurse-submodules
cd BonDriver_LinuxMirakc
make -j"$(nproc)"
cp BonDriver_LinuxMirakc.so     /usr/local/lib/edcb/
cp BonDriver_LinuxMirakc.so.ini_sample /usr/local/lib/edcb/BonDriver_LinuxMirakc.so.ini
cp "$DTV_DIR/scanned/EDCB-Wine/ChSet5.txt" /var/local/edcb/Setting/
cp "$DTV_DIR/scanned/EDCB-Wine/BonDriver_mirakc(BonDriver_mirakc).ChSet4.txt" \
   '/var/local/edcb/Setting/BonDriver_LinuxMirakc(LinuxMirakc).ChSet4.txt'
sed -i 's/^ALLOW_SETTING=.*/ALLOW_SETTING=true/' /var/local/edcb/HttpPublic/legacy/util.lua
# ============================================================
# 録画フォルダのセットアップ
# ホストマウントポイント /opt/lxd-data/tv を
# /var/local/edcb/HttpPublic/video にバインドマウント
# ============================================================
RECORD_SRC="/opt/lxd-data/tv"
RECORD_DST="/var/local/edcb/HttpPublic/video"
echo "録画フォルダをセットアップ中: $RECORD_SRC -> $RECORD_DST"
mkdir -p "$RECORD_SRC" "$RECORD_DST"
if grep -q "$RECORD_DST" /etc/fstab; then
    echo "fstab: $RECORD_DST は既に登録済みのためスキップします"
else
    echo "$RECORD_SRC $RECORD_DST none bind 0 0" >> /etc/fstab
    echo "fstab: $RECORD_DST を登録しました"
fi
if mountpoint -q "$RECORD_DST"; then
    echo "バインドマウント: $RECORD_DST は既にマウント済みです"
else
    mount --bind "$RECORD_SRC" "$RECORD_DST"
    echo "バインドマウント: $RECORD_DST をマウントしました"
fi
ISDB_DEV_COUNT=$(ls /dev/isdb2056video* 2>/dev/null | wc -l)
PX4_DEV_COUNT=$(ls /dev/px4video*       2>/dev/null | wc -l)
if [ "$ISDB_DEV_COUNT" -gt 0 ]; then
    TUNER_COUNT=$ISDB_DEV_COUNT
elif [ "$PX4_DEV_COUNT" -gt 0 ]; then
    TUNER_COUNT=$(( PX4_DEV_COUNT / 4 ))
    [ "$TUNER_COUNT" -eq 0 ] && TUNER_COUNT=1
else
    TUNER_COUNT=$(grep -c '^ *- name:' /usr/local/etc/mirakurun/tuners.yml 2>/dev/null || echo 1)
    [ "$TUNER_COUNT" -eq 0 ] && TUNER_COUNT=1
fi
echo "検出チューナー数: ${TUNER_COUNT}"
tee /var/local/edcb/EpgTimerSrv.ini > /dev/null <<EOT
[SET]
EnableHttpSrv=1
EnableTCPSrv=1
RecEndMode=0
Data=1
HttpAccessControlList=+127.0.0.0/8,+10.0.0.0/8,+172.16.0.0/12,+192.168.0.0/16,+169.254.0.0/16,+100.64.0.0/10
[TunerNum]
BonDriver_LinuxMirakc(LinuxMirakc).so=${TUNER_COUNT}
[BonDriver_LinuxMirakc.so]
Count=${TUNER_COUNT}
GetEpg=1
EPGCount=0
Priority=0
EOT
tee /var/local/edcb/Common.ini > /dev/null <<'EOT'
[SET]
RecFolderPath0=/var/local/edcb/HttpPublic/video
RecFolderNum=1
EOT
tee /etc/systemd/system/edcb.service > /dev/null <<EOT
[Unit]
Description=EpgTimerSrv
After=network-online.target
[Service]
Type=simple
User=$REAL_USER
ExecStart=/usr/local/bin/EpgTimerSrv
Restart=always
[Install]
WantedBy=default.target
EOT
systemctl daemon-reload
systemctl enable edcb
systemctl start edcb
echo "=== 8/8: KonomiTV インストール ==="
cd "$DTV_DIR"
curl -LO https://github.com/tsukumijima/KonomiTV/releases/latest/download/KonomiTV-Installer.elf
chmod +x KonomiTV-Installer.elf
./KonomiTV-Installer.elf
KONOMI_CONFIG=$(find /opt /usr/local /home -name "config.yaml" -path "*/KonomiTV/*" 2>/dev/null | head -1)
if [ -z "$KONOMI_CONFIG" ]; then
    echo "警告: KonomiTV の config.yaml が見つかりませんでした。パスを確認してください。"
else
    echo "KonomiTV config.yaml を更新中: $KONOMI_CONFIG"
    sed -i 's/always_receive_tv_from_mirakurun: false/always_receive_tv_from_mirakurun: true/' "$KONOMI_CONFIG"
    pm2 restart KonomiTV
fi
echo ""
echo "=== 環境構築が完了しました! ==="
echo ""
echo "録画ファイルの保存先:"
echo "  コンテナ内: $RECORD_DST"
echo "  ホスト側  : $RECORD_SRC"

ハードウェアエンコードが利用出来るかどうか確実ではない場合はいったんFFmpegにしておいたほうが無難。TVTestやEDCBの動作を確認したら切り替えてみて、切り替え出来るようなら利用可能です。

/opt/KonomiTV
tcp://127.0.0.1:4510/
FFmpeg
/var/local/edcb/HttpPublic/video

インストールが終了したら、KonomiTVを立ち上げてみます。番組表などはまだ受信されていないので、EDCBにアクセスして、EPGを受信します。

http://tv:5510

EPGを取得します。これにはかなり時間がかかります。下記を実行しtmpファイルが無くなれば受信完了。

ls /var/local/edcb/Setting/EpgData

EDCBの再起動は下記コマンドです。

sudo systemctl restart edcb

GPUをパススルーする

GPUのパススルーするは、まずLXD-UIのGPU項目でGPUを追加します。
続いて、コンテナ内に必要なファイルをインストールします。

Intel Media Driverのインストール(コンテナ側)

詳しくは作者の説明を見ると良いのですが、QSVEncを利用するなら Intel Media Driver のインストールが必要。Ubuntu 26.04では、旧来のIntel Media SDK (libmfx1) が廃止され、次世代のoneVPL (Video Processing Library) への移行が完了しています。そのため、パッケージ名が変更されたり、機能が統合されたりしています。そのため次のコマンドでインストールします。

sudo apt update
sudo apt install -y \
  intel-media-va-driver-non-free \
  intel-opencl-icd \
  libigfxcmrt7 \
  libmfx-gen1.2 \
  libvpl2 \
  libva-drm2 \
  libva-x11-2 \
  vainfo

# 表示されるか確認
vainfo

Cockpit(Tailscale Serve使用)

VMが必要ならCockpitが便利なのでホストにインストール

#!/bin/bash
set -e

# --- Cockpit + Tailscale セットアップ ---
sudo apt install -y nano cockpit

# 仮想マシンプラグイン
sudo apt install -y cockpit-machines

# --- Tailscale Serve 設定 ---

sudo tailscale serve --bg http://localhost:9090

# Tailscaleのフルドメイン名を自動取得
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
data = json.load(sys.stdin)
self = data.get('Self', {})
dns = self.get('DNSName', '').rstrip('.')
print(dns)
")

# CockpitのCSPにTailscaleドメインを許可(HTTPSアクセス対応)
sudo mkdir -p /etc/cockpit
sudo tee /etc/cockpit/cockpit.conf << EOF
[WebService]
AllowUnencrypted=true
Origins = https://${TAILSCALE_DOMAIN}
EOF

sudo systemctl restart cockpit

echo ""
echo "======================================"
echo " セットアップ完了!"
echo "======================================"
echo " アクセス先: https://${TAILSCALE_DOMAIN}"
echo "======================================"

デフォルトの場所(/var/lib/libvirt/images)よりもアクセスしやすいように変更。
Cockpitにアクセスし、仮想マシンページを開いたあとに下記を実行。

# ディレクトリ作成
sudo mkdir -p /opt/vm

# グループをlibvirtに統一
sudo chown root:libvirt /opt/vm

# setgidビット付与
sudo chmod 2775 /opt/vm

# 自分をlibvirtグループに追加
sudo usermod -aG libvirt $USER

# 再起動後も自動起動されるよう登録
sudo systemctl enable libvirtd

一度再起動。

sudo reboot

再ログイン後に実行。

# デフォルトプールとして登録
virsh pool-define-as default dir --target /opt/vm
virsh pool-build default
virsh pool-start default
virsh pool-autostart default

ISOをコピー

mv ~/iso/*.iso /opt/vm

Windows 11の場合

この方法でカスタマイズしたISOがある設定で、Windows 11を作成するスクリプト。

#!/bin/bash
VM_NAME="Win11"
ISO_PATH="/opt/vm/Win11-v.iso"
DISK_PATH="/opt/vm/Win11.qcow2"
DISK_SIZE="64"
RAM="4096"
VCPUS="4"

qemu-img create -f qcow2 "$DISK_PATH" "${DISK_SIZE}G"

virt-install \
  --name "$VM_NAME" \
  --ram "$RAM" \
  --vcpus "$VCPUS" \
  --cpu host-passthrough \
  --os-variant win11 \
  --disk path="$DISK_PATH",format=qcow2,bus=virtio \
  --cdrom "$ISO_PATH" \
  --network network=default,model=virtio \
  --graphics vnc,listen=127.0.0.1 \
  --video vga \
  --boot uefi,cdrom,hd \
  --features smm=on \
  --clock hypervclock_present=yes \
  --tpm backend.type=emulator,backend.version=2.0,model=tpm-crb \
  --noautoconsole

echo "VM '$VM_NAME' を作成しました。"

もしCockpitを使わないなら下記でもOK。

--graphics spice \
--video qxl \

なお、WIndowsのインストールが完了したら、virtio-win.isoをマウントしてゲストツールをインストールします。インストール時のスクリプトに下記を追加しておいても良いですね。
--disk path=/opt/vm/virtio-win.iso,device=cdrom \

virtio-win.isoが無いなら、サイトからゲストエージェントをダウンロード(現時点ではvirtio-win-guest-tools.exe)。

パスワードを設定した場合の自動ログイン設定

コマンドプロンプトを管理者で実行してレジストリに登録。

reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device" /v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f

次に、設定画面で設定。

control userpasswords2

qcow2ディスクの拡張

sudo qemu-img resize /opt/vm/Win11.qcow2 +60G

Ubuntu 24.04の場合

#!/bin/bash
VM_NAME="Ubuntu2404"
ISO_PATH="/opt/vm/ubuntu-24.04.4-desktop-amd64.iso"
DISK_PATH="/opt/vm/Ubuntu2404.qcow2"
DISK_SIZE="40"
RAM="4096"
VCPUS="4"

qemu-img create -f qcow2 "$DISK_PATH" "${DISK_SIZE}G"

virt-install \
  --name "$VM_NAME" \
  --ram "$RAM" \
  --vcpus "$VCPUS" \
  --cpu host-passthrough \
  --os-variant ubuntu24.04 \
  --disk path="$DISK_PATH",format=qcow2,bus=virtio \
  --cdrom "$ISO_PATH" \
  --network network=default,model=virtio \
  --graphics vnc,listen=127.0.0.1 \
  --video vga \
  --boot uefi,cdrom,hd \
  --noautoconsole

echo "VM '$VM_NAME' を作成しました。"

Win11との主な違い

Win11Ubuntu 24.04
--os-variantwin11ubuntu24.04
TPM必須不要
Secure Boot (smm=on)必須不要
ディスクサイズ64GB40GB

新しいOSを試すとき、ubuntu24.04 で通らなければ --os-variant detect=on,require=off でスキップできます

ちなみに、ゲストがUbuntuの場合は次のコマンドでゲストエージェントをインストール。

sudo apt install -y qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent
# エージェントが実行中か確認
systemctl status qemu-guest-agent

ファイラー Navigator プラグイン

sudo apt install -y git
git clone https://github.com/45Drives/cockpit-navigator.git
cd cockpit-navigator
git checkout v0.5.8
sudo apt install -y make
sudo make install
cd ..
rm -rf cockpit-navigator

Samba を Cockpit で管理

sudo apt install -y samba
wget https://github.com/45Drives/cockpit-file-sharing/releases/download/v4.5.3-4/cockpit-file-sharing_4.5.3-4jammy_all.deb
sudo apt install -y ./cockpit-file-sharing_4.5.3-4jammy_all.deb
rm cockpit-file-sharing_4.5.3-4jammy_all.deb

virt-managerもインストール

sudo apt install -y virt-manager
タイトルとURLをコピーしました