Taildrop Webを改良しセキュリティ強化とサーバ内ファイル送信

昨日、TaildropをWebブラウザから送信するスクリプトを書きましたが、少し改良して、サーバ上の/opt/lxd-data/taildropフォルダを、ファイル置き場のようにも利用出来るようになります。また、若干ですが、セキュリティを高めるためにTailscaleネットワーク内のみ公開に変更しています。具体的には、起動時にtailscale ip -4でTS IPを取得し、そのIPにのみバインドしています。

インストールスクリプト

LXDコンテナ内での実行を想定しています。

nano taildrop_web.py
"""
Taildrop Web UI
- Tailscale IP のみにバインド(LAN/外部には非公開)
- ファイルのドラッグ&ドロップ / クリック選択で送信(複数可)
- サーバ上の /opt/lxd-data/taildrop/ からチェックボックスで選択送信
"""

from flask import Flask, request, render_template_string, jsonify
from werkzeug.utils import secure_filename
import subprocess
import json
import os
import tempfile
import zipfile

app = Flask(__name__)
SERVER_FILE_DIR = "/opt/lxd-data/taildrop"

HTML = """
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Taildrop Web</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: system-ui, sans-serif; background: #f0f2f5; color: #222; }
  header { background: #0f7acc; color: #fff; padding: 14px 24px; display: flex; align-items: center; gap: 10px; }
  header h1 { font-size: 20px; font-weight: 600; }
  .container { max-width: 900px; margin: 24px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 20px; }
  .card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.1); padding: 20px 24px; }
  .card h2 { font-size: 15px; font-weight: 600; color: #444; margin-bottom: 14px; border-bottom: 1px solid #eee; padding-bottom: 8px; }

  .device-grid { display: flex; flex-wrap: wrap; gap: 10px; }
  .device-item label {
    display: flex; align-items: center; gap: 8px; padding: 8px 14px;
    border: 1.5px solid #ddd; border-radius: 8px; cursor: pointer;
    font-size: 14px; transition: border-color .15s, background .15s; user-select: none;
  }
  .device-item label:has(input:checked) { border-color: #0f7acc; background: #e8f3fd; }
  .device-item label:has(input:disabled) { opacity: .45; cursor: not-allowed; }

  .drop-zone {
    border: 2.5px dashed #0f7acc; border-radius: 12px; padding: 48px 20px;
    text-align: center; background: #f8fbff; cursor: pointer;
    transition: background .15s; font-size: 15px; color: #555;
  }
  .drop-zone.dragover { background: #dceffe; border-color: #0056a0; }
  .file-list { margin-top: 10px; font-size: 13px; color: #555; line-height: 1.8;
    max-height: 140px; overflow-y: auto; padding: 0 4px; }

  .server-files { display: flex; flex-direction: column; gap: 4px; max-height: 320px; overflow-y: auto; }
  .sf-item label {
    display: flex; align-items: center; gap: 10px; padding: 7px 10px;
    border-radius: 6px; cursor: pointer; font-size: 14px; transition: background .1s; user-select: none;
  }
  .sf-item label:hover { background: #f0f4f8; }
  .sf-meta { margin-left: auto; font-size: 12px; color: #999; white-space: nowrap; }
  .empty-msg { color: #999; font-size: 14px; text-align: center; padding: 20px; }

  .toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
  .tbtn { font-size: 12px; color: #0f7acc; background: none; border: 1px solid #c5dcf0;
    border-radius: 5px; cursor: pointer; padding: 3px 9px; }
  .tbtn:hover { background: #e8f3fd; }
  .tbtn.gray { color: #666; border-color: #ddd; }
  .tbtn.gray:hover { background: #f5f5f5; }
  .ml-auto { margin-left: auto; }

  .send-btn {
    width: 100%; padding: 13px; font-size: 16px; font-weight: 600;
    background: #0f7acc; color: #fff; border: none; border-radius: 10px;
    cursor: pointer; transition: background .15s;
  }
  .send-btn:hover:not(:disabled) { background: #0960a5; }
  .send-btn:disabled { opacity: .5; cursor: not-allowed; }

  .progress-wrap { margin-top: 10px; display: none; }
  .progress-wrap.show { display: block; }
  .progress-bar-bg { background: #e0e8f0; border-radius: 6px; height: 10px; overflow: hidden; }
  .progress-bar { background: #0f7acc; height: 100%; width: 0%; transition: width .2s; border-radius: 6px; }
  .progress-label { font-size: 12px; color: #666; margin-top: 4px; }

  #status { font-size: 14px; line-height: 1.9; }
  .ok   { color: #1a7f3c; }
  .err  { color: #c0392b; }
  .warn { color: #b07800; }
</style>
</head>
<body>
<header>
  <span style="font-size:22px">🚀</span>
  <h1>Taildrop Web</h1>
</header>

<div class="container">

  <div class="card">
    <h2>📡 送信先デバイス</h2>
    <div class="device-grid" id="device-list">読み込み中...</div>
  </div>

  <div class="card">
    <h2>📂 ファイルをアップロードして送信</h2>
    <div id="drop-zone" class="drop-zone">
      <div>ここにファイルをドロップ</div>
      <div style="font-size:13px;margin-top:6px;color:#999">またはクリックして選択(複数可)</div>
    </div>
    <input type="file" id="file-input" multiple style="display:none">
    <div id="upload-file-list" class="file-list"></div>
  </div>

  <div class="card">
    <h2>🗂 サーバ上のファイルから送信</h2>
    <div class="toolbar">
      <button class="tbtn" onclick="selectAll(true)">全て選択</button>
      <button class="tbtn" onclick="selectAll(false)">全て解除</button>
      <button class="tbtn gray ml-auto" onclick="loadServerFiles()">🔄 更新</button>
    </div>
    <div class="server-files" id="server-files">読み込み中...</div>
  </div>

  <div class="card">
    <button class="send-btn" id="send-btn" onclick="sendAll()" disabled>📤 送信</button>
    <div class="progress-wrap" id="progress-wrap">
      <div class="progress-bar-bg"><div class="progress-bar" id="progress-bar"></div></div>
      <div class="progress-label" id="progress-label"></div>
    </div>
    <div id="status" style="margin-top:14px"></div>
  </div>

</div>

<script>
let selectedDevices = [];
let uploadFiles     = [];

// ---- デバイス ----
fetch('/devices').then(r => r.json()).then(data => {
  const div = document.getElementById('device-list');
  div.innerHTML = '';
  if (!data.length) { div.textContent = 'デバイスが見つかりません'; return; }
  data.forEach(d => {
    const wrap = document.createElement('div');
    wrap.className = 'device-item';
    wrap.innerHTML = `<label>
      <input type="checkbox" value="${d.name}" ${d.online ? '' : 'disabled'}>
      <span>${d.online ? '🟢' : '⚪'}</span>
      <span>${d.name}<br><small style="color:#999;font-weight:400">${d.lastseen}</small></span>
    </label>`;
    wrap.querySelector('input').onchange = () => {
      selectedDevices = [...document.querySelectorAll('#device-list input:checked')].map(i => i.value);
      updateBtn();
    };
    div.appendChild(wrap);
  });
}).catch(() => { document.getElementById('device-list').textContent = 'デバイス取得エラー'; });

// ---- ドロップゾーン ----
const zone      = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');

zone.addEventListener('click',     () => fileInput.click());
zone.addEventListener('dragover',  e  => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', ()  => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
  e.preventDefault(); zone.classList.remove('dragover');
  setUploadFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', e => setUploadFiles(e.target.files));

function setUploadFiles(files) {
  if (!files || !files.length) return;
  uploadFiles = [...files];
  document.getElementById('upload-file-list').innerHTML =
    uploadFiles.map(f => `📄 ${f.name} <span style="color:#aaa">(${humanSize(f.size)})</span>`).join('<br>');
  updateBtn();
}

function humanSize(b) {
  if (b < 1024)       return b + ' B';
  if (b < 1024**2)    return (b/1024).toFixed(1) + ' KB';
  if (b < 1024**3)    return (b/1024**2).toFixed(1) + ' MB';
  return (b/1024**3).toFixed(2) + ' GB';
}

// ---- サーバファイル ----
function loadServerFiles() {
  const div = document.getElementById('server-files');
  div.innerHTML = '<span style="color:#999;font-size:14px;padding:10px;display:block">読み込み中...</span>';
  fetch('/server-files').then(r => r.json()).then(data => {
    div.innerHTML = '';
    if (!data.length) { div.innerHTML = '<div class="empty-msg">ファイルがありません</div>'; return; }
    data.forEach(f => {
      const item = document.createElement('div');
      item.className = 'sf-item';
      item.innerHTML = `<label>
        <input type="checkbox" name="sf" value="${f.name}" onchange="updateBtn()">
        <span>${f.is_dir ? '📁' : '📄'}</span>
        <span>${f.name}</span>
        <span class="sf-meta">${f.size_str}</span>
      </label>`;
      div.appendChild(item);
    });
  }).catch(() => { div.innerHTML = '<div class="empty-msg">取得エラー</div>'; });
}
loadServerFiles();

function selectAll(state) {
  document.querySelectorAll('#server-files input[name="sf"]').forEach(c => c.checked = state);
  updateBtn();
}

// ---- ボタン制御 ----
function updateBtn() {
  const hasTarget = selectedDevices.length > 0;
  const hasUpload = uploadFiles.length > 0;
  const hasSF     = [...document.querySelectorAll('#server-files input[name="sf"]:checked')].length > 0;
  document.getElementById('send-btn').disabled = !(hasTarget && (hasUpload || hasSF));
}

// ---- プログレス ----
function setProgress(pct, label) {
  document.getElementById('progress-wrap').classList.add('show');
  document.getElementById('progress-bar').style.width   = pct + '%';
  document.getElementById('progress-label').textContent = label;
}
function hideProgress() {
  document.getElementById('progress-wrap').classList.remove('show');
  document.getElementById('progress-bar').style.width = '0%';
}

// ---- XHR(プログレス付き) ----
function xhrUpload(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);
    xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
    xhr.onload  = () => {
      try { resolve(JSON.parse(xhr.responseText)); }
      catch(e) { reject(new Error('レスポンスのパース失敗')); }
    };
    xhr.onerror = () => reject(new Error('ネットワークエラー'));
    xhr.send(formData);
  });
}

// ---- 送信 ----
async function sendAll() {
  if (!selectedDevices.length) { alert('送信先を選択してください'); return; }
  const btn    = document.getElementById('send-btn');
  const status = document.getElementById('status');
  btn.disabled = true; btn.textContent = '送信中...';
  status.innerHTML = '';
  hideProgress();
  const results = [];

  // 1) アップロードファイル(1ファイルずつ)
  for (let i = 0; i < uploadFiles.length; i++) {
    const f = uploadFiles[i];
    setProgress(
      Math.round(i / uploadFiles.length * 100),
      `送信中 ${i + 1} / ${uploadFiles.length}: ${f.name}`
    );
    const form = new FormData();
    form.append('devices', JSON.stringify(selectedDevices));
    form.append('file', f, f.name);
    try {
      const res = await xhrUpload('/send-upload', form, (loaded, total) => {
        const base = Math.round(i / uploadFiles.length * 100);
        const step = Math.round(loaded / total * (100 / uploadFiles.length));
        setProgress(base + step,
          `送信中 ${i + 1}/${uploadFiles.length}: ${f.name}  ${humanSize(loaded)} / ${humanSize(total)}`
        );
      });
      results.push(...res.results);
    } catch(e) {
      results.push({ ok: false, msg: `❌ ${f.name} 送信エラー: ${e.message}` });
    }
  }
  if (uploadFiles.length) hideProgress();

  // 2) サーバファイル
  const sfChecked = [...document.querySelectorAll('#server-files input[name="sf"]:checked')].map(c => c.value);
  if (sfChecked.length > 0) {
    status.innerHTML = '<span style="color:#555">⏳ サーバファイル送信中...</span>';
    try {
      const res = await fetch('/send-server', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ files: sfChecked, devices: selectedDevices })
      }).then(r => r.json());
      results.push(...res.results);
    } catch(e) {
      results.push({ ok: false, msg: `❌ サーバファイル送信エラー: ${e.message}` });
    }
  }

  status.innerHTML = results.map(r =>
    `<div class="${r.ok ? 'ok' : 'err'}">${r.msg}</div>`
  ).join('') || '<span class="ok">完了</span>';

  btn.textContent = '📤 送信';
  btn.disabled    = false;
  hideProgress();
}
</script>
</body>
</html>
"""


