セルフホストしているサービスを手軽にブックマークして管理出来る「selfmark」

あとで読む系のサービスとしてLinkwardenを利用しており、通常のブックマーク管理にもある程度使っているのですが、ブックマーク管理だけだと少し機能が大袈裟となってしまいます。また、色々とセルフホストのテストなどを行なっていると何を公開しているのか分からなくなってきたりもします。これは管理が悪いだけなのですが。
なので、ブックマークを手軽に管理出来るサービスを作ってみました。
インストールした環境で公開しているサービス一覧を自動表示できるほか、Tailnet内のホストを指定して公開サービスを自動取得できます。手動追加機能や、タグによるグループ分け、並び替えにも対応させたので、普段使いのブックマーク管理としても利用出来るはず。

LXDコンテナ内にインストール

#!/usr/bin/env bash
# =============================================================================
#  selfmark — インストールスクリプト
#  - サーバで公開されているサービスを自動検出し一覧表示
#  - サービス名の編集・カテゴリー分け・並び替えが可能
#  - エクスポート/インポート機能付き
#  - ポート 3325 で公開
# =============================================================================
set -euo pipefail

INSTALL_DIR="/opt/selfmark"
SERVICE_NAME="selfmark"
PORT=3325
VENV_DIR="/opt/selfmark/venv"

info()  { echo -e "\033[1;34m[INFO]\033[0m  $*"; }
ok()    { echo -e "\033[1;32m[ OK ]\033[0m  $*"; }
warn()  { echo -e "\033[1;33m[WARN]\033[0m  $*"; }
die()   { echo -e "\033[1;31m[ERR ]\033[0m  $*" >&2; exit 1; }

# ── 前提チェック ───────────────────────────────────────────────────────────────
info "前提確認..."
command -v python3 >/dev/null 2>&1 || die "python3 が見つかりません"

# image-resize の venv を共有(なければ作成)
if [[ ! -f "${VENV_DIR}/bin/python" ]]; then
  warn "image-resize の venv が見つかりません。新規作成します..."
  mkdir -p "$(dirname "${VENV_DIR}")"

  # まずテストでvenv作成を試みる
  TMPDIR_VENV=$(mktemp -d)
  VENV_OK=false
  if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
    VENV_OK=true
  fi
  rm -rf "${TMPDIR_VENV}"

  # venvが使えない場合はインストール
  if [[ "${VENV_OK}" == "false" ]]; then
    warn "python3-venv が見つかりません。インストールします..."
    PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
    apt-get update -qq 2>/dev/null
    apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
    apt-get install -y -qq python3-venv 2>/dev/null || \
    die "python3-venv のインストールに失敗しました: sudo apt install python3-venv"
  fi

  python3 -m venv "${VENV_DIR}" || die "venv の作成に失敗しました"
  "${VENV_DIR}/bin/pip" install --quiet --upgrade flask
fi

# 最終確認
if ! "${VENV_DIR}/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
  die "venv が正しく作成されませんでした。手動でインストールしてください: sudo apt install python3-venv"
fi
ok "Python 環境 OK ($("${VENV_DIR}/bin/python" -c "import sys; print(sys.version)"))"

# ── ディレクトリ作成 ──────────────────────────────────────────────────────────
info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"

