昨日、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