def get_tailscale_ip() -> str:
    try:
        ip = subprocess.check_output(['tailscale', 'ip', '-4'], timeout=5).decode().strip()
        if ip:
            return ip
    except Exception:
        pass
    return '0.0.0.0'


def run_tailscale_cp(src: str, device: str):
    try:
        subprocess.check_call(['tailscale', 'file', 'cp', src, f'{device}:'], timeout=300)
        return True, None
    except subprocess.TimeoutExpired:
        return False, 'タイムアウト'
    except subprocess.CalledProcessError as e:
        return False, f'終了コード {e.returncode}'
    except Exception as e:
        return False, str(e)


def human_size(size: int) -> str:
    for unit in ('B', 'KB', 'MB', 'GB'):
        if size < 1024:
            return f'{size} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
        size /= 1024
    return f'{size:.1f} TB'


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


@app.route('/devices')
def get_devices():
    try:
        result = subprocess.check_output(['tailscale', 'status', '--json'], timeout=10)
        data   = json.loads(result)
        peers  = []
        for peer in data.get('Peer', {}).values():
            name = peer.get('HostName') or peer.get('DNSName', '').rstrip('.')
            name = name.split('.')[0] if name else ''
            if not name:
                continue
            online   = peer.get('Online', False)
            lastseen = '接続中' if online else peer.get('LastSeen', '不明')[:16]
            peers.append({'name': name, 'online': online, 'lastseen': lastseen})
        peers.sort(key=lambda x: (not x['online'], x['name']))
        return jsonify(peers)
    except Exception as e:
        return jsonify([{'name': f'エラー: {e}', 'online': False, 'lastseen': ''}])


