From 898e0905585a547d35a47bac4b6531cc37cd68db Mon Sep 17 00:00:00 2001 From: codename-test Date: Tue, 28 Jul 2026 15:35:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20.trash=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=EF=BC=88=E5=B7=B2=E5=9C=A8=20.gitig?= =?UTF-8?q?nore=20=E4=B8=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trash/db.py | 717 ------------------------------------- .trash/design.md | 346 ------------------ .trash/improvement_plan.md | 92 ----- .trash/sender.py | 71 ---- 4 files changed, 1226 deletions(-) delete mode 100644 .trash/db.py delete mode 100644 .trash/design.md delete mode 100644 .trash/improvement_plan.md delete mode 100644 .trash/sender.py diff --git a/.trash/db.py b/.trash/db.py deleted file mode 100644 index 5a8821f..0000000 --- a/.trash/db.py +++ /dev/null @@ -1,717 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: UTF-8 -*- -""" -数据库模块:建表 + 全部 CRUD。 -v1.1 — 统一 message_log 表(合并旧 message_log + message_queue) -""" - -import sqlite3 -import json -import os -import uuid -import datetime as dt -import log - -DB_PATH = os.getenv("DB_PATH", "ego.db") - - -def _conn(): - global _conn_singleton - if '_conn_singleton' not in globals() or _conn_singleton is None: - _conn_singleton = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) - _conn_singleton.row_factory = sqlite3.Row - _conn_singleton.execute("PRAGMA busy_timeout=5000") - return _conn_singleton - - -# ═══════════════════════════════════════════════ -# Init -# ═══════════════════════════════════════════════ - -def init_db(): - conn = _conn() - conn.executescript(""" - CREATE TABLE IF NOT EXISTS parsers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - filename TEXT NOT NULL UNIQUE, - description TEXT DEFAULT '', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS sources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - port INTEGER UNIQUE NOT NULL, - parser_id INTEGER, - enabled INTEGER DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (parser_id) REFERENCES parsers(id) - ); - - CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - config TEXT NOT NULL DEFAULT '{}', - enabled INTEGER DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - engine TEXT DEFAULT 'jinja2', - title_tpl TEXT DEFAULT '', - content_tpl TEXT DEFAULT '', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS source_channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_id INTEGER NOT NULL, - channel_id INTEGER NOT NULL, - template_id INTEGER NOT NULL, - condition_expr TEXT DEFAULT '', - dedup_key_expr TEXT DEFAULT '', - dedup_window INTEGER DEFAULT 3600, - priority INTEGER DEFAULT 0, - enabled INTEGER DEFAULT 1, - urgent INTEGER DEFAULT 0, - FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE, - FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, - FOREIGN KEY (template_id) REFERENCES templates(id) - ); - - CREATE TABLE IF NOT EXISTS message_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trace_id TEXT NOT NULL, - source_id INTEGER, - source_name TEXT, - raw_body TEXT, - msg_json TEXT, - dedup_key TEXT, - status TEXT DEFAULT 'RECEIVED', - channel_results TEXT, - error TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - sent_at TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS system_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - level TEXT NOT NULL DEFAULT 'INFO', - module TEXT DEFAULT '', - message TEXT NOT NULL, - trace_id TEXT DEFAULT '' - ); - - -- 内置 Emby 解析器 - INSERT OR IGNORE INTO parsers (id, name, filename, description) - VALUES (1, 'Emby Webhook', 'emby.py', '解析 Emby/Jellyfin Webhook 数据'); - - -- 默认模板 - INSERT OR IGNORE INTO templates (id, name, engine, title_tpl, content_tpl) - VALUES (1, '默认模板', 'jinja2', - '{{ msg.title }}', - ''); - - -- 迁移:旧版默认 content_tpl '{{ msg.content }}' → 空字符串(启用自动KV列表) - UPDATE templates SET content_tpl = '' - WHERE id = 1 AND content_tpl = '{{ msg.content }}'; - - -- DND / 日志等级默认值 - INSERT OR IGNORE INTO system_config (key, value) VALUES ('dnd_enabled', '0'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('dnd_start', '23:00'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('dnd_end', '07:00'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('log_level', 'WARNING'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_SUCCESS', '168'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_FAILED', '720'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_RECEIVED', '24'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_PARSED', '24'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_NO_MATCH', '168'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_DISCARDED', '168'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_IGNORED', '168'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_PENDING', '0'); - INSERT OR IGNORE INTO system_config (key, value) VALUES ('cleanup_SENDING', '0'); - """) - - - # migrate old source_channels that lack dedup columns - try: - conn.execute("ALTER TABLE source_channels ADD COLUMN dedup_key_expr TEXT DEFAULT ''") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE source_channels ADD COLUMN dedup_window INTEGER DEFAULT 3600") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE source_channels ADD COLUMN urgent INTEGER DEFAULT 0") - except sqlite3.OperationalError: - pass - - # migrate old message_log that lack new columns - for col in ['source_name', 'msg_json', 'dedup_key', 'channel_results', 'sent_at', 'updated_at']: - try: - if col == 'updated_at': - conn.execute(f"ALTER TABLE message_log ADD COLUMN {col} TIMESTAMP DEFAULT CURRENT_TIMESTAMP") - else: - conn.execute(f"ALTER TABLE message_log ADD COLUMN {col} TEXT") - except sqlite3.OperationalError: - pass - - # drop old message_queue if exists - conn.execute("DROP TABLE IF EXISTS message_queue") - conn.commit() - - -# ═══════════════════════════════════════════════ -# Parsers -# ═══════════════════════════════════════════════ - -def get_parsers(): - return [dict(r) for r in _conn().execute("SELECT * FROM parsers ORDER BY id").fetchall()] - - -def get_parser(parser_id): - r = _conn().execute("SELECT * FROM parsers WHERE id=?", (parser_id,)).fetchone() - return dict(r) if r else None - - -def create_parser(name, filename, description=""): - try: - c = _conn().execute( - "INSERT INTO parsers (name, filename, description) VALUES (?,?,?)", - (name, filename, description) - ) - _conn().commit() - return c.lastrowid - except sqlite3.IntegrityError: - return None - - -def update_parser(parser_id, **kwargs): - if not kwargs: - return - allowed = {"name", "filename", "description"} - sets = [f"{k}=?" for k in kwargs if k in allowed] - vals = [kwargs[k] for k in kwargs if k in allowed] - if not sets: - return - vals.append(parser_id) - _conn().execute(f"UPDATE parsers SET {', '.join(sets)} WHERE id=?", vals) - _conn().commit() - - -def delete_parser(parser_id): - _conn().execute("DELETE FROM parsers WHERE id=?", (parser_id,)) - _conn().commit() - - -def upsert_parser(pid, name, filename, description=""): - """带显式 ID 的插入/覆盖(config_manager.load_all 用)。""" - _conn().execute( - "INSERT OR REPLACE INTO parsers (id, name, filename, description) VALUES (?,?,?,?)", - (pid, name, filename, description) - ) - _conn().commit() - - -# ═══════════════════════════════════════════════ -# Sources -# ═══════════════════════════════════════════════ - -def get_sources(): - return [dict(r) for r in _conn().execute("SELECT * FROM sources ORDER BY id").fetchall()] - - -def get_source(source_id): - r = _conn().execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() - return dict(r) if r else None - - -def get_source_by_port(port): - r = _conn().execute("SELECT * FROM sources WHERE port=?", (port,)).fetchone() - return dict(r) if r else None - - -def create_source(name, port, parser_id=None, enabled=1): - try: - c = _conn().execute( - "INSERT INTO sources (name, port, parser_id, enabled) VALUES (?,?,?,?)", - (name, port, parser_id, enabled) - ) - _conn().commit() - return c.lastrowid - except sqlite3.IntegrityError: - return None - - -def update_source(source_id, **kwargs): - if not kwargs: - return - allowed = {"name", "port", "parser_id", "enabled"} - sets = [f"{k}=?" for k in kwargs if k in allowed] - vals = [kwargs[k] for k in kwargs if k in allowed] - if not sets: - return - vals.append(source_id) - _conn().execute(f"UPDATE sources SET {', '.join(sets)} WHERE id=?", vals) - _conn().commit() - - -def delete_source(source_id): - _conn().execute("DELETE FROM sources WHERE id=?", (source_id,)) - _conn().commit() - - -def upsert_source(sid, name, port, parser_id=None, enabled=1): - """带显式 ID 的插入/覆盖(config_manager.load_all 用)。""" - _conn().execute( - "INSERT OR REPLACE INTO sources (id, name, port, parser_id, enabled) VALUES (?,?,?,?,?)", - (sid, name, port, parser_id, enabled) - ) - _conn().commit() - - -# ═══════════════════════════════════════════════ -# Channels -# ═══════════════════════════════════════════════ - -def get_channels(): - return [dict(r) for r in _conn().execute("SELECT * FROM channels ORDER BY id").fetchall()] - - -def get_channel(channel_id): - r = _conn().execute("SELECT * FROM channels WHERE id=?", (channel_id,)).fetchone() - return dict(r) if r else None - - -def create_channel(name, channel_type, config="{}", enabled=1): - if isinstance(config, dict): - config = json.dumps(config) - c = _conn().execute( - "INSERT INTO channels (name, type, config, enabled) VALUES (?,?,?,?)", - (name, channel_type, config, enabled) - ) - _conn().commit() - return c.lastrowid - - -def update_channel(channel_id, **kwargs): - if not kwargs: - return - allowed = {"name", "type", "config", "enabled"} - sets = [] - vals = [] - for k in allowed: - if k in kwargs: - sets.append(f"{k}=?") - v = kwargs[k] - vals.append(json.dumps(v) if k == "config" and isinstance(v, dict) else v) - if not sets: - return - vals.append(channel_id) - _conn().execute(f"UPDATE channels SET {', '.join(sets)} WHERE id=?", vals) - _conn().commit() - - -def delete_channel(channel_id): - _conn().execute("DELETE FROM channels WHERE id=?", (channel_id,)) - _conn().commit() - - -def upsert_channel(cid, name, channel_type, config="{}", enabled=1): - """带显式 ID 的插入/覆盖(config_manager.load_all 用)。""" - if isinstance(config, dict): - config = json.dumps(config) - _conn().execute( - "INSERT OR REPLACE INTO channels (id, name, type, config, enabled) VALUES (?,?,?,?,?)", - (cid, name, channel_type, config, enabled) - ) - _conn().commit() - - -# ═══════════════════════════════════════════════ -# Templates -# ═══════════════════════════════════════════════ - -def get_templates(): - return [dict(r) for r in _conn().execute("SELECT * FROM templates ORDER BY id").fetchall()] - - -def get_template(template_id): - r = _conn().execute("SELECT * FROM templates WHERE id=?", (template_id,)).fetchone() - return dict(r) if r else None - - -def create_template(name, engine="jinja2", title_tpl="", content_tpl=""): - c = _conn().execute( - "INSERT INTO templates (name, engine, title_tpl, content_tpl) VALUES (?,?,?,?)", - (name, engine, title_tpl, content_tpl) - ) - _conn().commit() - return c.lastrowid - - -def update_template(template_id, **kwargs): - if not kwargs: - return - allowed = {"name", "engine", "title_tpl", "content_tpl"} - sets = [f"{k}=?" for k in kwargs if k in allowed] - vals = [kwargs[k] for k in kwargs if k in allowed] - if not sets: - return - vals.append(template_id) - _conn().execute(f"UPDATE templates SET {', '.join(sets)} WHERE id=?", vals) - _conn().commit() - - -def delete_template(template_id): - _conn().execute("DELETE FROM templates WHERE id=?", (template_id,)) - _conn().commit() - - -def upsert_template(tid, name, engine="jinja2", title_tpl="", content_tpl=""): - """带显式 ID 的插入/覆盖(config_manager.load_all 用)。""" - _conn().execute( - "INSERT OR REPLACE INTO templates (id, name, engine, title_tpl, content_tpl) VALUES (?,?,?,?,?)", - (tid, name, engine, title_tpl, content_tpl) - ) - _conn().commit() - - -# ═══════════════════════════════════════════════ -# Source-Channels bindings -# ═══════════════════════════════════════════════ - -def get_source_channels(source_id): - return [dict(r) for r in _conn().execute( - "SELECT * FROM source_channels WHERE source_id=? ORDER BY priority", - (source_id,) - ).fetchall()] - - -def get_all_source_channels(): - return [dict(r) for r in _conn().execute( - "SELECT * FROM source_channels ORDER BY source_id, priority" - ).fetchall()] - - -def create_source_channel(source_id, channel_id, template_id, condition_expr="", priority=0, enabled=1, urgent=0, dedup_key_expr="", dedup_window=3600): - c = _conn().execute( - """INSERT INTO source_channels - (source_id, channel_id, template_id, condition_expr, priority, enabled, urgent, dedup_key_expr, dedup_window) - VALUES (?,?,?,?,?,?,?,?,?)""", - (source_id, channel_id, template_id, condition_expr, priority, enabled, urgent, dedup_key_expr, dedup_window) - ) - _conn().commit() - return c.lastrowid - - -def update_source_channel(sc_id, **kwargs): - if not kwargs: - return - allowed = {"channel_id", "template_id", "condition_expr", "dedup_key_expr", "dedup_window", "priority", "enabled", "urgent"} - sets = [f"{k}=?" for k in kwargs if k in allowed] - vals = [kwargs[k] for k in kwargs if k in allowed] - if not sets: - return - vals.append(sc_id) - _conn().execute(f"UPDATE source_channels SET {', '.join(sets)} WHERE id=?", vals) - _conn().commit() - - -def delete_source_channel(sc_id): - _conn().execute("DELETE FROM source_channels WHERE id=?", (sc_id,)) - _conn().commit() - - -def upsert_source_channel(sc_id, source_id, channel_id, template_id, - condition_expr="", priority=0, enabled=1, urgent=0, - dedup_key_expr="", dedup_window=3600): - """带显式 ID 的插入/覆盖(config_manager.load_all 用)。""" - _conn().execute( - """INSERT OR REPLACE INTO source_channels - (id, source_id, channel_id, template_id, condition_expr, - dedup_key_expr, dedup_window, priority, enabled, urgent) - VALUES (?,?,?,?,?,?,?,?,?,?)""", - (sc_id, source_id, channel_id, template_id, condition_expr, - dedup_key_expr, dedup_window, priority, enabled, urgent) - ) - _conn().commit() - - -# ═══════════════════════════════════════════════ -# Unified Message Log (merged old message_log + message_queue) -# ═══════════════════════════════════════════════ - -def create_message_log(trace_id, source_id, source_name="", raw_body="", status="RECEIVED"): - """新建消息记录。返回自增 id。""" - c = _conn().execute( - "INSERT INTO message_log (trace_id, source_id, source_name, raw_body, status) VALUES (?,?,?,?,?)", - (trace_id, source_id, source_name, raw_body, status) - ) - _conn().commit() - return c.lastrowid - - -def update_message(trace_id, **kwargs): - """按 trace_id 更新消息记录。""" - if not kwargs: - return - allowed = {"status", "msg_json", "error", "channel_results", "raw_body", "sent_at", "dedup_key"} - sets = [f"{k}=?" for k in kwargs if k in allowed] - if not sets: - return - vals = [kwargs[k] for k in kwargs if k in allowed] - vals.append(trace_id) - _conn().execute(f"UPDATE message_log SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE trace_id=?", vals) - _conn().commit() - - -def update_message_by_id(msg_id, **kwargs): - """按 id 更新消息记录。""" - if not kwargs: - return - allowed = {"status", "msg_json", "error", "channel_results", "raw_body", "sent_at", "dedup_key"} - sets = [f"{k}=?" for k in kwargs if k in allowed] - if not sets: - return - vals = [kwargs[k] for k in kwargs if k in allowed] - vals.append(msg_id) - _conn().execute(f"UPDATE message_log SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?", vals) - _conn().commit() - - -def get_message(trace_id): - """按 trace_id 获取消息。""" - r = _conn().execute("SELECT * FROM message_log WHERE trace_id=?", (trace_id,)).fetchone() - return dict(r) if r else None - - -def get_message_by_id(msg_id): - """按 id 获取消息。""" - r = _conn().execute("SELECT * FROM message_log WHERE id=?", (msg_id,)).fetchone() - return dict(r) if r else None - - -def get_pending_messages(source_id=None): - """获取 PENDING 状态的队列消息。""" - if source_id: - rows = _conn().execute( - "SELECT * FROM message_log WHERE source_id=? AND status='PENDING' ORDER BY created_at", - (source_id,), - ).fetchall() - else: - rows = _conn().execute( - "SELECT * FROM message_log WHERE status='PENDING' ORDER BY created_at" - ).fetchall() - return [dict(r) for r in rows] - - -def get_messages(source_id=None, status=None, channel_type=None, limit=100, offset=0): - """分页查询消息,支持按 source_id/status/channel_type 筛选。""" - sql = "SELECT * FROM message_log WHERE 1=1" - params = [] - if source_id: - sql += " AND source_id=?" - params.append(source_id) - if status: - sql += " AND status=?" - params.append(status) - if channel_type: - sql += " AND channel_results LIKE ?" - params.append(f'%"{channel_type}"%') - sql += " ORDER BY id DESC LIMIT ? OFFSET ?" - params.extend([limit, offset]) - return [dict(r) for r in _conn().execute(sql, params).fetchall()] - - -def get_message_count(status=None, source_id=None, today_only=False): - """统计消息数量。""" - sql = "SELECT COUNT(*) FROM message_log WHERE 1=1" - params = [] - if status: - sql += " AND status=?" - params.append(status) - if source_id: - sql += " AND source_id=?" - params.append(source_id) - if today_only: - sql += " AND date(created_at)=date('now')" - return _conn().execute(sql, params).fetchone()[0] - - -def delete_message(msg_id): - """删除消息记录。""" - _conn().execute("DELETE FROM message_log WHERE id=?", (msg_id,)) - _conn().commit() - - -# 所有消息状态类型及其说明(供清理配置和前端使用) -MESSAGE_STATUSES = { - "RECEIVED": "已接收,尚未被解析器处理", - "PARSED": "解析成功,等待路由匹配通道", - "NO_MATCH": "解析成功,但没有匹配的路由规则", - "PENDING": "处于免打扰时段,排队等待发送", - "SENDING": "正在发送中", - "SUCCESS": "所有通道推送成功", - "FAILED": "解析或推送失败", - "DISCARDED": "去重命中,主动丢弃", - "IGNORED": "手动标记为已处理", -} - - -def get_cleanup_config(): - """返回 {status: hours} 字典,从 system_config 读取每个状态的保留时长。""" - return {s: int(get_config(f"cleanup_{s}", "0")) for s in MESSAGE_STATUSES} - - -def cleanup_old_messages(overrides=None): - """清理旧消息。每个状态独立保留时长,0=不清理。overrides 为 {status: hours} 可覆盖配置。返回删除条数。""" - cfg = get_cleanup_config() - if overrides: - cfg.update(overrides) - conn = _conn() - total = 0 - for status, hours in cfg.items(): - if hours <= 0: - continue - cursor = conn.execute( - "DELETE FROM message_log WHERE status=? AND created_at < datetime('now','localtime',?||' hours')", - (status, f"-{hours}") - ) - total += cursor.rowcount - conn.commit() - return total - - -def mark_ignored(msg_id): - """标记消息为已忽略。""" - _conn().execute("UPDATE message_log SET status='IGNORED' WHERE id=?", (msg_id,)) - _conn().commit() - - -def check_dedup(dedup_key, window_seconds): - """检查是否命中去重:同一 dedup_key 在窗口时间内已 SUCCESS 发送过。""" - r = _conn().execute( - "SELECT COUNT(*) FROM message_log WHERE dedup_key=? AND status='SUCCESS' AND sent_at > datetime('now','-'||?||' seconds')", - (dedup_key, str(window_seconds)) - ).fetchone() - return r[0] > 0 - - -def get_queue_stats(): - """队列统计(来自统一 message_log 表)。""" - total = _conn().execute("SELECT COUNT(*) FROM message_log WHERE status IN ('PENDING','FAILED','SUCCESS')").fetchone()[0] - pending = _conn().execute("SELECT COUNT(*) FROM message_log WHERE status='PENDING'").fetchone()[0] - sent = _conn().execute("SELECT COUNT(*) FROM message_log WHERE status='SUCCESS'").fetchone()[0] - failed = _conn().execute("SELECT COUNT(*) FROM message_log WHERE status='FAILED'").fetchone()[0] - return {"total": total, "pending": pending, "sent": sent, "failed": failed} - - -# ═══════════════════════════════════════════════ -# System Config -# ═══════════════════════════════════════════════ - -def get_config(key, default=None): - r = _conn().execute("SELECT value FROM system_config WHERE key=?", (key,)).fetchone() - return r["value"] if r else default - - -def set_config(key, value): - _conn().execute( - "INSERT OR REPLACE INTO system_config (key, value) VALUES (?,?)", - (key, str(value)) - ) - _conn().commit() - - -def get_dnd(): - return { - "enabled": get_config("dnd_enabled", "0") == "1", - "start_time": get_config("dnd_start", "23:00"), - "end_time": get_config("dnd_end", "07:00"), - } - - -def get_log_level(): - return get_config("log_level", "WARNING") - - -# ═══════════════════════════════════════════════ -# Logs -# ═══════════════════════════════════════════════ - -def add_log(level, message, module="", trace_id=""): - _conn().execute( - "INSERT INTO logs (timestamp, level, message, module, trace_id) VALUES (datetime('now','localtime'),?,?,?,?)", - (level, message, module, trace_id) - ) - _conn().commit() - # 保留最近 10000 条 - _conn().execute( - "DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 10000)" - ) - _conn().commit() - - -def get_logs(level=None, limit=200): - level_order = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4} - sql = "SELECT * FROM logs WHERE 1=1" - params = [] - if level and isinstance(level, str): - min_level = level_order.get(level.upper(), 1) - levels_to_show = [l for l, v in level_order.items() if v >= min_level] - placeholders = ",".join(["?"] * len(levels_to_show)) - sql += f" AND level IN ({placeholders})" - params.extend(levels_to_show) - sql += " ORDER BY id DESC LIMIT ?" - params.append(limit) - return [dict(r) for r in _conn().execute(sql, params).fetchall()] - - -def clear_logs(): - _conn().execute("DELETE FROM logs") - _conn().commit() - - -def set_log_level(level): - _conn().execute( - "INSERT OR REPLACE INTO system_config (key, value) VALUES (?,?)", - ("log_level", level) - ) - _conn().commit() - import logging - log.logger.setLevel(getattr(logging, level, logging.INFO)) - for h in log.logger.handlers: - h.setLevel(getattr(logging, level, logging.INFO)) - - -# ═══════════════════════════════════════════════ -# Stats -# ═══════════════════════════════════════════════ - -def get_stats(): - qs = get_queue_stats() - return { - "sources": _conn().execute("SELECT COUNT(*) FROM sources").fetchone()[0], - "channels": _conn().execute("SELECT COUNT(*) FROM channels").fetchone()[0], - "templates": _conn().execute("SELECT COUNT(*) FROM templates").fetchone()[0], - "messages_today": get_message_count(today_only=True), - "queue_total": qs["total"], - "queue_pending": qs["pending"], - "queue_sent": qs["sent"], - "queue_failed": qs["failed"], - "messages_failed": get_message_count(status="FAILED"), - "messages_success": get_message_count(status="SUCCESS"), - } diff --git a/.trash/design.md b/.trash/design.md deleted file mode 100644 index 62bf147..0000000 --- a/.trash/design.md +++ /dev/null @@ -1,346 +0,0 @@ -# EGo 项目设计文档 v1.2.0 - -> EverywhereYouGo — 通用信息转发平台 - -## 架构 - -``` - ┌─────────────── EventBus (blinker) ───────────────┐ - │ │ -HTTP POST ──→ source_listener ──→ parser_engine ──→ router_engine ──→ sender_engine - │ │ │ │ - │ parser_loader.py router.py renderer.py - │ parsers/emby.py simpleeval Jinja2(Sandboxed)/Simple - │ channel_loader.py - │ channels/*.py - │ │ - │ ┌────┴────┐ - │ │ enqueue │ - │ └────┬────┘ - │ ▼ - │ message_queue (SQLite) - │ │ - │ worker.py - │ (后台消费) - │ │ - │ 失败重试 → DLQ - │ - └──────── db / config_manager / log ───────────────┘ -``` - -**数据流**:Source HTTP 监听器接收 Webhook → 事件总线分发 → 解析引擎执行解析器 → 路由引擎匹配条件 → 发送引擎渲染模板并**异步入队** → Worker 后台消费 → 推送通道。 - -**事件总线信号**(`bus.py`,基于 blinker): - -| 信号 | 触发时机 | -|------|----------| -| `message.received` | HTTP 收到原始数据 | -| `message.parsed` | 解析器执行完成 | -| `message.routed` | 路由匹配完成 | -| `message.sending` | 开始推送 | -| `message.sent` | 推送成功 | -| `message.failed` | 推送失败 | -| `config.changed` | 配置变更 | -| `source.started` / `source.stopped` | 数据源启停 | - -## 组件说明 - -### 数据源 (Source) -- `source_listener/` — HTTP 监听器包,每个数据源独立端口 + 独立线程 -- 接收 HTTP POST,提取 body + headers + query params -- **请求体限制 5MB**(Content-Length 超限返回 413),读取超时 10s、整体 60s -- 通过 `source_manager.process_message()` 触发全链路 -- 自动保存最近 20 条样本数据(供解析器测试) - -### 解析器 (Parser) -- `parsers/*.py` — 用户自定义解析器,每个文件定义 `parse(raw_body, headers, query_params)` 函数 -- `parser_loader.py` — 动态加载 + 缓存 + 在线重载 -- `parser_engine/` — 事件引擎包,监听 `message.received`,调用 parser_loader 执行解析 -- **返回结构**:`dict`,必须包含 `title`(字符串)。其余所有顶层字段自动展平为 KV 对,同时作为路由条件变量和模板渲染变量 - ```python - # 示例返回 - {"title": "星际穿越", "event": "library.new", "media_type": "Movie", "Overview": "一部科幻电影..."} - ``` -- title 自动生成兜底:若解析器未设 title,按 Name/title/Subject/Event 优先级查找,找不到用第一个非空值 -- **Parser 版本绑定**:消息入库时记录解析器内容 MD5(`parser_hash`),重发时若解析器已变更会告警,确保 PENDING 消息用原始版本语义 - -### 路由 (Router) -- `router.py` — 条件匹配引擎,基于 simpleeval 安全求值 -- `router_engine/` — 事件引擎包,监听 `message.parsed`,执行路由匹配 + DND 检测 -- 条件表达式:`event == 'library.new' and media_type == 'Movie'` -- 支持 `and`/`or`/括号分组 -- 空条件 = 默认匹配(无条件绑定兜底) -- **DND(勿扰模式)**:可配置起止时间,DND 时段内非紧急消息自动进入 PENDING 队列,结束后自动刷新发送 -- **DND 队列上限 10000 条**,溢出消息直接 DISCARD 并告警,防长时间 DND 内存膨胀 - -### 模板 (Template) -- `renderer.py` — 双引擎渲染 - - **Simple**:`{varName}` 替换,变量为 msg 的所有顶层标量字段 - - **Jinja2**:`{{ msg.varName }}`,msg 作为上下文变量注入,**使用 `SandboxedEnvironment` 防 SSTI**(拦截 `__class__`/`import`/`attr('__xxx__')` 等危险操作) -- 模板存储为 title_tpl + content_tpl 两段 - -### 渠道 (Channel) -- `channels/` — 6 种内置通道: - - 企业微信 Bot(`wechat_work_bot.py`) - - 企业微信 API(`wechat_work_api.py`) - - 钉钉(`dingtalk.py`) - - 飞书(`feishu.py`) - - Telegram(`telegram_bot.py`) - - Bark(`bark.py`) -- `channel_loader.py` — 通道插件加载器,支持用户自定义 Python 通道插件(Channel SDK) -- `sender_engine/` — 事件引擎包,监听 `message.routed`,执行去重检查 → **按通道入队** → Worker 异步消费 - - 去重:支持多字段拼接去重键(如 `event+Item.Type`)+ 可配窗口时间 - - 双路径:webhook 流入队异步发送;flush/retry 直接发送(绕过队列) - - 并行:ThreadPoolExecutor,最多 10 并发 - -### 异步队列 (Queue) -- `queue_backend.py` — 队列抽象层,默认 `SQLiteQueueBackend`,预留 Redis 升级接口 -- `worker.py` — 后台消费线程(默认 1 个,SQLite 单写者友好;Redis 后端可多开) -- **粒度**:按"消息 × 通道"入队,单通道失败不影响其他通道,重试只重发失败通道 -- **重试策略**:3 次指数退避(5s / 30s / 2min),耗尽后移入死信队列(DLQ) -- **DLQ**:`dead_letter_queue` 表,UI 可手动重发或丢弃 -- **崩溃恢复**:启动时 `recover_processing()` 把卡在 processing 的任务重置为 pending -- **WAL 模式**:SQLite 启用 `journal_mode=WAL`,并发读写不阻塞 - -### 消息生命周期 - -``` -RECEIVED → PARSED → SENDING → SUCCESS / FAILED - ↘ NO_MATCH - ↘ PENDING (DND 期间) - ↘ DISCARDED (去重命中 / DND 队列溢出) -``` - -## 配置存储 - -- `config/*.json` — 配置持久化(**唯一真相源**),5 类配置: - - `sources.json`(数据源) - - `channels.json`(推送渠道) - - `templates.json`(推送模板) - - `bindings.json`(数据源→渠道绑定,含条件表达式/去重配置) - - `settings.json`(系统设置:DND/日志等) -- SQLite(`ego.db`)— 消息日志 + 队列 + 运行时缓存 -- `config_manager.py` — 启动时 JSON → SQLite 同步,UI 编辑即时双向同步,外部修改通过 mtime 检测 -- **文件锁**:JSON 读写使用 `fcntl.flock`(读共享锁 / 写排他锁),防并发写损坏 -- **Schema 校验**:5 类配置加载时校验必需字段(parsers/sources/channels/templates/bindings),格式错误记日志告警 - -## 认证 - -可选功能,默认不开启。T1/T2 信任局域网无需认证,T3/T4 由 Nginx 处理。 - -若手动开启,通过环境变量配置: -- `EGO_AUTH_TOKEN` — API Bearer Token 验证 -- `EGO_SECRET_KEY` — Flask session 签名(启动时检查弱 Key 并告警) - -Session 24h 自动过期,`/api/health` 路由免认证(供监控探针)。 - -## SSL - -EGo 支持自签名 SSL,仅用于 **Web UI 管理页面**,提供两个便利: -- 浏览器不报"不安全" -- 剪贴板 API(`navigator.clipboard`)在 HTTPS 下正常工作 - -**Webhook 数据接收端口始终走 HTTP**,不受 SSL 影响。T3/T4 部署中数据接收的 SSL 由 Nginx 统一处理。 - -通过 `gen_cert.py` 首次启动自动生成自签名证书(默认 `certs/ego.crt` + `certs/ego.key`),支持环境变量覆盖: -- `EGO_SSL_DIR` — 证书目录 -- `EGO_SSL_CERT` — 证书文件路径 -- `EGO_SSL_KEY` — 私钥文件路径 - -## 健康检查 - -`GET /api/health` 返回 JSON,检查项: -- SQLite 连接可用性 -- 磁盘剩余空间 -- 配置文件完整性 -- 队列积压状态(pending / processing / dlq) - -免认证访问,供 Docker healthcheck、Nginx upstream 探针、Prometheus blackbox 等使用。 - -## 版本更新检测 - -- `version_checker.py` — 后台线程,启动 5s 后首次检查,之后每 24h -- 对比 GitHub `version.json` 与本地版本 -- 有新版本时侧边栏显示绿点提示 + WebSocket 推送 -- API:`GET /api/version/check`(查看)、`POST /api/version/check`(手动触发) - -## 部署架构 - -EGo 支持 4 种部署层级,按场景从简到繁: - -| 层级 | 命名 | 网络模式 | HTTPS | 证书管理 | 运维复杂度 | 推荐场景 | -|------|------|----------|-------|----------|-----------|----------| -| T1 | 裸机直连 | `host` | ❌ | 无 | ⭐ | 家庭/内网调试 | -| T2 | Docker 内网 | `bridge` | ❌ | 无 | ⭐⭐ | 容器间协同 | -| T3 | 企业级部署 | `bridge` + Nginx | ✅ | 手动证书 | ⭐⭐⭐ | 正式生产环境 | -| T4 | 懒人全自动 | `bridge` + Nginx + Certbot | ✅ | 自动 Let's Encrypt | ⭐⭐ | 个人/小团队云端部署 | - -### T1 裸机直连 -- EGo 直接监听宿主机端口(HTTP),局域网内直连访问 -- 适用于家庭内网设备(NAS、路由器等) - -### T2 Docker 内网 -- EGo 运行在 Docker 容器中,bridge 网络,通过宿主机端口映射访问 -- 适用于容器编排场景(多服务间协同) - -### T3 企业级部署 -- EGo 在 Docker bridge 内只开 HTTP,前端由 Nginx 反向代理 + SSL 终结 -- Nginx 负责:HTTPS 证书、认证、速率限制、请求体大小限制 -- 适用于正式生产环境,有固定域名或公网 IP - -### T4 懒人全自动 -- 在 T3 基础上增加 Certbot 自动申请和续签 Let's Encrypt 证书 -- 适合个人/小团队云端服务器,无需手动管理证书 - -### 职责分工 - -| 功能 | EGo(所有层级) | Nginx(T3/T4) | -|------|----------------|----------------| -| 消息接收与转发 | ✅ 核心处理 | — | -| WebUI SSL | ✅ 自签名证书,方便浏览器访问 | ✅ 可选代理接管 | -| Webhook 数据接收 SSL | ❌ 始终 HTTP | ✅ Nginx 统一处理 | -| 用户认证 | ❌ 默认无(局域网信任) | ✅ 按需配置 | -| API 限流 | ❌ 不内置 | ✅ `limit_req` | -| 请求体大小限制 | ✅ 5MB 内置防护 | ✅ `client_max_body_size` 可叠加 | -| Webhook 来源鉴权 | ❌ 不内置 | ✅ 按需配置 | - -> **说明**: -> - **WebUI SSL**:EGo 用自签名证书给管理页面开 HTTPS,T1/T2 下浏览器不报不安全、剪贴板 API 正常工作。T3/T4 里可以交由 Nginx 统一管理。 -> - **Webhook 数据接收**:始终走 HTTP,与 SSL 无关。T3/T4 由 Nginx 前置代理做 SSL 终结。 -> - **安全边界**:T1/T2 信任局域网环境。T3/T4 将外部安全交由 Nginx 处理。EGo 代码层只负责核心逻辑相关事项——如 SSTI 防护(功能级 RCE)、出站通道限流(Nginx 管不到出站)等。 - -## 备份恢复 - -- **备份**:下载 ZIP(`config/*.json` + `parsers/*.py` + `version.txt`) -- **恢复**:上传 ZIP,覆盖配置后即时生效,支持插入/覆盖模式 - -## 国际化 - -- 中英双语支持(`i18n.py`) -- 页面顶部语言切换 - -## 目录结构 - -``` -EverywhereYouGo/ -├── main.py # 入口:初始化 DB → 启动 Worker → 启动监听 → 启动 WebUI -├── web_ui.py # Flask 启动(兼容层,支持自签名 SSL + 环境变量证书路径) -├── bus.py # 事件总线(blinker 信号声明 + emit/on/off) -│ -├── source_listener/ # HTTP 监听器包(Source HTTP Server) -│ └── __init__.py # HookHandler + ListenerManager + 样本存储 + 5MB Body 限制 -├── parser_engine/ # 解析引擎包(事件驱动) -│ └── __init__.py # 监听 message.received,调用 parser_loader,记录 parser_hash -├── router_engine/ # 路由引擎包(事件驱动) -│ └── __init__.py # 监听 message.parsed,DND 检测 + 路由匹配 + 队列上限保护 -├── sender_engine/ # 发送引擎包(事件驱动) -│ └── __init__.py # 监听 message.routed,去重 → 按通道入队 / 直发 -│ -├── queue_backend.py # 队列抽象层(SQLite 默认,Redis 升级口) -├── worker.py # 后台消费线程(轮询 + 重试 + DLQ) -│ -├── parser_loader.py # 解析器动态加载 + 缓存 + 重载 -├── router.py # 路由条件匹配(simpleeval) -├── renderer.py # 模板渲染(Simple + Jinja2 SandboxedEnvironment) -├── channel_loader.py # 通道插件加载器(Channel SDK) -├── source_manager.py # 编排层:全链路 process_message + 队列刷新 + 重发 -│ -├── api/ # RESTful API(11 个蓝图) -│ ├── __init__.py # Blueprint 注册 + 认证中间件 + Session 24h 过期 -│ ├── auth.py # 登录/登出 -│ ├── backup.py # 导出/导入/备份/恢复 -│ ├── channels.py # 通道 CRUD -│ ├── logs.py # 日志查询/清理 -│ ├── messages.py # 消息查询/重发/批量/忽略 -│ ├── pages.py # HTML 页面渲染 -│ ├── parsers.py # 解析器 CRUD + 在线编辑 + 测试 -│ ├── sources.py # 数据源 CRUD + 绑定 + 样本 + 测试 -│ ├── system.py # 系统设置/健康检查/语言/队列状态 -│ └── templates.py # 模板 CRUD + 测试渲染 -│ -├── db/ # 数据库层 -│ ├── __init__.py # 公开接口(与旧 db.py 兼容) -│ ├── connection.py # 连接管理(WAL 模式) -│ ├── queries.py # SQL 查询(含 message_queue / dead_letter_queue) -│ └── schema.py # 表结构定义(含队列表 + DLQ 表 + parser_hash) -├── db.py # 兼容入口(代理到 db/ 包) -│ -├── channels/ # 6 种内置通道实现 -│ ├── wechat_work_bot.py -│ ├── wechat_work_api.py -│ ├── dingtalk.py -│ ├── feishu.py -│ ├── telegram_bot.py -│ └── bark.py -│ -├── parsers/ # 用户自定义解析器 -│ └── emby.py # Emby Webhook 解析器(示例) -│ -├── templates/ # HTML 前端模板(Jinja2 渲染) -├── tests/ # 自动化测试(85 个) -├── config/ # JSON 配置文件目录 -│ -├── config_manager.py # JSON ↔ SQLite 配置同步(文件锁 + Schema 校验) -├── version_checker.py # GitHub 版本更新检测 -├── i18n.py # 中英双语翻译 -├── log.py # 日志系统 -│ -├── gen_cert.py # SSL 自签名证书生成(支持 EGO_SSL_* 环境变量) -├── certs/ # SSL 证书目录 -│ -├── doc/ # 设计文档 -├── Dockerfile -├── docker-compose.yml -├── build.py # 构建脚本 -├── requirements.txt -├── version.json # 版本号 + 更新日志 -├── README.md / README.en.md -└── .github/workflows/ # GitHub Actions CI/CD -``` - -## 版本历史 - -### v1.2.0(当前) - -**Phase 0 安全加固** -- Jinja2 渲染改用 `SandboxedEnvironment`,防 SSTI(`__class__`/`import`/`attr` 全部拦截) -- HTTP Body 限制 5MB(超限 413),读取/整体超时(10s / 60s) -- Session 24h 过期,弱 Secret Key 启动告警,`/api/health` 免认证 - -**Phase 1 异步队列架构** -- 新增 `queue_backend.py` + `worker.py`:HTTP 立即返回 200,后台 Worker 消费 -- 按"消息 × 通道"粒度入队,单通道失败不影响其他通道 -- 3 次指数退避重试(5s / 30s / 2min),耗尽进入死信队列(DLQ) -- DLQ 支持 UI 手动重发或丢弃 -- SQLite WAL 模式,进程崩溃后自动恢复 processing 任务 -- 队列后端接口化,预留 Redis 升级路径 - -**Phase 2 健壮性** -- 配置 JSON 文件锁(`fcntl.flock`),防并发写损坏 -- 5 类配置 Schema 校验,缺字段记日志告警 -- DND 队列上限 10000 条,溢出 DISCARD 并告警 -- 消息详情页展示 Trace ID + sent_at -- Parser 版本绑定(MD5 哈希),重发时检测解析器变更 - -**Phase 3 可观测性(部分)** -- `/api/health` 深度健康检查(SQLite / 磁盘 / 配置 / 队列) -- SSL 证书路径支持环境变量(`EGO_SSL_DIR` / `EGO_SSL_CERT` / `EGO_SSL_KEY`) - -**测试** -- 测试用例从 51 个扩展到 85 个 -- 新增 `test_queue_backend.py`(12 用例)+ `test_config_manager.py`(18 用例) -- `test_renderer.py` 新增 SSTI 防护测试(4 用例) - -### v1.1.0 - -- 事件总线架构(blinker 信号系统) -- API 拆分为 11 个蓝图(web_ui.py 1217 行 → 25 行) -- 三大引擎包:parser_engine / router_engine / sender_engine -- WebUI 自签名 SSL 支持(方便浏览器访问) -- 中英双语 i18n 全量支持 -- 推送通道插件化(Channel SDK) -- 去重配置 + 多字段拼接去重键 -- 消息清理时间可配置 -- 版本更新检查(GitHub version.json) -- 多项安全修复(SQL 注入防护 / HMAC 对比 / parser 缓存) -- 部署架构文档化(4 层模型:裸机 / Docker / Nginx / Nginx+Certbot) diff --git a/.trash/improvement_plan.md b/.trash/improvement_plan.md deleted file mode 100644 index 7bfa768..0000000 --- a/.trash/improvement_plan.md +++ /dev/null @@ -1,92 +0,0 @@ -# EGo 改进计划 v5 - -> 基于代码审计 + 改进计划 v4 评估调整 -> 创建:2026-07-20 最后更新:2026-07-21 - ---- - -## 进度总览 - -| 阶段 | 项数 | 状态 | -|------|------|------| -| Phase 0 | 3 | ✅ 全部完成 | -| Phase 1 | 6 | ✅ 全部完成 | -| Phase 2 | 5 | ✅ 全部完成 | -| Phase 3 | 5 | 🟡 2/5(#15 #16 完成;#17 留下个大版本;#18 #19 待做)| -| T3/T4 专项 | 2 | ⬜ 按需推进 | - ---- - -## Phase 0:紧急修复 ✅ - -| # | 问题 | 修复 | 状态 | -|---|------|------|------| -| 1 | Jinja2 SSTI — `Template()` 裸调用可执行任意代码 | 改用 `SandboxedEnvironment` | ✅ | -| 2 | Body 无限制 — 超大请求可耗尽内存 | Content-Length > 5MB 返回 413 | ✅ | -| 3 | 认证加固 — Session 永不过期,弱 Secret Key 无告警 | 24h 过期 + 启动时检查默认 Key + health 路由免认证 | ✅ | - ---- - -## Phase 1:核心架构 ✅ - -| # | 问题 | 修复 | 状态 | -|---|------|------|------| -| 4 | 同步阻塞发送 | 后台队列 + Worker,HTTP 立即返回 200 | ✅ | -| 5 | 消息无持久化 | SQLite 队列表(与 #4 合并实现) | ✅ | -| 6 | 发送失败无重试 | 3 次重试,指数退避(5s/30s/2min) | ✅ | -| 7 | 重试耗尽无去处 | 死信队列表,用户可手动重发或丢弃 | ✅ | -| 8 | HTTP Server 无超时 | 读取 10s、整体 60s | ✅ | -| 9 | 事件总线无隔离 | 每个 listener 独立 try-except | ✅ | - ---- - -## Phase 2:健壮性 ✅ - -| # | 问题 | 修复 | 状态 | -|---|------|------|------| -| 10 | 配置双写竞态 | JSON 写入加文件锁(`fcntl.flock`) | ✅ | -| 11 | Trace ID 缺展示 | 已有字段,补日志输出 + 前端消息详情页展示 | ✅ | -| 12 | DND 队列无上限 | 加上限(10000 条)+ 溢出告警 | ✅ | -| 13 | 配置 JSON 无 Schema 校验 | 5 类配置加结构校验,格式错误拒绝加载 | ✅ | -| 14 | Parser 版本绑定 | PENDING 消息绑定生成时的解析器内容哈希 | ✅ | - ---- - -## Phase 3:可观测性 🟡 - -| # | 问题 | 修复 | 状态 | -|---|------|------|------| -| 15 | 健康检查太浅 | `/api/health` 补充 SQLite 连接、磁盘空间、配置文件检查 | ✅ | -| 16 | SSL 路径硬编码 | 加 `EGO_SSL_CERT`/`EGO_SSL_KEY` 环境变量 | ✅ | -| 17 | 统一 Webhook 入口 | 单端口 + `/webhook/` 路径路由 | ⏸ 留下个大版本 | -| 18 | Prometheus Metrics | 暴露 `/metrics` 端点 | ⬜ 待做 | -| 19 | 熔断机制 | 通道连续失败 N 次自动熔断,定期探测恢复 | ⬜ 待做 | - ---- - -## T3/T4 专项 ⬜ - -| # | 问题 | 修复 | -|---|------|------| -| 20 | API 限流 | 令牌桶 60 req/min per source(Nginx 也可做,二选一) | -| 21 | 通道限流 | 加令牌桶(条/分钟),防多通道并发被封号 | - -> T1/T2 内网场景无外部攻击面,限流优先级低。T3/T4 由 Nginx 统一处理更高效。 - ---- - -## 不纳入 - -| 项目 | 理由 | -|------|------| -| 去重内存泄漏 | 审计确认当前是 DB 查询(`check_dedup` SQL 时间窗口),非内存缓存,不存在泄漏 | -| 插件沙箱化 | 投入大收益低。Docker 部署已隔离,裸机场景 RCE 风险通过文档告知 | -| Source 鉴权 | 泛用转发平台不应限制上游发消息方式 | - ---- - -## 下一步 - -- **v1.3.0 候选**:#17 统一 Webhook 入口(路径路由,与多端口并存) -- **观测增强**:#18 Metrics + #19 熔断(T3/T4 部署需求驱动) -- **限流专项**:#20 #21 仅在公网暴露场景按需推进 diff --git a/.trash/sender.py b/.trash/sender.py deleted file mode 100644 index 3029894..0000000 --- a/.trash/sender.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: UTF-8 -*- -""" -模板渲染器 — 支持 simple({var} 替换)和 Jinja2 两种引擎。 -""" - -import log - -try: - from jinja2 import Template, Environment, BaseLoader - _HAS_JINJA2 = True -except ImportError: - _HAS_JINJA2 = False - - -def render_template(engine, title_tpl, content_tpl, msg): - """ - 渲染模板。 - - Args: - engine: "jinja2" 或 "simple" - title_tpl: 标题模板字符串 - content_tpl: 正文模板字符串 - msg: 解析器返回的 dict(title/content + 展平的顶层字段) - - Returns: - (title, content) 两个字符串 - """ - if engine == "jinja2": - return _render_jinja2(title_tpl, content_tpl, msg) - else: - return _render_simple(title_tpl, content_tpl, msg) - - -def _render_simple(title_tpl, content_tpl, msg): - """Simple 模式:{key} 替换,msg 所有顶层标量字段直接可用。""" - ns = {k: v for k, v in msg.items() if isinstance(v, (str, int, float, bool))} - - try: - title = title_tpl.format(**ns) if title_tpl else msg.get("title", "") - except (KeyError, ValueError): - title = title_tpl - try: - content = content_tpl.format(**ns) if content_tpl else "\n".join(f"- **{k}**: {v}" for k, v in ns.items() if k != "title") - except (KeyError, ValueError): - content = content_tpl - return title, content - - -def _render_jinja2(title_tpl, content_tpl, msg): - """Jinja2 模式:{{ msg.title }} 或 {{ title }}。""" - env = Environment(loader=BaseLoader()) - ns = {"msg": msg} - # 顶层标量字段也直接暴露 - for k, v in msg.items(): - if isinstance(v, (str, int, float, bool)): - ns[k] = v - - try: - title = env.from_string(title_tpl).render(**ns) if title_tpl else msg.get("title", "") - except Exception as e: - log.logger.error(f"Jinja2 title render error: {e}") - title = title_tpl - - try: - content = env.from_string(content_tpl).render(**ns) if content_tpl else "\n".join(f"- **{k}**: {v}" for k, v in ns.items() if k not in ("msg", "title")) - except Exception as e: - log.logger.error(f"Jinja2 content render error: {e}") - content = "\n".join(f"- **{k}**: {v}" for k, v in ns.items() if k not in ("msg", "title")) - - return title, content From b14dc798744df5e701555a3ae62052ddd7c122d1 Mon Sep 17 00:00:00 2001 From: codename-test Date: Tue, 28 Jul 2026 15:37:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20certs=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=88=B0?= =?UTF-8?q?=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ certs/ego.crt | 19 ------------------- certs/ego.key | 28 ---------------------------- 3 files changed, 3 insertions(+), 47 deletions(-) delete mode 100644 certs/ego.crt delete mode 100644 certs/ego.key diff --git a/.gitignore b/.gitignore index 42d3a4d..826c3a4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ doc/improvement.md *.db-shm *.db-wal +# 证书文件(运行时自动生成) +certs/ + diff --git a/certs/ego.crt b/certs/ego.crt deleted file mode 100644 index 11fff67..0000000 --- a/certs/ego.crt +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC/TCCAeWgAwIBAgIUVRTEJeTugRgToH5PloPnqYZqQFswDQYJKoZIhvcNAQEL -BQAwDjEMMAoGA1UEAwwDRUdvMB4XDTI2MDcxODAyMTEwMloXDTM2MDcxNTAyMTEw -MlowDjEMMAoGA1UEAwwDRUdvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC -AQEAmTe9cpuBGKeFkXsT8T6EXaiclCeC2oRE3MCUJjnSKswPFDAYColQ5/9WTCer -MmSkKcJyKeuM3dGxo9JQ+2TSVYqXZix4coFAn9mr7jrOGQ58W5XwIro13GZigv6q -FeGE7PklZQhi2yOAGIef3x7pna5Vx0weCkRWCw3eH8b5QpIC7JIITeNis/aTBJ21 -2lYNqGvuQc7hoqST9xjxeaRmm91V9HdKJFR6QDSFYleCkL8QH4pKF9ZrwcZLLXb2 -B3+OX6xwjWkNbBZnC4qX59yKeaBu4984kPBONFqGrANyJXM0i2kqI1S+soczfbv1 -wFt8d5hNGJ1F0nW0T8RR5aSa5QIDAQABo1MwUTAdBgNVHQ4EFgQUb7KmbXFQ/40E -uB6nfipT1HhuUZIwHwYDVR0jBBgwFoAUb7KmbXFQ/40EuB6nfipT1HhuUZIwDwYD -VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAD9FBN5orbAhqS3dcOxXO -bY2O+N/V6nwNVQ7MsVxlcc/6SeMcD2272SjyBB8ETQb2DC3x4B3GAE8rHFANSC8p -+MN27vf4hJpTTi/5RC0uZHRYncr7voW7/HBGZHYbbJ4ydhf2rh1K+8lKyCqQcVhX -Ko6O4woDXUqbHsoMdABa6i4GOQsG06eptwBDQzoXmkyzEQRNTdNfKeTjqNPs5zRY -gD8/SAN1B1ORm3xIsr4Nl16LnpFjKEaWd95moY4b524s+ofqkfVkLPlQWxtPCfdA -MeXTfnJgLMMPgkvM/Y6FNwKfI6b+VMFM3Ulet2bZWMM1xjVUmyzbiCKaEAjaqlnT -BQ== ------END CERTIFICATE----- diff --git a/certs/ego.key b/certs/ego.key deleted file mode 100644 index bbb83f2..0000000 --- a/certs/ego.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCZN71ym4EYp4WR -exPxPoRdqJyUJ4LahETcwJQmOdIqzA8UMBgKiVDn/1ZMJ6syZKQpwnIp64zd0bGj -0lD7ZNJVipdmLHhygUCf2avuOs4ZDnxblfAiujXcZmKC/qoV4YTs+SVlCGLbI4AY -h5/fHumdrlXHTB4KRFYLDd4fxvlCkgLskghN42Kz9pMEnbXaVg2oa+5BzuGipJP3 -GPF5pGab3VX0d0okVHpANIViV4KQvxAfikoX1mvBxkstdvYHf45frHCNaQ1sFmcL -ipfn3Ip5oG7j3ziQ8E40WoasA3IlczSLaSojVL6yhzN9u/XAW3x3mE0YnUXSdbRP -xFHlpJrlAgMBAAECggEARKaVg4cKmnXhIpS/lD3bSC3W7okfl/Jg+nptRw1sTlG9 -ReHgu8PYFQMy8w9pJTyz06Q0y/SnWvJsgkcnkuSTW9Gr0ZOGamPghV8eKrF9i/uS -G+U8QgsmO/lCFYiuw/wWt6lQqIrP5HltGVCXgP0UVtr4w0InSZlh5jUR/DIGmF0q -3e7G7xH5GGaqFH63+ZxKG/xkigUcamhyYzyIQv568BJa2BxuGevPwnnReLlLKlY7 -4wBFIJDSaw8mWNHKMWajPN4qvkuTQJgYE2Dw9JRKJc8W6EfM/YfNmFdg+REnXApL -fsSk/0F+pDnCDLBo5FOWVGu52/s0Sq9WqaWo1E1VNwKBgQDWI6KkHS2gjjoXYbM9 -n02pOwx4FtJy1BgJeD169NPwxSY7LKvjbLMpQxaPTelD9naYKH6ow9uW5I/zChmH -ogfcvTpUMI0WHSVviu1e3MBrK5oFoHf/vW2AkAROlyOM0xHgaTJ7b+O3+1Oevwld -IrqlXPqrFcRD0gTbcVvkHfFhKwKBgQC3K1sYRnHeq2Ksrrnn/zmU7W+jPIaAIjrh -lR6R4mijW/98kzSCESbbdXAex4tQ0nnT+ipEvWcdGkJyGcPnEcNX3F1bSibn2HKK -he15OsTiL7KHamj+ZS14Znyyl4NLMcTlkAUj2IB4mNnByXb5FTNItVIaGHWGM9zk -CWOmzPFMLwKBgE+ByahfgQpk5GK8Dr3pgP89gmKwLpEd+JLDhyg48Nwrhwdfja6S -NG80jpLEncBzKRNTrbthhQ2gbWYSsOnwGjU2OQr7P9cfGUAx9/JJylPAS56dNxDI -N3kNf9viN/vYfoGw7WJxZ75u3VxKl+Jxu5UcUaHmIgoAV3dWvoSTLYUjAoGBAK0o -dHaPaehptt+uUhsq4UGT8LytZC1PrsBA22RoWClOuEkJVFM9abF/5N2EIIToZLlr -Png7YXZWs8fv6Y3Q8g1mSm8Zu4vjx+yCTYwUbbmyA6EWjGkqNwEiSfH/X+Q/CXdH -XkST0Gexf/I9xZYVX6T1nMcEpl2XO9PruGQgTGCLAoGBAJPv5prYhrfZP4yJQf6I -Roo7bW4xTh9QORFooBZ0VNFm9PpShW/QL/5DzwVVGuKS0GOrozWMNCfjvhCW2pTq -O5PETu38X/8NKx3+dlibqw6/NOfiYMqglDl7fezdXg5RsMUMHTIxymDSyXvn8/TN -gkt6dVGQfGn9D/YvFyr9+L/G ------END PRIVATE KEY----- From f98404c79191533aeb44a44744e62b8035a4434b Mon Sep 17 00:00:00 2001 From: codename-test Date: Tue, 28 Jul 2026 23:51:44 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20nginx=20=E7=AB=AF=E5=8F=A3=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E6=94=B9=E4=B8=BA=20800/4430=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20GitHub=20=E9=93=BE=E6=8E=A5=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20.trash=20=E5=92=8C=20certs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- README.en.md | 4 +- README.md | 4 +- api/channels.py | 7 +- api/pages.py | 2 +- api/parsers.py | 2 +- build.py | 11 +- deploy/README.en.md | 89 +++++---- deploy/README.md | 73 +++++--- deploy/init.sh | 271 ++++++++++++++------------- deploy/t3-nginx/docker-compose.yml | 21 ++- deploy/t3-nginx/nginx.conf | 12 +- deploy/t4-acme/docker-compose.yml | 80 ++++++++ deploy/t4-acme/nginx.conf | 34 ++++ deploy/t4-certbot/docker-compose.yml | 80 -------- deploy/t4-certbot/nginx.conf | 42 ----- doc/architecture.md | 4 +- doc/changelog.md | 21 +++ doc/roadmap.md | 65 ++++--- main.py | 2 +- version.json | 17 +- 21 files changed, 461 insertions(+), 382 deletions(-) create mode 100644 deploy/t4-acme/docker-compose.yml create mode 100644 deploy/t4-acme/nginx.conf delete mode 100644 deploy/t4-certbot/docker-compose.yml delete mode 100644 deploy/t4-certbot/nginx.conf diff --git a/Dockerfile b/Dockerfile index 12ccad0..b8ceca2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# EGo — 通用信息转发平台 v1.2.3 +# EGo — 通用信息转发平台 v1.2.4 FROM python:3.11-alpine3.18 LABEL maintainer="EGo Team" diff --git a/README.en.md b/README.en.md index 787d0fe..6c4f775 100644 --- a/README.en.md +++ b/README.en.md @@ -1,4 +1,4 @@ -# EverywhereYouGo (EGo) v1.2.3 +# EverywhereYouGo (EGo) v1.2.4 [中文](README.md) | English @@ -17,7 +17,7 @@ chmod +x init.sh # Follow prompts to select deployment mode ``` -Supports 5 deployment modes: default (quick start), t1-host (host network), t2-bridge (bridge network), t3-nginx (Nginx + manual certificate), t4-certbot (Nginx + Let's Encrypt auto certificate). +Supports 5 deployment modes: default (quick start), t1-host (host network), t2-bridge (bridge network), t3-nginx (Nginx + manual certificate), t4-acme (Nginx + Let's Encrypt auto certificate). > More deployment options in [deploy/README.en.md](deploy/README.en.md). diff --git a/README.md b/README.md index 1d9d45d..fdac8c3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# EverywhereYouGo (EGo) v1.2.3 +# EverywhereYouGo (EGo) v1.2.4 [English](README.en.md) | 中文 @@ -17,7 +17,7 @@ chmod +x init.sh # 按提示选择部署模式 ``` -支持 5 种部署模式:default(快速起步)、t1-host(host 网络)、t2-bridge(bridge 网络)、t3-nginx(Nginx + 手动证书)、t4-certbot(Nginx + Let's Encrypt 全自动证书)。 +支持 5 种部署模式:default(快速起步)、t1-host(host 网络)、t2-bridge(bridge 网络)、t3-nginx(Nginx + 手动证书)、t4-acme(Nginx + Let's Encrypt 全自动证书)。 > 更多部署形态说明见 [deploy/README.md](deploy/README.md)。 diff --git a/api/channels.py b/api/channels.py index 4bb984e..c858544 100644 --- a/api/channels.py +++ b/api/channels.py @@ -62,7 +62,7 @@ def api_create_channel_plugin(): f = request.files["file"] if not f.filename.endswith(".py"): return jsonify({"error": i18n._("err.py_only")}), 400 - filename = f.filename + filename = os.path.basename(f.filename) filepath = os.path.join(CHANNELS_DIR, filename) if os.path.isfile(filepath): return jsonify({"error": i18n._("err.parser_exists")}), 400 @@ -77,6 +77,7 @@ def api_create_channel_plugin(): @channels_bp.route("/api/channel_plugins/", methods=["GET"]) def api_get_channel_plugin_content(filename): + filename = os.path.basename(filename) filepath = os.path.join(CHANNELS_DIR, filename) if not os.path.isfile(filepath): return jsonify({"error": i18n._("err.file_not_found")}), 404 @@ -86,6 +87,7 @@ def api_get_channel_plugin_content(filename): @channels_bp.route("/api/channel_plugins/", methods=["PUT"]) def api_update_channel_plugin_content(filename): + filename = os.path.basename(filename) data = request.json if "content" not in data: return jsonify({"error": i18n._("err.missing_content")}), 400 @@ -101,6 +103,7 @@ def api_update_channel_plugin_content(filename): @channels_bp.route("/api/channel_plugins/", methods=["DELETE"]) def api_delete_channel_plugin(filename): + filename = os.path.basename(filename) filepath = os.path.join(CHANNELS_DIR, filename) if os.path.isfile(filepath): os.remove(filepath) @@ -112,6 +115,7 @@ def api_delete_channel_plugin(filename): @channels_bp.route("/api/channel_plugins//test", methods=["POST"]) def api_test_channel_plugin(filename): + filename = os.path.basename(filename) config = request.json or {} result = channel_loader.test_channel(filename, config) return jsonify(result) @@ -119,6 +123,7 @@ def api_test_channel_plugin(filename): @channels_bp.route("/api/channel_plugins//fields", methods=["GET"]) def api_channel_plugin_fields(filename): + filename = os.path.basename(filename) try: mod = channel_loader.load_plugin(filename) cls = mod.Channel diff --git a/api/pages.py b/api/pages.py index 33e245b..c4be9bf 100644 --- a/api/pages.py +++ b/api/pages.py @@ -10,7 +10,7 @@ pages_bp = Blueprint("pages", __name__) PARSERS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "parsers") -VERSION = "1.2.3" +VERSION = "1.2.4" def _render(page, title, active_page="", **kwargs): diff --git a/api/parsers.py b/api/parsers.py index a9a86b4..4de2227 100644 --- a/api/parsers.py +++ b/api/parsers.py @@ -33,7 +33,7 @@ def api_create_parser(): f = request.files["file"] if not f.filename.endswith(".py"): return jsonify({"error": i18n._("err.py_only")}), 400 - filename = f.filename + filename = os.path.basename(f.filename) filepath = os.path.join(PARSERS_DIR, filename) f.save(filepath) pid = db.create_parser(name, filename, desc) diff --git a/build.py b/build.py index 0aa98cb..4d09d8a 100644 --- a/build.py +++ b/build.py @@ -5,7 +5,7 @@ 用法: python3 build.py # 构建镜像 python3 build.py --push # 构建并推送 - python3 build.py --tag v1.2.1 # 指定版本标签 + python3 build.py --tag v1.2.4 # 指定版本标签 """ import os @@ -13,8 +13,8 @@ import subprocess import argparse -VERSION = "1.2.1" -IMAGE_NAME = "ego" +VERSION = "1.2.4" +IMAGE_NAME = "codenametest/everywhereyougo" DOCKERFILE = "Dockerfile" @@ -56,5 +56,6 @@ def run(cmd: str, cwd: str | None = None): run(f"docker push {t}") print("✅ Pushed all tags") - print("\n📦 使用方式:") - print(f" docker run -d --name ego -p 5000:5000 -v ./ego_data:/app/data {tags[0]}") + print("\n📦 使用方式(推荐直接用 deploy/ 下的 compose 配置):") + print(f" docker run -d --name ego -p 5000:5000 -p 5001:5001 " + f"-v ego_data:/app/data -v ego_config:/app/config -v ego_certs:/app/certs {tags[0]}") diff --git a/deploy/README.en.md b/deploy/README.en.md index de344af..df49138 100644 --- a/deploy/README.en.md +++ b/deploy/README.en.md @@ -10,26 +10,33 @@ All Docker Compose configs are in this directory. `default/` is the recommended |--------|-----------|---------|----------|-------------|----------| | Default | `default/` | `bridge` | HTTPS self-signed | Auto-generated | Quick start for beginners | | T1 | `t1-host/` | `host` | HTTPS self-signed | Auto-generated | Home / LAN debugging, direct host ports | -| T2 | `t2-bridge/` | `bridge` | HTTPS self-signed | Auto-generated | Multi-container LAN, service name access | -| T3 | `t3-nginx/` | `bridge` + Nginx | HTTPS trusted cert | Manual | Production with existing cert | -| T4 | `t4-certbot/` | `bridge` + Nginx + acme.sh | HTTPS trusted cert | Let's Encrypt auto | Public domain, fully automated | +| T2 | `t2-bridge/` | `bridge` | HTTPS self-signed | Auto-generated | Multi-container LAN, service-name access | +| T3 | `t3-nginx/` | `bridge` + Nginx | HTTPS trusted cert | Manual | Production with your own cert | +| T4 | `t4-acme/` | `bridge` + Nginx + acme.sh | HTTPS trusted cert | Let's Encrypt auto (Cloudflare DNS) | Public domain, fully automated | ## Why HTTPS for the Admin UI -The browser clipboard API (`navigator.clipboard`) used by various "copy" buttons only works in a **secure context** (HTTPS or localhost). Even for LAN debugging (T1/T2/Default), the admin UI uses built-in self-signed HTTPS — the browser will warn once, just accept it. Self-signed certs are persisted via the `ego_certs` volume, so you won't need to accept again after container rebuild. +The browser clipboard API (`navigator.clipboard`) used by various "copy" buttons only works in a **secure context** (HTTPS or localhost). Even for LAN debugging (T1/T2/Default), the admin UI uses built-in self-signed HTTPS — the browser warns once, just accept it. Self-signed certs are persisted via the `ego_certs` volume, so you won't need to accept again after a container rebuild. Webhook receivers (`/in/...`) and health checks (`/api/health`) are machine-to-machine traffic and always go over HTTP (`WEB_PORT`). +## Certificate Rule (uniform across all tiers) + +- **EGo 5001 admin**: always uses Flask's auto-generated **self-signed certificate** (persisted in the `ego_certs` volume). T1/T2/T3/T4 all follow this rule; browsing directly to `https://:5001` requires accepting the warning once. +- **Nginx (T3/T4 only)**: terminates TLS on 80/443 with a **real certificate**, then proxies to EGo's plaintext HTTP 5000. T3 uses a certificate you import manually; T4 uses a Let's Encrypt certificate that acme.sh issues/renews automatically. +- The two certificates are **independent**: EGo's self-signed cert lives in the `ego_certs` volume; nginx's cert lives in this directory's `certs/`. Public access goes through `https:///` (nginx, trusted cert); on the LAN you can also hit `https://:5001` directly (self-signed). + ## How to Choose -- **LAN/internal use**, need clipboard buttons to work → **Default** or **T1** (host network, most efficient) / **T2** (bridge, multi-container). All use self-signed HTTPS. -- **Public-facing**, need a green padlock → **T3** (existing cert) or **T4** (domain, Certbot auto). +- **LAN/internal use**, need clipboard buttons to work → **Default** or **T1** (host network) / **T2** (bridge, multi-container). All use self-signed HTTPS. +- **Public-facing**, need a green padlock → **T3** (existing cert) or **T4** (domain + Cloudflare, acme.sh DNS auto). ## Ports & Switches -- `WEB_PORT` (default 5000): HTTP — Webhook receivers (`/in/...`) and `/api/health`. -- `WEB_SSL_PORT` (default 5001): HTTPS — Admin UI (listens only when SSL is enabled). -- `EGO_SSL_ENABLED` (default 1): Set to `0` to **fully disable** built-in HTTPS — skips cert generation, HTTP only, no redirect, session cookies without `Secure`. Only use this if you want pure HTTP or TLS is terminated by a reverse proxy and you don't want double encryption. +- `WEB_PORT` (default 5000): HTTP — webhook receivers (`/in/...`) and `/api/health`. +- `WEB_SSL_PORT` (default 5001): HTTPS — admin UI (listens only when SSL is enabled). +- `EGO_SSL_ENABLED` (default 1): set to `0` to **fully disable** built-in HTTPS — skips cert generation, HTTP only, no redirect, session cookies without `Secure`. Only use this if you want pure HTTP or TLS is terminated upstream and you don't want double encryption. +- `HTTP_PORT` / `HTTPS_PORT` (T3/T4 only, default 80/443): host ports for nginx. Override via `.env` (the one-click script guides you and detects conflicts). ## Usage @@ -40,20 +47,20 @@ cd deploy/default # or t1-host / t2-bridge docker compose up -d ``` -- Admin UI: `https://:5001` (accept self-signed cert warning) +- Admin UI: `https://:5001` (accept the self-signed warning) - Webhook / Health: `http://:5000` -> T1 uses host network: if ports 5000/5001 are already taken (e.g., iStoreOS router's `miniupnpd` occupies 5000), change `WEB_PORT`/`WEB_SSL_PORT` in compose to free ports (e.g., 5080/5081). +> T1 uses host network: if ports 5000/5001 are taken (e.g., iStoreOS router's `miniupnpd` occupies 5000), change `WEB_PORT`/`WEB_SSL_PORT` in the compose to free ports (e.g., 5080/5081). ### T3 (Nginx + Manual Cert) -**One-click deployment:** +**One-click (bilingual script, detects port conflicts and lets you change them):** ```bash curl -O https://raw.githubusercontent.com/codename-test/EverywhereYouGo/main/deploy/init.sh chmod +x init.sh ./init.sh -# Select option 4, follow prompts for domain and certificate paths +# Choose language → option 4 → enter domain and cert paths; default HTTP=80/HTTPS=443, changeable on the fly ``` **Manual deployment (for customization):** @@ -63,48 +70,56 @@ cd deploy/t3-nginx mkdir -p certs cp /path/to/your/ego.crt certs/ego.crt cp /path/to/your/ego.key certs/ego.key +# Optional: custom nginx ports (default 80/443) +# echo "HTTP_PORT=8080" >> .env; echo "HTTPS_PORT=4430" >> .env docker compose up -d ``` -Access `https://:4430/`. Nginx terminates TLS on 4430 (your cert), proxies all traffic to EGo's HTTP 5000. Path routing is handled internally by EGo — change `path_prefix` in the UI, nginx needs no changes. Port 800 automatically redirects to 4430. +Access `https:///`. Nginx terminates TLS on 443 with your imported cert and proxies all traffic to EGo's HTTP 5000. Path routing is handled internally by EGo — change `path_prefix` in the UI, nginx needs no changes. HTTP 80 redirects to HTTPS. EGo 5001 stays self-signed (independent); on the LAN you can hit `https://:5001` directly. + +> If you use a custom HTTPS port (e.g., 4430) manually, also change the redirect in `nginx.conf` to `https://$host:4430$request_uri`; the one-click script handles this automatically. -### T4 (Nginx + acme.sh Auto) +### T4 (Nginx + acme.sh Auto, Cloudflare DNS) -**Certificate separation design:** -- nginx 4430: uses `certs/ego.crt` + `ego.key` (Let's Encrypt certificate) -- EGo 5001: uses `certs/ego-selfsigned.crt` + `ego-selfsigned.key` (self-signed certificate) +**Certificate separation:** EGo 5001 uses Flask's self-signed cert (`ego_certs` volume, independent); nginx 443 uses the Let's Encrypt cert that acme.sh writes to `certs/`. nginx terminates TLS and proxies to EGo's HTTP 5000. The two certs don't interfere. -**One-click deployment:** +**Validation: Cloudflare DNS API** (no port 80 required — ideal when port 80 is taken, there's no public port 80, or behind NAT). Requirements: +- Domain hosted on Cloudflare; +- A Cloudflare API Token with `Zone:DNS:Edit` permission. + +**One-click (bilingual script, detects port conflicts and lets you change them):** ```bash curl -O https://raw.githubusercontent.com/codename-test/EverywhereYouGo/main/deploy/init.sh chmod +x init.sh ./init.sh -# Select option 5, follow prompts for domain, email, and verification method +# Choose language → option 5 → enter domain and Cloudflare API Token; default HTTP=80/HTTPS=443, changeable on the fly ``` -Supports two verification methods: -- **webroot mode** (default): Requires port 800, domain must resolve to this server's public IP -- **Cloudflare DNS API mode**: No port 800 required, needs Cloudflare API Token - **Manual deployment (for customization):** ```bash -cd deploy/t4-certbot +cd deploy/t4-acme mkdir -p certs -echo "your.domain" > certs/.domain - -# Method 1: webroot mode (requires port 800) +echo "your.domain" > certs/.domain # change to your domain +sed -i 's/your.domain/your.domain/g' nginx.conf +# Put CF_Token in .env (so renewal still works after container/host restarts) +echo "CF_Token=your_Cloudflare_Token" > .env +# Optional: custom nginx ports (default 80/443) +# echo "HTTP_PORT=8080" >> .env; echo "HTTPS_PORT=4430" >> .env docker compose up -d -docker compose exec acme acme.sh --issue --webroot /var/www/acme -d your.domain --server letsencrypt +``` -# Method 2: Cloudflare DNS API mode (no port 800 required) -CF_Token=your_token docker compose up -d -docker compose exec acme acme.sh --issue --dns dns_cf -d your.domain --server letsencrypt +On startup the `acme` container issues the certificate (including wildcard `*.your.domain`) via Cloudflare DNS validation and writes it to `certs/`; nginx detects the change and reloads automatically. It then auto-renews every 12 hours — fully automated, no manual intervention. On first boot nginx restarts a few times until the certificate is issued; this is normal. -# Copy certificates to correct location (Let's Encrypt cert for nginx) -docker compose exec acme cp /acme.sh/your.domain/fullchain.cer /certs/ego.crt -docker compose exec acme cp /acme.sh/your.domain/your.domain.key /certs/ego.key -``` +> If you use a custom HTTPS port (e.g., 4430) manually, also change the redirect in `nginx.conf` to `https://$host:4430$request_uri`; the one-click script handles this automatically. + +#### T4 Certificate Renewal Operations -The `acme.sh` container auto-renews every 12 hours. After successful renewal, certificates are automatically copied to `certs/` directory. The nginx container polls for certificate changes every 60 seconds and automatically reloads when updates are detected — fully automated, no manual intervention required. +- **Automatic renewal**: the `acme` container checks every 12 hours; as the certificate nears expiry (Let's Encrypt certs are valid 90 days) it renews via Cloudflare DNS, writes to `certs/`, and nginx reloads within 60 seconds. No manual intervention needed. +- **View renewal logs**: `docker compose logs -f acme` +- **Check current certificate validity**: `openssl x509 -in certs/ego.crt -noout -dates` +- **Force a manual renewal**: `docker compose exec acme acme.sh --renew -d your.domain --force` +- **Change domain**: edit `certs/.domain` and the domain in `nginx.conf`, then `docker compose restart acme nginx`. +- **Change Cloudflare Token**: edit `CF_Token` in `.env`, then `docker compose up -d` (recreates the acme container). +- **Troubleshoot failed renewal**: start with `docker compose logs acme`; common causes are an expired/under-permissioned CF_Token, or the domain not being hosted on Cloudflare. diff --git a/deploy/README.md b/deploy/README.md index eefd54d..822091a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -12,7 +12,7 @@ | T1 | `t1-host/` | `host` | HTTPS 自签名 | 自动生成 | 家庭 / 内网调试,直接用主机端口 | | T2 | `t2-bridge/` | `bridge` | HTTPS 自签名 | 自动生成 | 内网多容器协同,容器间用服务名互访 | | T3 | `t3-nginx/` | `bridge` + Nginx | HTTPS 可信证书 | 手动 | 正式生产,自有证书 | -| T4 | `t4-certbot/` | `bridge` + Nginx + acme.sh | HTTPS 可信证书 | Let's Encrypt 自动 | 有公网域名,全自动免费证书 | +| T4 | `t4-acme/` | `bridge` + Nginx + acme.sh | HTTPS 可信证书 | Let's Encrypt 自动(Cloudflare DNS) | 有公网域名,全自动免费证书 | ## 为什么管理页面要 HTTPS @@ -20,10 +20,16 @@ Webhook 接收(`/in/...`)与健康检查(`/api/health`)是机器间流量,始终走 HTTP(`WEB_PORT`)。 +## 证书规则(所有层级统一) + +- **EGo 5001 管理页面**:始终使用 Flask 自动生成的**自签名证书**(持久化在 `ego_certs` 卷)。T1/T2/T3/T4 都遵循这一规则;浏览器直连 `https://:5001` 需放行一次证书警告。 +- **Nginx(仅 T3/T4)**:在 80/443 用**真实证书**终结 TLS,再反代到 EGo 的明文 HTTP 5000。T3 用你手动导入的证书,T4 用 acme.sh 自动签发/续期的 Let's Encrypt 证书。 +- 这两张证书**相互独立**:EGo 的自签证书在 `ego_certs` 卷,nginx 的证书在本目录 `certs/`。对外访问走 `https://<域名>/`(nginx,可信证书);内网也可直连 `https://:5001`(自签)。 + ## 怎么选 - 内网自己用、要复制按钮能用 → **默认** 或 **T1**(host 网络,端口最省)/**T2**(bridge,需与其它容器互访)。三者都用自签名 HTTPS。 -- 要对外、要绿色小锁 → **T3**(已有证书)或 **T4**(有域名,Certbot 自动签)。 +- 要对外、要绿色小锁 → **T3**(已有证书)或 **T4**(有域名 + Cloudflare,acme.sh DNS 自动签)。 ## 端口与开关 @@ -47,13 +53,13 @@ docker compose up -d ### T3(Nginx + 手动证书) -**一键部署:** +**一键部署(脚本支持中/英双语,会检测端口占用并允许修改):** ```bash curl -O https://raw.githubusercontent.com/codename-test/EverywhereYouGo/main/deploy/init.sh chmod +x init.sh ./init.sh -# 选择选项 4,按提示输入域名和证书路径 +# 选择语言 → 选项 4 → 按提示输入域名、证书路径;默认 HTTP=80/HTTPS=443,可现场改 ``` **手动部署(如需自定义):** @@ -63,49 +69,58 @@ cd deploy/t3-nginx mkdir -p certs cp /path/to/your/ego.crt certs/ego.crt cp /path/to/your/ego.key certs/ego.key +# 可选:自定义 nginx 端口(默认 80/443) +# echo "HTTP_PORT=8080" >> .env; echo "HTTPS_PORT=4430" >> .env docker compose up -d ``` -访问 `https://<域名或IP>:4430/`。Nginx 在 4430 终结 TLS(你的证书),全流量透传 EGo 的 HTTP 5000; -路径路由由 EGo 内部处理(用户在前端改 path_prefix,nginx 无需改动)。800 自动跳 4430。 +访问 `https://<域名或IP>/`。Nginx 在 443 用你导入的证书终结 TLS,全流量透传 EGo 的 HTTP 5000; +路径路由由 EGo 内部处理(用户在前端改 path_prefix,nginx 无需改动)。HTTP 80 自动跳 HTTPS。 +EGo 5001 仍是自签名证书(独立),可内网直连 `https://:5001`。 + +> 自定义 HTTPS 端口(如 4430)时,手动部署需同时把 `nginx.conf` 里的跳转改成 `https://$host:4430$request_uri`;一键脚本会自动处理。 -### T4(Nginx + acme.sh 自动证书) +### T4(Nginx + acme.sh 自动证书,Cloudflare DNS) -**证书分离设计:** -- nginx 4430: 使用 `certs/ego.crt` + `ego.key`(Let's Encrypt 证书) -- EGo 5001: 使用 `certs/ego-selfsigned.crt` + `ego-selfsigned.key`(自签名证书) +**证书分离设计:** EGo 5001 用 Flask 自签名证书(`ego_certs` 卷,独立);nginx 443 用 acme.sh 写入 `certs/` 的 Let's Encrypt 证书。nginx 终结 TLS 后反代到 EGo 的 HTTP 5000。两张证书互不影响。 -**一键部署:** +**验证方式:Cloudflare DNS API**(不需要 80 端口,适合 80 被占用 / 无公网 80 / 内网穿透)。前提: +- 域名托管在 Cloudflare; +- 一个 Cloudflare API Token(`Zone:DNS:Edit` 权限)。 + +**一键部署(脚本支持中/英双语,会检测端口占用并允许修改):** ```bash curl -O https://raw.githubusercontent.com/codename-test/EverywhereYouGo/main/deploy/init.sh chmod +x init.sh ./init.sh -# 选择选项 5,按提示输入域名、邮箱和验证方式 +# 选择语言 → 选项 5 → 按提示输入域名、Cloudflare API Token;默认 HTTP=80/HTTPS=443,可现场改 ``` -支持两种验证方式: -- **webroot 模式**(默认):需要 800 端口,域名需解析到本机公网 IP -- **Cloudflare DNS API 模式**:不需要 800 端口,需要 Cloudflare API Token - **手动部署(如需自定义):** ```bash -cd deploy/t4-certbot +cd deploy/t4-acme mkdir -p certs -echo "your.domain" > certs/.domain - -# 方式一:webroot 模式(需要 800 端口) +echo "your.domain" > certs/.domain # 改成你的域名 +sed -i 's/your.domain/你的域名/g' nginx.conf +# 把 CF_Token 写进 .env(容器重启 / 重启机器后续期仍可用) +echo "CF_Token=你的Cloudflare_Token" > .env +# 可选:自定义 nginx 端口(默认 80/443) +# echo "HTTP_PORT=8080" >> .env; echo "HTTPS_PORT=4430" >> .env docker compose up -d -docker compose exec acme acme.sh --issue --webroot /var/www/acme -d your.domain --server letsencrypt +``` -# 方式二:Cloudflare DNS API 模式(不需要 800 端口) -CF_Token=your_token docker compose up -d -docker compose exec acme acme.sh --issue --dns dns_cf -d your.domain --server letsencrypt +启动后 `acme` 容器通过 Cloudflare DNS 验证签发证书(含泛域名 `*.你的域名`)写入 `certs/`,nginx 检测到证书变化自动 reload;之后每 12 小时自动续期,全程无需人工干预。首次启动 nginx 会重启几次,直到 acme 签出证书,属正常现象。 -# 复制证书到正确位置(Let's Encrypt 证书给 nginx) -docker compose exec acme cp /acme.sh/your.domain/fullchain.cer /certs/ego.crt -docker compose exec acme cp /acme.sh/your.domain/your.domain.key /certs/ego.key -``` +> 自定义 HTTPS 端口(如 4430)时,手动部署需同时把 `nginx.conf` 里的跳转改成 `https://$host:4430$request_uri`;一键脚本会自动处理。 + +#### T4 证书续签运维 -之后 `acme.sh` 容器每 12 小时自动尝试续期。续期成功后会自动复制证书到 `certs/` 目录,nginx 容器每 60 秒轮询检测证书变化,发现更新后自动 reload,全程无需人工干预。 +- **自动续期**:`acme` 容器每 12 小时检查一次,证书临近到期(Let's Encrypt 有效期 90 天)时自动经 Cloudflare DNS 续期,写入 `certs/` 后 nginx 60 秒内自动 reload。无需人工干预。 +- **查看续期日志**:`docker compose logs -f acme` +- **查看当前证书有效期**:`openssl x509 -in certs/ego.crt -noout -dates` +- **手动强制续期**:`docker compose exec acme acme.sh --renew -d 你的域名 --force` +- **更换域名**:修改 `certs/.domain` 与 `nginx.conf` 里的域名,然后 `docker compose restart acme nginx`。 +- **更换 Cloudflare Token**:修改 `.env` 里的 `CF_Token`,然后 `docker compose up -d`(会重建 acme 容器)。 +- **排查续期失败**:先看 `docker compose logs acme`;常见原因是 CF_Token 失效/权限不足,或域名未托管在 Cloudflare。 diff --git a/deploy/init.sh b/deploy/init.sh index 5f726a8..543b887 100644 --- a/deploy/init.sh +++ b/deploy/init.sh @@ -1,174 +1,183 @@ #!/bin/bash -# EGo 一键部署脚本 -# 用法: ./init.sh -# 交互式选择部署模式并自动配置 +# EGo 一键部署脚本 / EGo One-click Deployment Script +# 用法 / Usage: ./init.sh +# +# 交互式选择部署模式并自动配置,最终在当前目录生成 ego-deploy/, +# 其中 docker-compose.yml / nginx.conf / .env / certs/ 均按你的选择写入。 set -e BASE_URL="https://raw.githubusercontent.com/codename-test/EverywhereYouGo/main/deploy" -echo "=== EGo 一键部署 ===" +# ── 语言选择 / Language selection ────────────────────────── +echo "请选择语言 / Select language:" +echo " 1) 中文" +echo " 2) English" +printf "选择 / Choice [1-2, 默认/default 1]: " +read LANG_CHOICE +LANG_CHOICE=$(echo "$LANG_CHOICE" | tr -d '[:space:]') +if [ "$LANG_CHOICE" = "2" ]; then L=en; else L=zh; fi + +# 双语输出:m "中文" "English"(带换行);p "中文" "English"(不带换行,用于输入提示) +m() { if [ "$L" = "en" ]; then printf '%s\n' "$2"; else printf '%s\n' "$1"; fi; } +p() { if [ "$L" = "en" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi; } + +# 检测端口占用(占用返回 0,空闲返回 1),并打印占用进程 +port_busy() { + netstat -tlnp 2>/dev/null | grep -E ":$1 " | head -1 || true +} + +echo "" +m "=== EGo 一键部署 ===" "=== EGo One-click Deployment ===" echo "" -echo "请选择部署模式:" -echo " 1) default — 快速起步(自签名 HTTPS,适合内网测试)" -echo " 2) t1-host — host 网络(直接占用主机端口,适合家庭/内网)" -echo " 3) t2-bridge — bridge 网络(容器间互访,适合多容器协同)" -echo " 4) t3-nginx — Nginx + 手动证书(生产环境,自有证书)" -echo " 5) t4-certbot — Nginx + Let's Encrypt(全自动证书,需公网域名)" +m "请选择部署模式:" "Select deployment mode:" +m " 1) default — 快速起步(自签名 HTTPS,适合内网测试)" " 1) default — Quick start (self-signed HTTPS, LAN testing)" +m " 2) t1-host — host 网络(直接占用主机端口,适合家庭/内网)" " 2) t1-host — host network (direct host ports, home/LAN)" +m " 3) t2-bridge — bridge 网络(容器间互访,适合多容器协同)" " 3) t2-bridge — bridge network (multi-container LAN)" +m " 4) t3-nginx — Nginx + 手动证书(生产环境,自有证书)" " 4) t3-nginx — Nginx + manual cert (production, own cert)" +m " 5) t4-acme — Nginx + Let's Encrypt(Cloudflare DNS 全自动)" " 5) t4-acme — Nginx + Let's Encrypt (Cloudflare DNS, fully auto)" echo "" -printf "请输入选项 [1-5]: " +p "请输入选项 [1-5]: " "Enter choice [1-5]: " read MODE MODE=$(echo "$MODE" | tr -d '[:space:]') +DEPLOY_DIR="" +DOMAIN="" +CERT_PATH="" +KEY_PATH="" +CF_Token="" +HTTP_PORT="" +HTTPS_PORT="" + case "$MODE" in - 1) - DEPLOY_DIR="default" - ;; - 2) - DEPLOY_DIR="t1-host" - ;; - 3) - DEPLOY_DIR="t2-bridge" - ;; - 4) - DEPLOY_DIR="t3-nginx" - printf "请输入域名: " - read DOMAIN - printf "请输入证书路径: " - read CERT_PATH - printf "请输入私钥路径: " - read KEY_PATH - ;; - 5) - DEPLOY_DIR="t4-certbot" - printf "请输入域名: " - read DOMAIN - printf "请输入邮箱: " - read EMAIL - echo "" - echo "证书验证方式:" - echo " 1) webroot 模式(默认,需要 800 端口,域名需解析到本机公网 IP)" - echo " 2) Cloudflare DNS API 模式(不需要 800 端口,需要 CF_Token)" - printf "请选择 [1-2,默认 1]: " - read CERT_MODE - CERT_MODE=$(echo "$CERT_MODE" | tr -d '[:space:]') - if [ "$CERT_MODE" = "2" ]; then - printf "请输入 Cloudflare API Token: " - read CF_TOKEN - export CF_Token="$CF_TOKEN" - fi - ;; - *) - echo "无效选项" - exit 1 - ;; + 1) DEPLOY_DIR="default" ;; + 2) DEPLOY_DIR="t1-host" ;; + 3) DEPLOY_DIR="t2-bridge" ;; + 4) DEPLOY_DIR="t3-nginx" + p "请输入域名: " "Enter domain: "; read DOMAIN + p "请输入证书路径: " "Enter certificate path: "; read CERT_PATH + p "请输入私钥路径: " "Enter private key path: "; read KEY_PATH + ;; + 5) DEPLOY_DIR="t4-acme" + p "请输入域名: " "Enter domain: "; read DOMAIN + p "请输入 Cloudflare API Token(需 Zone:DNS:Edit 权限): " "Enter Cloudflare API Token (Zone:DNS:Edit permission): "; read CF_TOKEN + CF_Token="$CF_TOKEN" + ;; + *) m "无效选项" "Invalid choice"; exit 1 ;; esac +# ── T3/T4:Nginx 端口确认(默认 80/443,含占用检测)────────── +if [ "$DEPLOY_DIR" = "t3-nginx" ] || [ "$DEPLOY_DIR" = "t4-acme" ]; then + HTTP_PORT=80 + HTTPS_PORT=443 + echo "" + m "Nginx 默认使用 HTTP=80 / HTTPS=443。" "Nginx defaults to HTTP=80 / HTTPS=443." + # 占用检测告警 + for prt in 80 443; do + OCC=$(port_busy "$prt") + if [ -n "$OCC" ]; then + m " ⚠ 端口 $prt 已被占用:$OCC" " ⚠ Port $prt is already in use: $OCC" + fi + done + p "是否修改这两个端口?[y/N]: " "Change these ports? [y/N]: " + read CHG + CHG=$(echo "$CHG" | tr -d '[:space:]') + if [ "$CHG" = "y" ] || [ "$CHG" = "Y" ]; then + p " HTTP 端口 [回车保持 80]: " " HTTP port [Enter to keep 80]: "; read HP; [ -n "$HP" ] && HTTP_PORT="$HP" + p " HTTPS 端口 [回车保持 443]: " " HTTPS port [Enter to keep 443]: "; read HSP; [ -n "$HSP" ] && HTTPS_PORT="$HSP" + fi + # 最终端口再次检测占用 + for prt in "$HTTP_PORT" "$HTTPS_PORT"; do + if [ -n "$(port_busy "$prt")" ]; then + m " ⚠ 注意:端口 $prt 当前被占用,启动可能失败,可稍后手动释放。" \ + " ⚠ Warning: port $prt is currently in use; startup may fail. Free it later if needed." + fi + done +fi + echo "" -echo "部署模式: $DEPLOY_DIR" +m "部署模式: $DEPLOY_DIR" "Deployment mode: $DEPLOY_DIR" echo "" -# 创建部署目录 +# ── 创建部署目录 ────────────────────────────────────────── mkdir -p ego-deploy cd ego-deploy -# 下载 docker-compose.yml -echo "[1/3] 下载配置文件..." +# ── 下载配置文件 ────────────────────────────────────────── +m "[1/3] 下载配置文件..." "[1/3] Downloading config files..." curl -sL "$BASE_URL/$DEPLOY_DIR/docker-compose.yml" -o docker-compose.yml - -# 下载 nginx.conf(如果有) -if [ "$DEPLOY_DIR" = "t3-nginx" ] || [ "$DEPLOY_DIR" = "t4-certbot" ]; then +if [ ! -s docker-compose.yml ]; then + m "下载失败:docker-compose.yml 为空,请检查网络。" "Download failed: docker-compose.yml is empty. Check your network." + exit 1 +fi +if [ "$DEPLOY_DIR" = "t3-nginx" ] || [ "$DEPLOY_DIR" = "t4-acme" ]; then curl -sL "$BASE_URL/$DEPLOY_DIR/nginx.conf" -o nginx.conf fi -# 配置 -echo "[2/3] 配置..." -case $DEPLOY_DIR in +# ── 按用户选择写入最终配置 ──────────────────────────────── +m "[2/3] 写入配置..." "[2/3] Writing configuration..." +EGO_SECRET_KEY=$(openssl rand -hex 32) + +case "$DEPLOY_DIR" in t3-nginx) - sed -i.bak "s/your.domain/$DOMAIN/g" nginx.conf - rm nginx.conf.bak + # 域名 → nginx.conf + sed -i.bak "s/your.domain/$DOMAIN/g" nginx.conf && rm -f nginx.conf.bak + # 端口 → compose(占位符替换为用户最终值) + sed -i.bak "s|\${HTTP_PORT:-80}|${HTTP_PORT}|g; s|\${HTTPS_PORT:-443}|${HTTPS_PORT}|g" docker-compose.yml && rm -f docker-compose.yml.bak + # 自定义 HTTPS 端口时修正跳转目标 + if [ "$HTTPS_PORT" != "443" ]; then + sed -i.bak "s|https://\$host\$request_uri|https://\$host:${HTTPS_PORT}\$request_uri|" nginx.conf && rm -f nginx.conf.bak + fi + # 导入证书 mkdir -p certs cp "$CERT_PATH" certs/ego.crt cp "$KEY_PATH" certs/ego.key + # 会话密钥 + printf 'EGO_SECRET_KEY=%s\n' "$EGO_SECRET_KEY" > .env ;; - t4-certbot) - sed -i.bak "s/your.domain/$DOMAIN/g" nginx.conf - rm nginx.conf.bak + t4-acme) + # 域名 → nginx.conf 与 certs/.domain + sed -i.bak "s/your.domain/$DOMAIN/g" nginx.conf && rm -f nginx.conf.bak mkdir -p certs echo "$DOMAIN" > certs/.domain - - # 安装 acme.sh 到临时目录 - echo "安装 acme.sh..." - curl -sL https://get.acme.sh | sh -s email=$EMAIL - ACME_SH="$HOME/.acme.sh/acme.sh" - - if [ -n "$CF_Token" ]; then - echo "使用 Cloudflare DNS API 模式签发证书(含泛域名)..." - export CF_Token - $ACME_SH --issue --dns dns_cf -d "$DOMAIN" -d "*.$DOMAIN" --server letsencrypt - else - echo "使用 webroot 模式签发证书(需要 800 端口)..." - - # 检查 800 端口是否被 uhttpd 占用 - UHTTPD_STOPPED=0 - if netstat -tlnp 2>/dev/null | grep -q ":800.*uhttpd"; then - echo "检测到 800 端口被 uhttpd 占用,临时停止..." - /etc/init.d/uhttpd stop - UHTTPD_STOPPED=1 - sleep 2 - fi - - # 启动临时 web 服务器 - mkdir -p /tmp/acme_webroot - cd /tmp/acme_webroot - python3 -m http.server 800 & - WEB_PID=$! - sleep 2 - - $ACME_SH --issue --webroot /tmp/acme_webroot -d "$DOMAIN" --server letsencrypt - - # 停止临时 web 服务器 - kill $WEB_PID 2>/dev/null || true - cd - > /dev/null - - # 恢复 uhttpd - if [ "$UHTTPD_STOPPED" = "1" ]; then - echo "恢复 uhttpd 服务..." - /etc/init.d/uhttpd start - fi - fi - - # 复制证书到正确位置(acme.sh 默认使用 ECC 证书) - if [ -d "$HOME/.acme.sh/${DOMAIN}_ecc" ]; then - cp "$HOME/.acme.sh/${DOMAIN}_ecc/fullchain.cer" certs/ego.crt - cp "$HOME/.acme.sh/${DOMAIN}_ecc/${DOMAIN}.key" certs/ego.key - else - cp "$HOME/.acme.sh/$DOMAIN/fullchain.cer" certs/ego.crt - cp "$HOME/.acme.sh/$DOMAIN/$DOMAIN.key" certs/ego.key + # 端口 → compose + sed -i.bak "s|\${HTTP_PORT:-80}|${HTTP_PORT}|g; s|\${HTTPS_PORT:-443}|${HTTPS_PORT}|g" docker-compose.yml && rm -f docker-compose.yml.bak + if [ "$HTTPS_PORT" != "443" ]; then + sed -i.bak "s|https://\$host\$request_uri|https://\$host:${HTTPS_PORT}\$request_uri|" nginx.conf && rm -f nginx.conf.bak fi - - # 生成自签名证书给 EGo 5001 端口使用 - echo "生成自签名证书给 EGo..." - openssl req -x509 -newkey rsa:2048 -keyout certs/ego-selfsigned.key -out certs/ego-selfsigned.crt -days 365 -nodes -subj "/CN=$DOMAIN" + # CF_Token + 会话密钥 → .env(持久化,重启后续期仍可用) + printf 'CF_Token=%s\nEGO_SECRET_KEY=%s\n' "$CF_Token" "$EGO_SECRET_KEY" > .env + ;; + *) + # default / t1 / t2:仅持久化会话密钥 + printf 'EGO_SECRET_KEY=%s\n' "$EGO_SECRET_KEY" > .env ;; esac -# 启动 -echo "[3/3] 启动服务..." +# ── 启动 ───────────────────────────────────────────────── +m "[3/3] 启动服务..." "[3/3] Starting services..." docker compose up -d echo "" -echo "=== 部署完成 ===" -case $DEPLOY_DIR in +m "=== 部署完成 ===" "=== Deployment complete ===" +case "$DEPLOY_DIR" in default|t1-host|t2-bridge) - echo "管理页面: https://<主机IP>:5001(自签名证书,浏览器需放行)" - echo "Webhook: http://<主机IP>:5000" + m "管理页面: https://<主机IP>:5001(自签名证书,浏览器需放行)" "Admin UI: https://:5001 (self-signed; accept the browser warning)" + m "Webhook: http://<主机IP>:5000/in/" "Webhook: http://:5000/in/" ;; - t3-nginx|t4-certbot) - echo "管理页面: https://$DOMAIN:4430" - echo "Webhook: http://$DOMAIN:800/in/" + t3-nginx|t4-acme) + if [ "$HTTPS_PORT" = "443" ]; then + m "管理页面: https://$DOMAIN/" "Admin UI: https://$DOMAIN/" + m "Webhook: https://$DOMAIN/in/" "Webhook: https://$DOMAIN/in/" + else + m "管理页面: https://$DOMAIN:$HTTPS_PORT/" "Admin UI: https://$DOMAIN:$HTTPS_PORT/" + m "Webhook: https://$DOMAIN:$HTTPS_PORT/in/" "Webhook: https://$DOMAIN:$HTTPS_PORT/in/" + fi + m "直连管理(自签名): https://<主机IP>:5001" "Direct admin (self-signed): https://:5001" ;; esac echo "" -echo "查看日志: docker compose logs -f" -echo "停止服务: docker compose down" +m "已生成最终配置于 ./ego-deploy/(docker-compose.yml / nginx.conf / .env / certs/),均按上面的选择写入。" \ + "Final config written to ./ego-deploy/ (docker-compose.yml / nginx.conf / .env / certs/), all per your choices above." +m "查看日志: cd ego-deploy && docker compose logs -f" "View logs: cd ego-deploy && docker compose logs -f" +m "停止服务: cd ego-deploy && docker compose down" "Stop: cd ego-deploy && docker compose down" diff --git a/deploy/t3-nginx/docker-compose.yml b/deploy/t3-nginx/docker-compose.yml index 984d5ad..bf5cfaf 100644 --- a/deploy/t3-nginx/docker-compose.yml +++ b/deploy/t3-nginx/docker-compose.yml @@ -1,24 +1,30 @@ # T3 — 企业级部署(bridge + Nginx,手动证书) -# 场景:正式生产环境。Nginx 负责 HTTPS 终结(80/443), -# 5001 直通 EGo 管理页面(与 nginx 共用同一份证书)。 +# 场景:正式生产环境,自有证书。 +# +# 证书规则(与 T4 一致): +# - EGo 5001:始终使用 Flask 自动生成的自签名证书(ego_certs 卷持久化), +# 浏览器直连 https://:5001 需放行一次证书警告。 +# - Nginx 80/443:使用你导入的证书(certs/ego.crt + ego.key),反代到 EGo 的 HTTP 5000。 +# # 前置:把你的证书放到本目录 certs/ego.crt 与 certs/ego.key。 +# 端口:默认 80/443。可用环境变量 HTTP_PORT/HTTPS_PORT 覆盖(一键脚本会引导设置并写入)。 services: ego: image: codenametest/everywhereyougo:latest container_name: ego ports: - - "5001:5001" # 管理页面直通(与 nginx 共用 certs/ 目录) + - "5001:5001" # 管理页面直连(Flask 自签名证书) environment: - TZ=Asia/Shanghai - WEB_PORT=5000 - WEB_SSL_PORT=5001 - DB_PATH=/app/data/ego.db - LOG_LEVEL=INFO - - EGO_SSL_DIR=/app/certs # EGo 使用与 nginx 相同目录的证书 + - EGO_SECRET_KEY=${EGO_SECRET_KEY:-} volumes: - ego_data:/app/data - ego_config:/app/config - - ./certs:/app/certs:ro # 同一份证书,nginx 与 EGo 共用 + - ego_certs:/app/certs # EGo 自签名证书(Flask 自动生成并持久化) networks: - ego_net restart: unless-stopped @@ -29,8 +35,8 @@ services: depends_on: - ego ports: - - "800:80" - - "4430:443" + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./certs:/etc/nginx/certs:ro @@ -45,3 +51,4 @@ networks: volumes: ego_data: ego_config: + ego_certs: diff --git a/deploy/t3-nginx/nginx.conf b/deploy/t3-nginx/nginx.conf index a14fe2a..1256de4 100644 --- a/deploy/t3-nginx/nginx.conf +++ b/deploy/t3-nginx/nginx.conf @@ -1,19 +1,19 @@ # T3 Nginx — HTTPS 终结(手动证书),反代到 EGo HTTP 5000 -# 证书路径:/etc/nginx/certs/ego.crt 与 /etc/nginx/certs/ego.key -# 把本目录下的 certs/ 放上你的证书;把下面 your.domain 改为你的域名(或保留 _ 通配)。 -# 该 certs/ 目录同时被 EGo 自身使用,管理页面 5001 端口也走同一套证书。 -# 用户在前端改 path_prefix,nginx 无需任何变更。 +# 证书路径:/etc/nginx/certs/ego.crt 与 /etc/nginx/certs/ego.key(你导入的证书)。 +# 把下面所有 your.domain 改为你的域名(或保留 _ 通配)。 +# 说明:EGo 5001 使用自签名证书(独立于本目录);nginx 443 使用本目录 certs/ 的证书。 +# 若通过一键脚本改用非 443 的 HTTPS 端口,脚本会自动修正下方跳转目标。 # HTTP → HTTPS 跳转 server { listen 80; - server_name your.domain; # ← 改为你的域名,或保留 _ 通配 + server_name your.domain; return 301 https://$host$request_uri; } server { listen 443 ssl; - server_name your.domain; # ← 与上面一致 + server_name your.domain; ssl_certificate /etc/nginx/certs/ego.crt; ssl_certificate_key /etc/nginx/certs/ego.key; diff --git a/deploy/t4-acme/docker-compose.yml b/deploy/t4-acme/docker-compose.yml new file mode 100644 index 0000000..f321c66 --- /dev/null +++ b/deploy/t4-acme/docker-compose.yml @@ -0,0 +1,80 @@ +# T4 — 懒人全自动(bridge + Nginx + acme.sh,Let's Encrypt,Cloudflare DNS 验证) +# 场景:个人 / 小团队云端,拥有公网域名。自动签发并续期免费证书。 +# +# 证书规则(与 T3 一致): +# - EGo 5001:始终使用 Flask 自动生成的自签名证书(ego_certs 卷持久化), +# 浏览器直连 https://:5001 需放行一次证书警告。 +# - Nginx 80/443:使用 acme.sh 自动签发/续期的 Let's Encrypt 证书(certs/ego.crt + ego.key), +# 反代到 EGo 的 HTTP 5000。 +# +# 采用 Cloudflare DNS API 验证:不需要 80 端口,适合 80 被占用 / 无公网 80 / 内网穿透。 +# 端口:默认 80/443。可用环境变量 HTTP_PORT/HTTPS_PORT 覆盖(一键脚本会引导设置并写入)。 +# +# 使用步骤: +# 1) 写入你的域名: mkdir -p certs && echo "example.com" > certs/.domain +# 2) 改 nginx.conf: 把里面所有 your.domain 换成你的域名 +# 3) 设置 Cloudflare Token(需 Zone:DNS:Edit 权限)后启动: +# CF_Token=your_token docker compose up -d +# 4) acme.sh 经 DNS 验证签发证书(含通配符 *.example.com)写入 certs/, +# nginx 检测到证书变化自动 reload;之后每 12h 自动续期。 +# 注:首次启动 nginx 会重启几次,直到 acme.sh 签出证书,属正常现象。 +services: + ego: + image: codenametest/everywhereyougo:latest + container_name: ego + ports: + - "5001:5001" # 管理页面直连(Flask 自签名证书) + environment: + - TZ=Asia/Shanghai + - WEB_PORT=5000 + - WEB_SSL_PORT=5001 + - DB_PATH=/app/data/ego.db + - LOG_LEVEL=INFO + - EGO_SECRET_KEY=${EGO_SECRET_KEY:-} + volumes: + - ego_data:/app/data + - ego_config:/app/config + - ego_certs:/app/certs # EGo 自签名证书(Flask 自动生成并持久化) + networks: + - ego_net + restart: unless-stopped + + nginx: + image: nginx:1.25-alpine + container_name: ego-nginx + depends_on: + - ego + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./certs:/etc/nginx/certs:ro + networks: + - ego_net + restart: unless-stopped + # 每 60s 检测证书变化,变化则 reload(acme.sh 续期后自动生效) + entrypoint: /bin/sh -c '(last_md5=""; while true; do sleep 60; if [ -f /etc/nginx/certs/ego.crt ] && [ -f /etc/nginx/certs/ego.key ]; then current_md5=$$(md5sum /etc/nginx/certs/ego.crt /etc/nginx/certs/ego.key 2>/dev/null | md5sum); if [ -n "$$last_md5" ] && [ "$$current_md5" != "$$last_md5" ]; then nginx -s reload; fi; last_md5="$$current_md5"; fi; done) & nginx -g "daemon off;"' + + acme: + image: neilpang/acme.sh + container_name: ego-acme + environment: + - CF_Token=${CF_Token:-} + volumes: + - acme_conf:/acme.sh + - ./certs:/certs:rw + networks: + - ego_net + restart: unless-stopped + entrypoint: /bin/sh -c 'trap exit TERM; if [ -z "$$CF_Token" ]; then echo "ERROR - T4 requires CF_Token (Cloudflare DNS mode); set the CF_Token env var."; exit 1; fi; DOMAIN=$$(cat /certs/.domain 2>/dev/null || echo ""); if [ -z "$$DOMAIN" ] || [ "$$DOMAIN" = "your.domain" ]; then echo "ERROR - put your domain into certs/.domain first."; exit 1; fi; echo "Using Cloudflare DNS API mode for $$DOMAIN"; while :; do acme.sh --issue --dns dns_cf -d $$DOMAIN -d "*.$$DOMAIN" --server letsencrypt; if [ -d /acme.sh/$${DOMAIN}_ecc ]; then cp /acme.sh/$${DOMAIN}_ecc/fullchain.cer /certs/ego.crt; cp /acme.sh/$${DOMAIN}_ecc/$${DOMAIN}.key /certs/ego.key; else cp /acme.sh/$$DOMAIN/fullchain.cer /certs/ego.crt; cp /acme.sh/$$DOMAIN/$${DOMAIN}.key /certs/ego.key; fi; echo "Certificate issued/renewed and copied to certs/"; sleep 12h & wait $$!; done' + +networks: + ego_net: + name: ego_net + +volumes: + ego_data: + ego_config: + ego_certs: + acme_conf: diff --git a/deploy/t4-acme/nginx.conf b/deploy/t4-acme/nginx.conf new file mode 100644 index 0000000..ae8e1da --- /dev/null +++ b/deploy/t4-acme/nginx.conf @@ -0,0 +1,34 @@ +# T4 Nginx — HTTPS 终结(acme.sh 自动证书),反代到 EGo HTTP 5000 +# 证书路径:/etc/nginx/certs/ego.crt 与 /etc/nginx/certs/ego.key(acme.sh 自动签发/续期)。 +# 把下面所有 your.domain 改为你的域名。 +# 说明:EGo 5001 使用自签名证书(独立于本目录);nginx 443 使用 acme.sh 写入的 Let's Encrypt 证书。 +# 若通过一键脚本改用非 443 的 HTTPS 端口,脚本会自动修正下方跳转目标。 + +# HTTP → HTTPS 跳转 +server { + listen 80; + server_name your.domain; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name your.domain; + + ssl_certificate /etc/nginx/certs/ego.crt; + ssl_certificate_key /etc/nginx/certs/ego.key; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 6m; # 略大于 EGo 的 5MB body 上限 + + location / { + # 全部流量透传 EGo HTTP 5000,路径路由由 EGo 内部处理 + proxy_pass http://ego:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_http_version 1.1; + proxy_read_timeout 60s; + } +} diff --git a/deploy/t4-certbot/docker-compose.yml b/deploy/t4-certbot/docker-compose.yml deleted file mode 100644 index 292622a..0000000 --- a/deploy/t4-certbot/docker-compose.yml +++ /dev/null @@ -1,80 +0,0 @@ -# T4 — 懒人全自动(bridge + Nginx + acme.sh,Let's Encrypt) -# 场景:个人 / 小团队云端,拥有公网域名。自动签发并续期免费证书。 -# -# 证书分离设计: -# - nginx 443: 使用 certs/ego.crt + ego.key(Let's Encrypt 证书) -# - EGo 5001: 使用 certs/ego-selfsigned.crt + ego-selfsigned.key(自签名证书) -# -# 默认使用 webroot 模式(需要 80 端口),也支持 Cloudflare DNS API 验证。 -# -# 方式一:webroot 模式(默认,需要域名解析到本机公网 IP) -# docker compose up -d -# -# 方式二:Cloudflare DNS API 模式(不需要 80 端口) -# 设置环境变量 CF_Token 后启动: -# CF_Token=your_token docker compose up -d -# -# 之后 acme.sh 容器会每 12h 自动续期,nginx 每 60s 检测证书变化并自动 reload。 -services: - ego: - image: codenametest/everywhereyougo:latest - container_name: ego - ports: - - "5001:5001" - environment: - - TZ=Asia/Shanghai - - WEB_PORT=5000 - - WEB_SSL_PORT=5001 - - DB_PATH=/app/data/ego.db - - LOG_LEVEL=INFO - - EGO_SSL_CERT=/app/certs/ego-selfsigned.crt - - EGO_SSL_KEY=/app/certs/ego-selfsigned.key - - EGO_SECRET_KEY=${EGO_SECRET_KEY:-} - volumes: - - ego_data:/app/data - - ego_config:/app/config - - ./certs:/app/certs:ro - networks: - - ego_net - restart: unless-stopped - - nginx: - image: nginx:1.25-alpine - container_name: ego-nginx - depends_on: - - ego - ports: - - "800:80" - - "4430:443" - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./certs:/etc/nginx/certs:ro - - acme_www:/var/www/acme:ro - networks: - - ego_net - restart: unless-stopped - entrypoint: /bin/sh -c '(last_md5=""; while true; do sleep 60; if [ -f /etc/nginx/certs/ego.crt ] && [ -f /etc/nginx/certs/ego.key ]; then current_md5=$$(md5sum /etc/nginx/certs/ego.crt /etc/nginx/certs/ego.key 2>/dev/null | md5sum); if [ -n "$$last_md5" ] && [ "$$current_md5" != "$$last_md5" ]; then nginx -s reload; fi; last_md5="$$current_md5"; fi; done) & nginx -g "daemon off;"' - - acme: - image: neilpang/acme.sh - container_name: ego-acme - environment: - - CF_Token=${CF_Token:-} - volumes: - - acme_www:/var/www/acme - - acme_conf:/acme.sh - - ./certs:/certs:rw - networks: - - ego_net - restart: unless-stopped - entrypoint: /bin/sh -c 'trap exit TERM; DOMAIN=$$(cat /certs/.domain 2>/dev/null || echo "your.domain"); if [ -n "$$CF_Token" ]; then echo "Using Cloudflare DNS API mode"; while :; do acme.sh --issue --dns dns_cf -d $$DOMAIN -d "*.$$DOMAIN" --server letsencrypt; if [ -d /acme.sh/$${DOMAIN}_ecc ]; then cp /acme.sh/$${DOMAIN}_ecc/fullchain.cer /certs/ego.crt; cp /acme.sh/$${DOMAIN}_ecc/$${DOMAIN}.key /certs/ego.key; else cp /acme.sh/$$DOMAIN/fullchain.cer /certs/ego.crt; cp /acme.sh/$$DOMAIN/$${DOMAIN}.key /certs/ego.key; fi; echo "Certificate renewed and copied to certs/"; sleep 12h & wait $${!}; done; else echo "Using webroot mode (requires port 80)"; while :; do acme.sh --issue --webroot /var/www/acme -d $$DOMAIN --server letsencrypt; if [ -d /acme.sh/$${DOMAIN}_ecc ]; then cp /acme.sh/$${DOMAIN}_ecc/fullchain.cer /certs/ego.crt; cp /acme.sh/$${DOMAIN}_ecc/$${DOMAIN}.key /certs/ego.key; else cp /acme.sh/$$DOMAIN/fullchain.cer /certs/ego.crt; cp /acme.sh/$$DOMAIN/$${DOMAIN}.key /certs/ego.key; fi; echo "Certificate renewed and copied to certs/"; sleep 12h & wait $${!}; done; fi' - -networks: - ego_net: - name: ego_net - -volumes: - ego_data: - ego_config: - acme_www: - acme_conf: diff --git a/deploy/t4-certbot/nginx.conf b/deploy/t4-certbot/nginx.conf deleted file mode 100644 index 587eaca..0000000 --- a/deploy/t4-certbot/nginx.conf +++ /dev/null @@ -1,42 +0,0 @@ -# T4 Nginx — Let's Encrypt(acme.sh webroot 自动续期) -# 证书分离设计: -# - nginx 443: 使用 certs/ego.crt + ego.key(Let's Encrypt 证书) -# - EGo 5001: 使用 certs/ego-selfsigned.crt + ego-selfsigned.key(自签名证书) -# 首次签发后需把证书复制到 certs/ 目录(见 docker-compose 注释)。 -# 把所有 your.domain 改成你的域名。 - -server { - listen 80; - server_name your.domain; - - # ACME 挑战(证书签发 / 续期用,acme.sh webroot 模式) - location /.well-known/acme-challenge/ { - root /var/www/acme; - } - - # 其余请求一律跳 HTTPS - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - server_name your.domain; - - ssl_certificate /etc/nginx/certs/ego.crt; - ssl_certificate_key /etc/nginx/certs/ego.key; - ssl_protocols TLSv1.2 TLSv1.3; - - client_max_body_size 6m; - - location / { - proxy_pass http://ego:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - proxy_http_version 1.1; - proxy_read_timeout 60s; - } -} diff --git a/doc/architecture.md b/doc/architecture.md index f87d57f..9d1703b 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -194,13 +194,13 @@ v1.2.2 起应用同时监听两个端口,按用途分流: | T1 | 裸机直连 | `host` | HTTPS 自签名 | 自动自签名 | 家庭/内网调试 | | T2 | Docker 内网 | `bridge` | HTTPS 自签名 | 自动自签名 | 容器间协同 | | T3 | 企业级部署 | `bridge` + Nginx | HTTPS 可信证书 | 手动证书 | 正式生产环境 | -| T4 | 懒人全自动 | `bridge` + Nginx + Certbot | HTTPS 可信证书 | Let's Encrypt | 个人/小团队云端 | +| T4 | 懒人全自动 | `bridge` + Nginx + acme.sh | HTTPS 可信证书 | Let's Encrypt | 个人/小团队云端 | > 管理页面统一走 HTTPS:浏览器剪贴板 API(页面复制按钮)要求安全上下文。T1/T2 用应用内置自签名证书(`ego_certs` 卷持久化),Webhook / 健康检查始终 HTTP。 **职责分工**:EGo 负责核心消息处理 + WebUI SSL + 5MB Body 防护。Nginx(T3/T4)负责 HTTPS 终结 + 认证 + 限流 + 来源鉴权。 -> 开箱即用的 Compose 配置统一见 `deploy/`(`default` / `t1-host` / `t2-bridge` / `t3-nginx` / `t4-certbot`),选用方式见 `deploy/README.md`。 +> 开箱即用的 Compose 配置统一见 `deploy/`(`default` / `t1-host` / `t2-bridge` / `t3-nginx` / `t4-acme`),选用方式见 `deploy/README.md`。 --- diff --git a/doc/changelog.md b/doc/changelog.md index 7318458..e960a0f 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -4,6 +4,27 @@ --- +## v1.2.4(2026-07-28) + +### 部署配置重构(deploy/) + +- T4 证书方案由 certbot 改为 **acme.sh + Cloudflare DNS 验证**(无需 80 端口,适合 80 被占用 / 无公网 80 / 内网穿透);目录 `t4-certbot/` 更名 `t4-acme/`。 +- **证书分离**:EGo 5001 管理页面恒用 Flask 自动生成的自签名证书(`ego_certs` 卷持久化),T1–T4 统一遵循;nginx(T3/T4)独立使用真实证书(T3 手动导入、T4 Let's Encrypt),反代到 EGo 明文 HTTP 5000。两张证书互不影响。 +- T3/T4 nginx 主机端口参数化:`${HTTP_PORT:-80}` / `${HTTPS_PORT:-443}`,可用 `.env` 覆盖。 +- 一键脚本 `init.sh` 支持**中英双语**;T3/T4 增加端口占用检测(netstat 告警)与现场自定义,并把端口/域名/CF_Token/EGO_SECRET_KEY 写入最终生成的 compose 与 `.env`。 +- `deploy/README.md` / `README.en.md` 增补「证书规则」说明与 T4 证书续签运维操作(查日志 / 查有效期 / 强制续期 / 换域名 / 换 Token)。 + +### 安全修复 + +- #22a 路径穿越:解析器上传(`api/parsers.py`)、通道插件上传及其 `` 路由(GET/PUT/DELETE/test/fields,`api/channels.py`)统一对文件名做 `os.path.basename()`,封堵 `../` 逃逸(PUT/DELETE 可写/删任意文件,风险最高)。 + +### 其它 + +- `build.py`:版本升至 1.2.4,镜像名改为 `codenametest/everywhereyougo`(与 deploy/ 拉取一致),部署示例改为双端口。 +- `doc/roadmap.md`:路径路由(#17)已在 v1.2.2 交付,移入已完成;补充已完成版本列表。 + +--- + ## v1.2.3(2026-07-27) ### 部署配置重构 diff --git a/doc/roadmap.md b/doc/roadmap.md index 2e29909..55a4ea5 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -1,13 +1,50 @@ # EGo 路线图 > 敲定的后续开发内容。完成后记入 changelog.md。 -> 最后更新:2026-07-22 +> 最后更新:2026-07-28 --- ## v1.3.0(下一个大版本) -### 路径路由(统一入口) +### 开放 API(规划中) + +- 外部系统通过 REST API 直接投递消息(不依赖 Webhook 格式) +- 需配套 API Key 认证机制 +- 与路径路由共享入口端口 + +### 韧性增强 + +| 项目 | 改进方向 | 来源 | +|------|----------|------| +| 通道熔断 | 连续失败 N 次自动熔断,定期探测恢复,防第三方宕机拖垮线程池 | 改进文档 #19 | +| 出站通道限流 | 令牌桶(条/分钟)按通道独立,防多通道并发被封号(Nginx 管不到出站) | 改进文档 #21 | + +--- + +## 待定(需确认后纳入版本) + +| 项目 | 触发条件 | 来源 | +|------|----------|------| +| Prometheus Metrics(`/metrics`) | T3/T4 部署接监控时 | 改进文档 #18 | +| API 限流 | 公网暴露时 | 改进文档 #20 | + +--- + +## 已完成版本 + +- **v1.2.4**(2026-07-28)— 部署配置重构(T4 改 acme.sh + Cloudflare DNS、证书分离、双语 init.sh、端口检测)+ #22a 上传路径穿越修复,详见 changelog.md +- **v1.2.3**(2026-07-27)— deploy/ 五套环境配置统一、EGO_SSL_DIR 参数一致性、nginx 反代跳转修复,详见 changelog.md +- **v1.2.2**(2026-07-26)— 可靠性加固(线程安全 DB、静默异常日志、备份路径穿越、ZIP 炸弹、CSRF cookie)+ **路径路由(#17,统一入口 `/in/`)**,详见 changelog.md +- **v1.2.1**(2026-07-24)— Sentinel 代码审核补充,详见 changelog.md +- **v1.2.0**(2026-07-21)— 异步队列 + 安全加固 + 健壮性,详见 changelog.md +- **v1.1.0**(2026-07-18)— 事件总线重构 + 通道插件化 + i18n,详见 changelog.md + +--- + +## 已交付功能存档 + +### 路径路由(统一入口)— v1.2.2 交付 **URL 结构**:`https:///<前缀>/<源slug>/<子路径>` @@ -60,27 +97,3 @@ server { ``` 来源:改进文档 #17 - -### 开放 API(规划中) - -- 外部系统通过 REST API 直接投递消息(不依赖 Webhook 格式) -- 需配套 API Key 认证机制 -- 与路径路由共享入口端口 - ---- - -## 待定(需确认后纳入版本) - -| 项目 | 触发条件 | 来源 | -|------|----------|------| -| Prometheus Metrics(`/metrics`) | T3/T4 部署接监控时 | 改进文档 #18 | -| 通道熔断 | 生产环境通道不稳定时 | 改进文档 #19 | -| API 限流 | 公网暴露时 | 改进文档 #20 | -| 出站通道限流 | 多通道高频推送被封号时 | 改进文档 #21 | - ---- - -## 已完成版本 - -- **v1.2.0**(2026-07-21)— 异步队列 + 安全加固 + 健壮性,详见 changelog.md -- **v1.1.0**(2026-07-18)— 事件总线重构 + 通道插件化 + i18n,详见 changelog.md diff --git a/main.py b/main.py index 593dfcc..fb3969d 100644 --- a/main.py +++ b/main.py @@ -31,7 +31,7 @@ import worker # 异步发送 worker from web_ui import run_web_ui, has_ssl, ssl_enabled_by_env, app as web_app -VERSION = "1.2.3" +VERSION = "1.2.4" AUTHOR = "codename-test" DESCRIPTION = "EverywhereYouGo (EGo) — 通用信息转发平台" diff --git a/version.json b/version.json index 2f7862a..42d8de8 100644 --- a/version.json +++ b/version.json @@ -1,12 +1,13 @@ { - "version": "1.2.3", - "release_date": "2026-07-27", + "version": "1.2.4", + "release_date": "2026-07-28", "url": "https://github.com/codename-test/EverywhereYouGo/releases/latest", "changelog": [ - "部署配置重构:统一 deploy/ 目录五套环境,nginx 证书/端口标准化", - "EGO_SSL_DIR 参数一致性修复(api/web_ui/gen_cert 三处统一)", - "nginx 代理 301 跳转修复(检查 X-Forwarded-Proto 头)", - "T4 证书续期全自动(certbot deploy-hook + nginx 轮询 reload)", - "README 环境变量文档更新,配置持久化说明修正" + "T4 证书方案重构:certbot 改用 acme.sh + Cloudflare DNS 验证(无需 80 端口)", + "证书分离:EGo 5001 恒用 Flask 自签名证书,nginx(T3/T4) 独立使用真实证书", + "一键脚本 init.sh 支持中英双语,T3/T4 增加端口占用检测与自定义", + "deploy/README 增补证书规则说明与 T4 续签运维操作", + "修复解析器/通道插件上传及 路由的路径穿越(#22a)", + "build.py 镜像名/版本/部署示例修正" ] -} \ No newline at end of file +}