# ── アプリケーション生成 ──────────────────────────────────────────────────────
info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""selfmark Directory — 自動検出とブックマーク機能"""

import json
import os
import re
import socket
import ssl
import subprocess
from flask import Flask, render_template_string, request, jsonify

CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "services.json")
app = Flask(__name__)


def get_hostname():
    try:
        r = subprocess.run(
            ["tailscale", "status", "--json"],
            capture_output=True, text=True, timeout=5
        )
        d = json.loads(r.stdout)
        return d["Self"]["DNSName"].rstrip(".")
    except Exception:
        pass
    try:
        return subprocess.check_output(["hostname"], text=True).strip()
    except Exception:
        return "localhost"


def check_https(port):
    """ポートがHTTPSで応答するか確認(自己署名証明書も含む)"""
    try:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        sock = socket.create_connection(("127.0.0.1", port), timeout=2)
        ssock = ctx.wrap_socket(sock, server_hostname="127.0.0.1")
        ssock.close()
        return True
    except Exception:
        return False


def detect_services():
    services = []
    seen_ports = set()
    skip_procs = {"tailscaled", "systemd-resolve", "sshd", "systemd"}
    skip_ports = {22, 53, 3325}

    # Tailscale Serve のマッピングを取得
    ts_serve_map = {}
    try:
        r = subprocess.run(
            ["tailscale", "serve", "status"],
            capture_output=True, text=True, timeout=5
        )
        for line in r.stdout.strip().split("\n"):
            m = re.match(r"https://[^:]+:(\d+)", line)
            if m:
                ts_serve_map[int(m.group(1))] = "https"
    except Exception:
        pass

    docker_map = {}
    try:
        r = subprocess.run(
            ["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"],
            capture_output=True, text=True, timeout=5
        )
        for line in r.stdout.strip().split("\n"):
            if not line:
                continue
            parts = line.split("\t")
            if len(parts) < 2:
                continue
            name = parts[0]
            ports_str = parts[1]
            for pm in re.finditer(r"(\d+\.\d+\.\d+\.\d+)?:(\d+)->", ports_str):
                host_port = int(pm.group(2))
                docker_map[host_port] = name
    except Exception:
        pass

    try:
        r = subprocess.run(
            ["ss", "-tlnp"], capture_output=True, text=True, timeout=5
        )
        for line in r.stdout.strip().split("\n")[1:]:
            parts = line.split()
            if len(parts) < 6:
                continue
            addr = parts[3]
            proc_info = parts[5]

            m = re.search(r":(\d+)$", addr)
            if not m:
                continue
            port = int(m.group(1))

            if port in skip_ports or port in seen_ports:
                continue
            if addr.startswith("["):
                continue

            proc_name = ""
            pm = re.search(r'"([^"]+)"', proc_info)
            if pm:
                proc_name = pm.group(1)

            if proc_name in skip_procs:
                continue

            if port in docker_map:
                display_name = docker_map[port].replace("-", " ").title()
            elif proc_name == "docker-proxy":
                display_name = "Docker Service"
            else:
                name_map = {
                    "python": "Python Service",
                    "filebrowser": "FileBrowser",
                    "node": "Node.js Service",
                }
                display_name = name_map.get(proc_name, proc_name)

            has_ts_serve = port in ts_serve_map
            is_direct_http = "0.0.0.0" in addr or addr.startswith("*")

            # HTTPS判定
            has_https = has_ts_serve
            if not has_https and is_direct_http:
                has_https = check_https(port)

            seen_ports.add(port)
            services.append({
                "port": port,
                "name": display_name,
                "proc": proc_name,
                "addr": addr,
                "has_ts_serve": has_ts_serve,
                "is_direct_http": is_direct_http,
                "has_https": has_https,
            })

    except Exception as e:
        services.append({"port": 0, "name": f"Error: {e}", "proc": "", "addr": ""})

    return sorted(services, key=lambda x: x["port"])


def load_custom_names():
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r") as f:
            data = json.load(f)
            return data.get("names", {}), data.get("manual", []), data.get("auto_order", {}), data.get("auto_cats", {}), data.get("manual_cats", {})
    return {}, [], {}, {}, {}


def save_custom_data(names, manual, auto_order, auto_cats, manual_cats):
    with open(CONFIG_FILE, "w") as f:
        json.dump({"names": names, "manual": manual, "auto_order": auto_order, "auto_cats": auto_cats, "manual_cats": manual_cats}, f, indent=2, ensure_ascii=False)


HTML = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>selfmark</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
  .header { background: #1a73e8; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
  .header h1 { font-size: 20px; }
  .header .host { margin-left: auto; font-size: 13px; opacity: 0.8; }
  .container { max-width: 800px; margin: 24px auto; padding: 0 16px; }
  .toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
  .btn { padding: 8px 16px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
  .btn-primary { background: #1a73e8; color: white; }
  .btn-primary:hover { background: #1557b0; }
  .btn-secondary { background: #e0e0e0; color: #333; }
  .btn-secondary:hover { background: #d0d0d0; }
  .btn-success { background: #34a853; color: white; }
  .btn-success:hover { background: #2d8f47; }
  .btn-danger { background: #e0e0e0; color: #333; }
  .btn-danger:hover { background: #d0d0d0; }
  .btn-danger .trash-icon { color: #ea4335; }
  .card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 16px; margin-bottom: 12px; display: flex; align-items: center; gap: 16px; cursor: grab; transition: transform 0.15s, box-shadow 0.15s; }
  .card:active { cursor: grabbing; }
  .card.dragging { opacity: 0.5; transform: scale(0.98); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
  .card.drag-over { border-top: 3px solid #1a73e8; }
  .card .drag-handle { color: #ccc; font-size: 18px; cursor: grab; user-select: none; flex-shrink: 0; }
  .card .drag-handle:hover { color: #999; }
  .card .icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; flex-shrink: 0; }
  .card .info { flex: 1; }
  .card .name { font-size: 16px; font-weight: 600; }
  .card .url { font-size: 13px; color: #666; margin-top: 2px; }
  .card .url a { color: #1a73e8; text-decoration: none; }
  .card .url a:hover { text-decoration: underline; }
  .card .actions { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
  .card .port-badge { background: #e8f0fe; color: #1a73e8; padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; }
  .card .manual-badge { background: #fef7e0; color: #f9ab00; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; }
  .card .cat-badge { background: #e6f4ea; color: #1e8e3e; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; margin-left: 4px; }
  .category-group { margin-bottom: 24px; }
  .category-title { font-size: 14px; font-weight: 700; color: #555; margin-bottom: 8px; padding-left: 4px; display: flex; align-items: center; gap: 8px; }
  .category-title .cat-icon { font-size: 16px; }
  .cat-input { border: 1px solid #ddd; border-radius: 6px; padding: 4px 8px; font-size: 13px; width: 120px; }
  .cat-input:focus { outline: none; border-color: #1a73e8; }
  .cat-suggestions { position: absolute; background: white; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 10; max-height: 150px; overflow-y: auto; display: none; }
  .cat-suggestions.show { display: block; }
  .cat-suggestions div { padding: 6px 12px; cursor: pointer; font-size: 13px; }
  .cat-suggestions div:hover { background: #f0f0f0; }
  .edit-input { border: 1px solid #ddd; border-radius: 6px; padding: 4px 8px; font-size: 14px; width: 200px; }
  .empty { text-align: center; padding: 40px; color: #999; }
  .icon-bg { background: #e8f0fe; color: #1a73e8; }
  .toast { position: fixed; bottom: 24px; right: 24px; background: #333; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; display: none; z-index: 100; }
  .add-form { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 16px; margin-bottom: 16px; display: none; }
  .add-form.show { display: block; }
  .add-form h3 { font-size: 14px; margin-bottom: 12px; color: #555; }
  .form-row { display: flex; gap: 8px; margin-bottom: 8px; }
  .form-row input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
  .form-row input:focus { outline: none; border-color: #1a73e8; }
  .section-title { font-size: 13px; color: #999; margin: 16px 0 8px; font-weight: 600; }
</style>
</head>
<body>
<div class="header">
  <h1>selfmark</h1>
  <span class="host" id="hostname">...</span>
</div>
<div class="container">
  <div class="toolbar">
    <button class="btn btn-primary" onclick="refresh()">🔄 最新の状態を取得</button>
    <button class="btn btn-primary" onclick="showTailnetHosts()">🔗 Tailホスト内取得</button>
    <button class="btn btn-success" onclick="toggleAddForm()">+ 手動追加</button>
    <button class="btn btn-success" onclick="saveAll()" id="saveBtn" style="display:none">💾 保存</button>
  </div>

  <div class="add-form" id="tailnetModal" style="display:none">
    <h3>Tailnetホストを選択</h3>
    <div id="tailnetHostList" style="max-height:300px;overflow-y:auto;margin-bottom:12px;">読み込み中...</div>
    <button class="btn btn-secondary" onclick="closeTailnetModal()">閉じる</button>
  </div>

  <div class="add-form" id="addForm">
    <h3>サービスを手動追加</h3>
    <div class="form-row">
      <input type="text" id="addName" placeholder="サービス名 (例: Nextcloud)">
      <input type="text" id="addUrl" placeholder="URL (例: https://192.168.1.100:443)">
    </div>
    <button class="btn btn-primary" onclick="addManual()">追加</button>
    <button class="btn btn-secondary" onclick="toggleAddForm()">キャンセル</button>
  </div>

  <div id="autoSection" class="section-title" style="display:none">🔍 自動検出</div>
  <div id="autoList"></div>

  <div id="manualSection" class="section-title" style="display:none">📌 手動追加</div>
  <div id="manualList"></div>

  <div style="margin-top:32px; padding-top:16px; border-top:1px solid #ddd; display:flex; gap:8px; justify-content:center;">
    <button class="btn btn-secondary" onclick="exportData()">📥 エクスポート</button>
    <button class="btn btn-secondary" onclick="document.getElementById('importFile').click()">📤 インポート</button>
    <input type="file" id="importFile" accept=".json" style="display:none" onchange="importData(event)">
  </div>
</div>
<div class="toast" id="toast"></div>

<script>
let hostname = '';
let autoServices = [];
let manualServices = [];
let editedNames = {};
let editedCats = {};
let dragSrc = null;
let allCategories = [];
const HIDDEN_CAT = '_hidden';

function escapeHtml(s) {
  const div = document.createElement('div');
  div.appendChild(document.createTextNode(s));
  return div.innerHTML;
}

function showToast(msg) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.style.display = 'block';
  setTimeout(() => t.style.display = 'none', 2000);
}

function toggleAddForm() {
  document.getElementById('addForm').classList.toggle('show');
}

function refresh() {
  fetch('/api/services').then(r => r.json()).then(data => {
    hostname = data.hostname;
    // 手動サービスのURLセット(重複チェック用)
    const manualUrls = new Set((data.manual || []).map(s => s.url));
    // 自動検出サービスをフィルタリング(手動追加済みと重複するものはスキップ)
    autoServices = (data.services || []).filter(s => {
      const protocol = s.has_https ? 'https' : 'http';
      const url = `${protocol}://${data.hostname}:${s.port}`;
      return !manualUrls.has(url);
    });
    manualServices = data.manual || [];
    editedNames = {};
    editedCats = {};
    // 全カテゴリーを収集(非表示カテゴリーは除外)
    const cats = new Set();
    [...autoServices, ...manualServices].forEach(s => {
      if (s.category && s.category !== HIDDEN_CAT) cats.add(s.category);
    });
    allCategories = [...cats];
    document.getElementById('hostname').textContent = hostname;
    document.getElementById('saveBtn').style.display = 'none';
    render();
    showToast('更新しました');
  });
}

function renderCard(s, type, i, extra = '') {
  const id = `${type}_${i}`;
  const name = editedNames[id] !== undefined ? editedNames[id] : s.name;
  const cat = editedCats[id] !== undefined ? editedCats[id] : (s.category || '');
  const isEditing = s._editing;

  let url, badge, icon;
  if (type === 'auto') {
    const protocol = s.has_https ? 'https' : 'http';
    url = `${protocol}://${hostname}:${s.port}`;
    badge = s.has_https ? '🔒 HTTPS' : `:${s.port}`;
    const icons = {'Nextexplorer': '📁', 'Onlyoffice': '📝', 'Image Resize': '🖼️', 'FileBrowser': '📂', 'SSH': '🔑'};
    icon = '🌐';
    for (const [key, val] of Object.entries(icons)) {
      if (s.name.includes(key)) { icon = val; break; }
    }
  } else {
    url = s.url;
    badge = '<span class="manual-badge">手動</span>';
    icon = '📌';
  }

  const catBadge = cat ? `<span class="cat-badge">${escapeHtml(cat)}</span>` : '';
  const catSuggestions = allCategories.filter(c => c !== cat).map(c =>
    `<div onclick="setCategory('${id}','${c.replace(/'/g, "\\'")}')">${escapeHtml(c)}</div>`
  ).join('');

  return `<div class="card" draggable="true" data-type="${type}" data-index="${i}"
    ondragstart="onDragStart(event)" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)" ondragend="onDragEnd(event)">
    <span class="drag-handle">⠿</span>
    <div class="icon icon-bg">${icon}</div>
    <div class="info">
      ${isEditing
        ? `<input class="edit-input" value="${escapeHtml(name)}" onchange="updateName('${id}', this.value)" onblur="stopEdit('${id}')" id="edit_${id}">`
        : `<div class="name">${escapeHtml(name)}${catBadge}</div>`
      }
      <div class="url"><a href="${url}" target="_blank">${escapeHtml(url)}</a></div>
    </div>
    <span class="port-badge">${badge}</span>
    <div class="actions">
      <div style="position:relative">
        <input class="cat-input" placeholder="カテゴリ" value="${cat}"
          onchange="updateCategory('${id}', this.value)"
          onfocus="showCatSuggestions('${id}')"
          onblur="setTimeout(()=>hideCatSuggestions('${id}'), 200)">
        <div class="cat-suggestions" id="sug_${id}">${catSuggestions}</div>
      </div>
      <button class="btn btn-secondary" onclick="startEdit('${id}')">✏️</button>
      <button class="btn btn-danger" onclick="removeService('${type}', ${i})"><span class="trash-icon">🗑️</span></button>
      ${extra}
    </div>
  </div>`;
}

function render() {
  const allServices = [
    ...autoServices.map((s, i) => ({...s, _type: 'auto', _index: i})),
    ...manualServices.map((s, i) => ({...s, _type: 'manual', _index: i})),
  ];

  function getCat(s) {
    return editedCats[`${s._type}_${s._index}`] !== undefined
      ? editedCats[`${s._type}_${s._index}`]
      : (s.category || '');
  }

  // 非表示カテゴリーのアイテムを分離
  const visibleServices = allServices.filter(s => getCat(s) !== HIDDEN_CAT);
  const hiddenItems = allServices.filter(s => getCat(s) === HIDDEN_CAT);

  // カテゴリーごとにグループ化
  const groups = {};
  const noCategory = [];
  visibleServices.forEach(s => {
    const cat = getCat(s);
    if (cat) {
      if (!groups[cat]) groups[cat] = [];
      groups[cat].push(s);
    } else {
      noCategory.push(s);
    }
  });

  const containerEl = document.getElementById('autoList');
  const autoSection = document.getElementById('autoSection');
  autoSection.style.display = 'none';
  let html = '';

  // カテゴリーグループ
  const sortedCats = Object.keys(groups).sort();
  sortedCats.forEach(cat => {
    html += `<div class="category-group">
      <div class="category-title"><span class="cat-icon">📂</span> ${escapeHtml(cat)}</div>
      ${groups[cat].map(s => renderCard(s, s._type, s._index)).join('')}
    </div>`;
  });

  // カテゴリーなし
  if (noCategory.length > 0) {
    if (sortedCats.length > 0) {
      html += `<div class="category-group">
        <div class="category-title"><span class="cat-icon">📋</span> 未分類</div>
        ${noCategory.map(s => renderCard(s, s._type, s._index)).join('')}
      </div>`;
    } else {
      html = noCategory.map(s => renderCard(s, s._type, s._index)).join('');
    }
  }

  if (visibleServices.length === 0) {
    html = '<div class="empty">公開サービスが見つかりません</div>';
  }

  // 非表示サービストラック(カテゴリー復元用)
  if (hiddenItems.length > 0) {
    html += `<div style="margin-top:24px;padding:12px 16px;background:#fff8e1;border-radius:8px;border:1px solid #ffe082;">
      <div style="font-size:13px;color:#f57f17;font-weight:600;margin-bottom:8px;">非表示: ${hiddenItems.length}件</div>
      <div style="font-size:12px;color:#999;margin-bottom:8px;">カテゴリを入力して復元してください</div>
      ${hiddenItems.map(s => {
        const id = `${s._type}_${s._index}`;
        const name = editedNames[id] !== undefined ? editedNames[id] : s.name;
        let url;
        if (s._type === 'auto') {
          const protocol = s.has_https ? 'https' : 'http';
          url = `${protocol}://${hostname}:${s.port}`;
        } else {
          url = s.url;
        }
        const catSuggestions = allCategories.filter(c => c !== HIDDEN_CAT).map(c =>
          `<div onclick="restoreHidden('${id}','${c.replace(/'/g, "\\'")}')">${escapeHtml(c)}</div>`
        ).join('');
        return `<div class="card" style="background:#fff9e6;margin-bottom:8px;">
          <div class="icon icon-bg" style="background:#ffe082;color:#f57f17;">🔍</div>
          <div class="info">
            <div class="name" style="color:#666;">${escapeHtml(name)}</div>
            <div class="url" style="color:#bbb;font-size:11px;">${escapeHtml(url)}</div>
          </div>
          <div class="actions">
            <div style="position:relative">
              <input class="cat-input" placeholder="カテゴリ" value=""
                onchange="restoreHidden('${id}', this.value)"
                onfocus="showCatSuggestions('h_${id}')"
                onblur="setTimeout(()=>hideCatSuggestions('h_${id}'), 200)">
              <div class="cat-suggestions" id="sug_h_${id}">${catSuggestions}</div>
            </div>
          </div>
        </div>`;
      }).join('')}
    </div>`;
  }

  containerEl.innerHTML = html;
}

function showCatSuggestions(id) {
  const el = document.getElementById('sug_' + id);
  if (el && el.children.length > 0) el.classList.add('show');
}

function hideCatSuggestions(id) {
  const el = document.getElementById('sug_' + id);
  if (el) el.classList.remove('show');
}

function restoreHidden(id, cat) {
  if (!cat || cat.trim() === '') { showToast('カテゴリを入力してください'); return; }
  editedCats[id] = cat.trim();
  if (!allCategories.includes(cat.trim())) allCategories.push(cat.trim());
  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
  showToast('復元しました');
}

function setCategory(id, cat) {
  editedCats[id] = cat;
  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
}

function updateCategory(id, val) {
  const cat = val.trim();
  editedCats[id] = cat;
  if (cat && !allCategories.includes(cat)) allCategories.push(cat);
  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
}

// Tailnetホストスキャン機能
function showTailnetHosts() {
  document.getElementById('tailnetModal').style.display = 'block';
  document.getElementById('tailnetHostList').innerHTML = '読み込み中...';
  fetch('/api/tailnet').then(r => r.json()).then(data => {
    const el = document.getElementById('tailnetHostList');
    if (!data.hosts || data.hosts.length === 0) {
      el.innerHTML = '<div class="empty">ホストが見つかりません</div>';
      return;
    }
    el.innerHTML = data.hosts.map(h => `
      <div class="card" style="cursor:pointer;margin-bottom:8px;" onclick="scanHost('${h.name}', '${h.fqdn}', '${h.ip}')">
        <div class="icon icon-bg">🖥️</div>
        <div class="info">
          <div class="name">${h.name}</div>
          <div class="url">${h.fqdn}</div>
        </div>
        <span class="port-badge">${h.status}</span>
      </div>
    `).join('');
  }).catch(err => {
    document.getElementById('tailnetHostList').innerHTML = '<div class="empty">取得に失敗しました</div>';
  });
}

function closeTailnetModal() {
  document.getElementById('tailnetModal').style.display = 'none';
}

function scanHost(name, fqdn, ip) {
  document.getElementById('tailnetHostList').innerHTML = `<div style="text-align:center;padding:20px;">${name} をスキャン中...</div>`;
  fetch('/api/scan-host', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({name: name, fqdn: fqdn, ip: ip}),
  }).then(r => r.json()).then(data => {
    if (data.services && data.services.length > 0) {
      const existingUrls = manualServices.map(s => s.url);
      let added = 0;
      data.services.forEach(s => {
        if (!existingUrls.includes(s.url)) {
          manualServices.push({
            name: s.name,
            url: s.url,
            category: name
          });
          added++;
        }
      });
      closeTailnetModal();
      if (added > 0) {
        document.getElementById('saveBtn').style.display = 'inline-block';
        render();
        showToast(`${added} 件のサービスを追加しました`);
      } else {
        showToast('新しいサービスは見つかりませんでした');
      }
    } else {
      showToast('サービスが見つかりませんでした');
      closeTailnetModal();
    }
  }).catch(err => {
    showToast('スキャンに失敗しました');
    closeTailnetModal();
  });
}

function onDragStart(e) {
  dragSrc = {type: e.target.dataset.type, index: parseInt(e.target.dataset.index)};
  e.target.classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
}

function onDragOver(e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'move';
  const card = e.target.closest('.card');
  if (card && card !== document.querySelector('.dragging')) {
    card.classList.add('drag-over');
  }
}

function onDragLeave(e) {
  const card = e.target.closest('.card');
  if (card) card.classList.remove('drag-over');
}

function onDrop(e) {
  e.preventDefault();
  const card = e.target.closest('.card');
  if (card) card.classList.remove('drag-over');
  if (!dragSrc) return;

  const dstType = card.dataset.type;
  const dstIndex = parseInt(card.dataset.index);
  if (dragSrc.type !== dstType || dragSrc.index === dstIndex) return;

  const list = dragSrc.type === 'auto' ? autoServices : manualServices;
  const item = list.splice(dragSrc.index, 1)[0];
  list.splice(dstIndex, 0, item);

  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
}

function onDragEnd(e) {
  e.target.classList.remove('dragging');
  document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
  dragSrc = null;
}

function startEdit(id) {
  const list = id.startsWith('auto_') ? autoServices : manualServices;
  const idx = parseInt(id.split('_')[1]);
  list[idx]._editing = true;
  render();
  const inp = document.getElementById('edit_' + id);
  if (inp) { inp.focus(); inp.select(); }
}

function stopEdit(id) {
  const list = id.startsWith('auto_') ? autoServices : manualServices;
  const idx = parseInt(id.split('_')[1]);
  list[idx]._editing = false;
  render();
}

function updateName(id, val) {
  editedNames[id] = val.trim();
  const list = id.startsWith('auto_') ? autoServices : manualServices;
  const idx = parseInt(id.split('_')[1]);
  list[idx].name = val.trim();
  list[idx]._editing = false;
  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
}

function addManual() {
  const name = document.getElementById('addName').value.trim();
  const url = document.getElementById('addUrl').value.trim();
  if (!name || !url) { showToast('名前とURLを入力してください'); return; }
  manualServices.push({name: name, url: url, category: ''});
  document.getElementById('addName').value = '';
  document.getElementById('addUrl').value = '';
  document.getElementById('addForm').classList.remove('show');
  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
  showToast('追加しました');
}

function removeService(type, i) {
  if (!confirm('このブックマークを非表示にしますか?')) return;
  if (type === 'manual') {
    manualServices.splice(i, 1);
  } else {
    // 自動検出サービスは非表示カテゴリーを設定
    const service = autoServices[i];
    if (!service) return;
    service.category = HIDDEN_CAT;
    const id = `auto_${i}`;
    editedCats[id] = HIDDEN_CAT;
  }
  document.getElementById('saveBtn').style.display = 'inline-block';
  render();
}

function saveAll() {
  const names = {};
  const auto_order = {};
  const auto_cats = {};
  autoServices.forEach((s, i) => {
    const id = `auto_${i}`;
    names[s.port] = editedNames[id] !== undefined ? editedNames[id] : s.name;
    auto_order[s.port] = i;
    if (editedCats[id] !== undefined) auto_cats[s.port] = editedCats[id];
    else if (s.category) auto_cats[s.port] = s.category;
  });
  const manual_cats = {};
  manualServices.forEach((s, i) => {
    const id = `manual_${i}`;
    if (editedCats[id] !== undefined) manual_cats[i] = editedCats[id];
    else if (s.category) manual_cats[i] = s.category;
  });
  fetch('/api/services', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({names: names, manual: manualServices, auto_order: auto_order, auto_cats: auto_cats, manual_cats: manual_cats}),
  }).then(r => r.json()).then(() => {
    document.getElementById('saveBtn').style.display = 'none';
    showToast('保存しました');
  });
}

function exportData() {
  const data = {
    manualServices: manualServices,
    exportedAt: new Date().toISOString()
  };
  const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `selfmark-${new Date().toISOString().slice(0,10)}.json`;
  a.click();
  URL.revokeObjectURL(url);
  showToast('エクスポートしました');
}

function importData(e) {
  const file = e.target.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = function(ev) {
    try {
      const data = JSON.parse(ev.target.result);
      // 手動追加サービスのみインポート(自動検出は環境依存のため除外)
      if (data.manualServices) {
        const existingUrls = manualServices.map(s => s.url);
        data.manualServices.forEach(s => {
          if (!existingUrls.includes(s.url)) {
            manualServices.push({
              name: s.name || '',
              url: s.url || '',
              category: s.category || ''
            });
          }
        });
      }
      // カテゴリー再収集
      const cats = new Set();
      manualServices.forEach(s => { if (s.category) cats.add(s.category); });
      autoServices.forEach(s => { if (s.category) cats.add(s.category); });
      allCategories = [...cats];
      document.getElementById('saveBtn').style.display = 'inline-block';
      render();
      showToast('インポートしました。保存してください。');
    } catch(err) {
      showToast('ファイルの読み込みに失敗しました');
    }
  };
  reader.readAsText(file);
  e.target.value = '';
}

refresh();
</script>
</body>
</html>"""