@app.route('/server-files')
def server_files():
    items = []
    try:
        for entry in sorted(os.scandir(SERVER_FILE_DIR), key=lambda e: (not e.is_dir(), e.name.lower())):
            try:
                if entry.is_dir():
                    total    = sum(f.stat().st_size for f in os.scandir(entry.path) if f.is_file())
                    size_str = f'{human_size(total)} (フォルダ)'
                    is_dir   = True
                else:
                    size_str = human_size(entry.stat().st_size)
                    is_dir   = False
                items.append({'name': entry.name, 'size_str': size_str, 'is_dir': is_dir})
            except PermissionError:
                pass
    except FileNotFoundError:
        pass
    return jsonify(items)


@app.route('/send-upload', methods=['POST'])
def send_upload():
    f = request.files.get('file')
    if not f:
        return jsonify({'results': [{'ok': False, 'msg': '❌ ファイルが見つかりません'}]})
    try:
        devices = json.loads(request.form.get('devices', '[]'))
    except Exception:
        return jsonify({'results': [{'ok': False, 'msg': '❌ 送信先パラメータが不正'}]})

    safe_name = secure_filename(f.filename) or 'upload'
    results   = []
    with tempfile.TemporaryDirectory() as tmp:
        save_path = os.path.join(tmp, safe_name)
        f.save(save_path)
        for dev in devices:
            ok, err = run_tailscale_cp(save_path, dev)
            msg = f'✅ {safe_name} → {dev}' if ok else f'❌ {safe_name} → {dev} : {err}'
            results.append({'ok': ok, 'msg': msg})
    return jsonify({'results': results})


