WebからTaildropでファイル送信!複数にも対応

Tailscaleを入れているWindowsやMacでは、GUIで別のTailscaleを導入しているデバイスへファイルを送信出来ますが、Ubuntuから送信する場合はコマンドで行う必要があります。これを手軽に行うために、Web-UIを作ってみました。下記のように、送信先一覧から対象マシンのチェックを入れて、ファイルをドラッグ&ドロップすれば送信出来るようになります。複数に対し同時送信も可能です。

WebブラウザからTaildropでファイル送信

セットアップすれば、http://<コンテナのTS IP>:5000にアクセスしてファイルを送信出来るようになります。なお、ここでは認証なしでTailscaleネットワーク全体に公開されるようにしているので、信頼できるネットワークのみで使用してください。

LXDコンテナ内で実行

LXDコンテナ内で実行する事を想定しています。Dockerは使用していません。
Pythonを使用しており、まずはPython用のファイルを作成します。

nano taildrop_web.py
from flask import Flask, request, render_template_string, jsonify
from werkzeug.utils import secure_filename
import subprocess
import json
import os
import shutil
import tempfile

app = Flask(__name__)

HTML = """
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Taildrop Web</title>
    <style>
        body { font-family: system-ui; margin: 20px; max-width: 800px; }
        .drop-zone {
            border: 3px dashed #007acc; border-radius: 12px;
            padding: 60px; text-align: center; background: #f8f9fa;
            cursor: pointer; transition: background 0.2s;
        }
        .drop-zone.dragover { background: #e3f2ff; border-color: #005a99; }
        .devices { margin: 20px 0; }
        .device-label { display: block; padding: 6px 0; }
        .status { margin-top: 10px; padding: 10px; border-radius: 6px; }
        .success { background: #d4edda; }
        .error   { background: #f8d7da; }
        #send-btn {
            margin-top: 16px; padding: 12px 28px; font-size: 16px;
            background: #007acc; color: white; border: none;
            border-radius: 8px; cursor: pointer; display: none;
        }
        #send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    </style>
</head>
<body>
    <h1>🚀 Taildrop Web</h1>
    <p>送信先デバイスにチェックを入れ、ファイルをドラッグ&ドロップして送信してください。</p>

    <div class="devices">
        <h3>送信先デバイス</h3>
        <div id="device-list">読み込み中...</div>
    </div>

    <div id="drop-zone" class="drop-zone">
        <p>ここにファイルをドラッグ&ドロップ<br>またはクリックして選択</p>
        <input type="file" id="file-input" multiple style="display:none">
    </div>

    <div id="file-names" style="margin-top:8px;font-size:14px;color:#555;"></div>
    <button id="send-btn" onclick="sendFiles()">📤 送信</button>
    <div id="status"></div>

    <script>
        let selectedDevices = [];
        let selectedFiles = null;

        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 label = document.createElement('label');
                    label.className = 'device-label';
                    label.innerHTML = `
                        <input type="checkbox" value="${d.name}" ${d.online ? '' : 'disabled'}>
                        ${d.name} ${d.online ? '🟢' : '⚪'} (${d.lastseen})
                    `;
                    label.querySelector('input').onchange = function() {
                        if (this.checked) selectedDevices.push(this.value);
                        else selectedDevices = selectedDevices.filter(n => n !== this.value);
                        updateSendBtn();
                    };
                    div.appendChild(label);
                });
            })
            .catch(() => {
                document.getElementById('device-list').textContent = 'デバイス取得エラー';
            });

        const zone = document.getElementById('drop-zone');
        const fileInput = document.getElementById('file-input');

        zone.addEventListener('click', () => fileInput.click());
        fileInput.addEventListener('change', e => setFiles(e.target.files));
        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');
            setFiles(e.dataTransfer.files);
        });

        function setFiles(files) {
            selectedFiles = files;
            const names = Array.from(files).map(f => f.name).join(', ');
            document.getElementById('file-names').textContent = `選択中: ${names}`;
            updateSendBtn();
        }

        function updateSendBtn() {
            const btn = document.getElementById('send-btn');
            btn.style.display = (selectedFiles && selectedFiles.length) ? 'inline-block' : 'none';
            btn.disabled = !selectedDevices.length;
        }

        function sendFiles() {
            if (!selectedDevices.length) { alert('送信先デバイスをチェックしてください!'); return; }
            if (!selectedFiles || !selectedFiles.length) { alert('ファイルを選択してください!'); return; }

            const btn = document.getElementById('send-btn');
            btn.disabled = true;
            btn.textContent = '送信中...';

            const form = new FormData();
            Array.from(selectedFiles).forEach(f => form.append('files', f));
            form.append('devices', JSON.stringify(selectedDevices));

            fetch('/send', { method: 'POST', body: form })
                .then(r => r.json())
                .then(res => {
                    const status = document.getElementById('status');
                    const cls = res.has_error ? 'error' : 'success';
                    status.innerHTML = `<div class="status ${cls}">${res.message}</div>`;
                    btn.textContent = '📤 送信';
                    btn.disabled = false;
                    setTimeout(() => status.innerHTML = '', 8000);
                })
                .catch(err => {
                    document.getElementById('status').innerHTML =
                        `<div class="status error">通信エラー: ${err}</div>`;
                    btn.textContent = '📤 送信';
                    btn.disabled = false;
                });
        }
    </script>
</body>
</html>
"""