@app.route("/")
def index():
    return render_template_string(HTML)


@app.route("/api/services", methods=["GET", "POST"])
def api_services():
    if request.method == "POST":
        data = request.json
        save_custom_data(data.get("names", {}), data.get("manual", []), data.get("auto_order", {}), data.get("auto_cats", {}), data.get("manual_cats", {}))
        return jsonify({"ok": True})

    hostname = get_hostname()
    services = detect_services()
    names, manual, auto_order, auto_cats, manual_cats = load_custom_names()

    for s in services:
        port_str = str(s["port"])
        if port_str in names:
            s["name"] = names[port_str]
        if port_str in auto_cats:
            s["category"] = auto_cats[port_str]

    # 手動サービスにカテゴリーを適用
    for i, s in enumerate(manual):
        if str(i) in manual_cats:
            s["category"] = manual_cats[str(i)]

    # 保存された順序がある場合は適用
    if auto_order:
        def sort_key(s):
            return auto_order.get(str(s["port"]), 9999)
        services.sort(key=sort_key)

    return jsonify({"hostname": hostname, "services": services, "manual": manual})


@app.route("/api/tailnet")
def api_tailnet():
    """Tailnet内のホスト一覧を取得"""
    hosts = []
    try:
        # ホスト名のサフィックスを取得(末尾ドット除去)
        suffix = ""
        r_self = subprocess.run(
            ["tailscale", "status", "--json"],
            capture_output=True, text=True, timeout=5
        )
        d = json.loads(r_self.stdout)
        full_name = d.get("Self", {}).get("DNSName", "").rstrip(".")
        if "." in full_name:
            suffix = "." + ".".join(full_name.split(".")[1:])

        r = subprocess.run(
            ["tailscale", "status"],
            capture_output=True, text=True, timeout=10
        )
        for line in r.stdout.strip().split("\n")[1:]:
            parts = line.split()
            if len(parts) >= 2:
                ip = parts[0]
                name = parts[1].rstrip(".")
                status = parts[2] if len(parts) > 2 else "unknown"
                fqdn = name + suffix if suffix else name
                if status in ("-", "active"):
                    status = "online"
                hosts.append({"name": name, "fqdn": fqdn, "ip": ip, "status": status})
    except Exception:
        pass
    return jsonify({"hosts": hosts})