@app.route('/send-server', methods=['POST'])
def send_server():
    body       = request.get_json(force=True, silent=True) or {}
    file_names = body.get('files', [])
    devices    = body.get('devices', [])
    results    = []

    with tempfile.TemporaryDirectory() as tmp:
        for name in file_names:
            safe_name = os.path.basename(name)
            if not safe_name or safe_name != name:
                results.append({'ok': False, 'msg': f'⚠️ 不正なファイル名: {name}'})
                continue
            src = os.path.join(SERVER_FILE_DIR, safe_name)
            if not os.path.exists(src):
                results.append({'ok': False, 'msg': f'⚠️ 見つかりません: {safe_name}'})
                continue

            if os.path.isdir(src):
                # サーバ上のフォルダはzip化して送信
                zip_name = safe_name + '.zip'
                zip_path = os.path.join(tmp, zip_name)
                with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
                    for root, dirs, ffiles in os.walk(src):
                        for ff in ffiles:
                            full = os.path.join(root, ff)
                            zf.write(full, os.path.relpath(full, os.path.dirname(src)))
                send_path, display_name = zip_path, zip_name
            else:
                send_path, display_name = src, safe_name

            for dev in devices:
                ok, err = run_tailscale_cp(send_path, dev)
                msg = f'✅ {display_name} → {dev}' if ok else f'❌ {display_name} → {dev} : {err}'
                results.append({'ok': ok, 'msg': msg})

    return jsonify({'results': results})


if __name__ == '__main__':
    port  = int(os.environ.get('PORT', 5000))
    ts_ip = get_tailscale_ip()
    print(f'🚀 Taildrop Web 起動')
    print(f'   バインド  : {ts_ip}:{port}  ← Tailscaleネットワーク内のみ')
    print(f'   アクセスURL: http://{ts_ip}:{port}')
    print(f'   サーバファイル: {SERVER_FILE_DIR}')
    app.run(host=ts_ip, port=port, debug=False)

起動

スクリプトを作成したら下記で起動します。

apt install -y python3-venv

# venv作成&インストール
python3 -m venv /opt/taildrop-venv
/opt/taildrop-venv/bin/pip install flask werkzeug

# スクリプト配置
mkdir -p /opt/lxd-data/taildrop   # サーバファイル置き場
cp taildrop_web.py /opt/taildrop-venv/

# 起動確認
/opt/taildrop-venv/bin/python /opt/taildrop-venv/taildrop_web.py

常時

問題ないようなら、自動起動する設定にしておきましょう。

# systemd 自動起動
cat > /etc/systemd/system/taildrop-web.service << 'EOF'
[Unit]
Description=Taildrop Web UI
After=network.target tailscaled.service

[Service]
ExecStart=/opt/taildrop-venv/bin/python /opt/taildrop-venv/taildrop_web.py
Restart=always
RestartSec=5
Environment=PORT=5000

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now taildrop-web

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