@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
        )
        status = json.loads(result)
        peers = []
        for peer in status.get('Peer', {}).values():
            # HostName を優先、なければ DNSName の末尾ドット除去
            name = peer.get('HostName') or peer.get('DNSName', '').rstrip('.')
            # さらに Tailscale ドメイン部分を除く(例: host.tail1234.ts.net → host)
            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 subprocess.TimeoutExpired:
        return jsonify([{'name': 'タイムアウト: tailscaleコマンドが応答しません', 'online': False, 'lastseen': ''}])
    except FileNotFoundError:
        return jsonify([{'name': 'エラー: tailscaleコマンドが見つかりません', 'online': False, 'lastseen': ''}])
    except Exception as e:
        return jsonify([{'name': f'エラー: {e}', 'online': False, 'lastseen': ''}])


@app.route('/send', methods=['POST'])
def send_files():
    files = request.files.getlist('files')
    try:
        devices = json.loads(request.form.get('devices', '[]'))
    except json.JSONDecodeError:
        return jsonify({'message': '送信先の形式が不正です', 'has_error': True})

    if not files or not devices:
        return jsonify({'message': 'ファイルか送信先がありません', 'has_error': True})

    results = []
    has_error = False

    with tempfile.TemporaryDirectory() as tmp:
        for file in files:
            if not file.filename:
                continue
            # ファイル名をサニタイズしつつ元のファイル名も記録
            safe_name = secure_filename(file.filename)
            if not safe_name:
                results.append(f'⚠️ ファイル名が無効: {file.filename}')
                has_error = True
                continue

            save_path = os.path.join(tmp, safe_name)
            file.save(save_path)

            for dev in devices:
                try:
                    # tailscale file cp はファイル名をそのまま相手に渡す
                    cmd = ['tailscale', 'file', 'cp', save_path, f'{dev}:']
                    subprocess.check_call(cmd, timeout=60)
                    results.append(f'✅ {safe_name} → {dev}')
                except subprocess.TimeoutExpired:
                    results.append(f'❌ {safe_name} → {dev} : タイムアウト')
                    has_error = True
                except subprocess.CalledProcessError as e:
                    results.append(f'❌ {safe_name} → {dev} : 送信失敗 (終了コード {e.returncode})')
                    has_error = True
                except Exception as e:
                    results.append(f'❌ {safe_name} → {dev} : {e}')
                    has_error = True

    message = '<br>'.join(results) + '<br>完了。'
    return jsonify({'message': message, 'has_error': has_error})


if __name__ == '__main__':
    # Tailscaleネットワーク内の全デバイスからアクセスできるよう 0.0.0.0 でバインド
    # LXDコンテナの場合、ホスト側のTailscale IPで外部からアクセス可能
    port = int(os.environ.get('PORT', 5000))
    print(f"🚀 Taildrop Web 起動 → http://0.0.0.0:{port}")
    print("Tailscale IP で他デバイスからアクセス: tailscale ip -4 を確認")

    # LXDコンテナ内でtailscale ipが取れる場合は表示
    try:
        ts_ip = subprocess.check_output(['tailscale', 'ip', '-4'], timeout=5).decode().strip()
        print(f"→ http://{ts_ip}:{port}")
    except Exception:
        pass

    app.run(host='0.0.0.0', port=port, debug=False)

作成が終わったら、下記コマンドでインストール、起動します。

apt install -y python3-venv
python3 -m venv /opt/taildrop
/opt/taildrop/bin/pip install flask werkzeug

# 起動
/opt/taildrop/bin/python taildrop_web.py

ファイルを送信する

起動したら表示されたアドレスにアクセスします。冒頭の画像のような画面が表示されるので、送信先へチェックし、送信したいファイルをドラッグ&ドロップします。

受信側

受信側では、下記コマンドを実行します。

tailscale file get /<保存ディレクトリ>
  • . を指定すると、現在のディレクトリに保存されます。
  • デフォルトの保存先は /var/lib/tailscale/files/ です。 

もしくは下記の方法で受信用スクリプトをあらかじめ実行しておけば、決めた場所へ自動で保存されます。

自動起動させる

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

# スクリプトをコピー
cp taildrop_web.py /opt/taildrop/

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

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

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now taildrop-web
systemctl status taildrop-web
タイトルとURLをコピーしました