@app.route("/api/scan-host", methods=["POST"])
def api_scan_host():
    """指定ホストの主要ポートをスキャン"""
    data = request.json
    name = data.get("name", "")
    fqdn = data.get("fqdn", "").rstrip(".")
    ip = data.get("ip", "")
    if not ip:
        return jsonify({"services": []})

    services = []
    common_ports = [80, 443, 3000, 3001, 3317, 3322, 3324, 3325, 8080, 8081, 8443, 8096, 9090]
    
    for port in common_ports:
        try:
            sock = socket.create_connection((ip, port), timeout=1)
            sock.close()
            
            # HTTPS判定
            has_https = False
            try:
                ctx = ssl.create_default_context()
                ctx.check_hostname = False
                ctx.verify_mode = ssl.CERT_NONE
                sock = socket.create_connection((ip, port), timeout=2)
                # SSL接続を試行
                ssock = ctx.wrap_socket(sock, server_hostname=fqdn or ip)
                # 実際にHTTPリクエストを送信して応答を確認
                ssock.sendall(b"GET / HTTP/1.1\r\nHost: " + (fqdn or ip).encode() + b"\r\nConnection: close\r\n\r\n")
                resp = ssock.recv(1024)
                ssock.close()
                has_https = True
            except ssl.SSLError:
                has_https = False
            except Exception:
                has_https = False

            # HTTPSで失敗した場合、HTTPでもう一度確認
            if not has_https:
                try:
                    sock = socket.create_connection((ip, port), timeout=2)
                    sock.sendall(b"GET / HTTP/1.1\r\nHost: " + (fqdn or ip).encode() + b"\r\nConnection: close\r\n\r\n")
                    resp = sock.recv(1024)
                    sock.close()
                    # HTTP応答が返ってきた = HTTPサービス
                except Exception:
                    pass

            protocol = "https" if has_https else "http"
            host = fqdn if fqdn else ip
            url = f"{protocol}://{host}:{port}"
            services.append({
                "name": f"{name}:{port}",
                "url": url,
                "port": port,
                "has_https": has_https,
            })
        except Exception:
            pass

    return jsonify({"services": services})


if __name__ == "__main__":
    print("[INFO] selfmark Directory: http://127.0.0.1:3325", flush=True)
    app.run(host="0.0.0.0", port=3325, debug=False)
PYEOF
ok "アプリケーション生成完了"

# ── 依存パッケージ確認 ────────────────────────────────────────────────────────
info "依存パッケージをインストール..."
if [[ -f "${VENV_DIR}/bin/pip" ]]; then
  "${VENV_DIR}/bin/pip" install --quiet --upgrade Pillow watchdog flask 2>/dev/null || \
  "${VENV_DIR}/bin/pip" install --quiet --upgrade flask
else
  die "venv の pip が見つかりません: ${VENV_DIR}/bin/pip"
fi
ok "依存パッケージインストール完了"

# ── systemd ユニットファイル生成 ───────────────────────────────────────────────
info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=selfmark Directory
After=network.target

[Service]
Type=simple
ExecStart=${VENV_DIR}/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"

# ── ポート使用確認 ────────────────────────────────────────────────────────────
if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then
  warn "ポート ${PORT} は既に使用中です。停止します..."
  fuser -k "${PORT}/tcp" 2>/dev/null || true
  sleep 1
fi

# ── サービス起動 ──────────────────────────────────────────────────────────────
info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" 2>/dev/null
systemctl restart "${SERVICE_NAME}"

# 起動確認(最大10秒待機)
STARTED=false
for i in $(seq 1 10); do
  sleep 1
  if curl -s --max-time 2 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
    STARTED=true
    break
  fi
done

if [[ "${STARTED}" == "true" ]]; then
  ok "サービス起動完了"
else
  warn "サービスの起動を確認できません。ログを確認します..."
  journalctl -u ${SERVICE_NAME} --no-pager -n 10 2>/dev/null || true
  # さらに待機
  sleep 3
  if curl -s --max-time 2 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
    ok "サービス起動完了(遅延あり)"
  else
    die "サービスの起動に失敗しました。journalctl -u ${SERVICE_NAME} -n 20 で確認してください"
  fi
fi

# ── 完了サマリー ──────────────────────────────────────────────────────────────
HOSTNAME=$(tailscale status --json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Self']['DNSName'].rstrip('.'))" 2>/dev/null || hostname)
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
echo "  URL: http://${HOSTNAME}:${PORT}"
echo ""
echo "  管理コマンド:"
echo "    systemctl status ${SERVICE_NAME}"
echo "    systemctl restart ${SERVICE_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

自動取得したブックマークを削除する時には非表示に、手動登録したブックマークを削除する時は削除されます。

Google Chromeに拡張機能を追加

Google Chromeの拡張機能も作りました。デベロッパーモードにして追加してください。

タイトルとURLをコピーしました