1738 lines
84 KiB
Python
1738 lines
84 KiB
Python
|
|
import asyncio
|
|||
|
|
import html
|
|||
|
|
import json
|
|||
|
|
import math
|
|||
|
|
import os
|
|||
|
|
import sqlite3
|
|||
|
|
import time
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
import paramiko
|
|||
|
|
from telegram import (
|
|||
|
|
BotCommand,
|
|||
|
|
InlineKeyboardButton,
|
|||
|
|
InlineKeyboardMarkup,
|
|||
|
|
MenuButtonCommands,
|
|||
|
|
ReplyKeyboardRemove,
|
|||
|
|
Update,
|
|||
|
|
)
|
|||
|
|
from telegram.constants import ParseMode
|
|||
|
|
from telegram.ext import (
|
|||
|
|
Application,
|
|||
|
|
CallbackQueryHandler,
|
|||
|
|
CommandHandler,
|
|||
|
|
ContextTypes,
|
|||
|
|
MessageHandler,
|
|||
|
|
filters,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
|||
|
|
KEYS_DIR = os.path.join(BASE_DIR, "keys")
|
|||
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|||
|
|
os.makedirs(KEYS_DIR, exist_ok=True)
|
|||
|
|
DB_PATH = os.path.join(DATA_DIR, "app.db")
|
|||
|
|
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def esc(s: str) -> str:
|
|||
|
|
return html.escape(str(s), quote=False)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_config_file():
|
|||
|
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_config_file(cfg):
|
|||
|
|
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
|
|||
|
|
CONFIG = load_config_file()
|
|||
|
|
TOKEN = CONFIG["bot_token"]
|
|||
|
|
ADMIN_IDS = set(CONFIG.get("admin_ids", []))
|
|||
|
|
BOT_NAME = CONFIG.get("bot_name", "VPS 多机中控")
|
|||
|
|
|
|||
|
|
SETTINGS_DEFAULTS = {
|
|||
|
|
"default_user": CONFIG.get("default_user", "root"),
|
|||
|
|
"default_port": str(CONFIG.get("default_port", 22)),
|
|||
|
|
"default_password": CONFIG.get("default_password", ""),
|
|||
|
|
"default_key_path": CONFIG.get("default_key_path", "/opt/vpsbot8/keys/koipy_key"),
|
|||
|
|
"check_interval": "60",
|
|||
|
|
"fail_threshold": "3",
|
|||
|
|
"notify_recovery": "1",
|
|||
|
|
"notify_offline": "1",
|
|||
|
|
"cpu_alert_threshold": "0",
|
|||
|
|
"mem_alert_threshold": "0",
|
|||
|
|
"disk_alert_threshold": "0",
|
|||
|
|
"expire_remind_days": "7",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
READ_ONLY_BATCH = {
|
|||
|
|
"hostname": ("🏷 主机名", "hostname"),
|
|||
|
|
"disk": ("💽 磁盘", "df -h /"),
|
|||
|
|
"failed": ("🚨 失败服务", "systemctl --failed --no-pager --no-legend || true"),
|
|||
|
|
"docker": ("🐳 Docker", "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' 2>/dev/null || echo docker_not_available"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ADD_FLOW = ["name", "group_name", "host", "port", "user"]
|
|||
|
|
SERVICE_ACTIONS = {
|
|||
|
|
"status": "状态",
|
|||
|
|
"restart": "重启",
|
|||
|
|
"start": "启动",
|
|||
|
|
"stop": "停止",
|
|||
|
|
}
|
|||
|
|
READONLY_CUSTOM_PREFIXES = (
|
|||
|
|
"uname", "hostname", "whoami", "uptime", "date", "df", "free", "cat /proc/",
|
|||
|
|
"docker ps", "docker images", "systemctl status", "systemctl is-active", "systemctl list-units",
|
|||
|
|
"ss ", "netstat ", "ip a", "ip r", "curl -I", "curl -sI", "ps ", "top -bn1", "lsb_release", "neofetch"
|
|||
|
|
)
|
|||
|
|
BLOCKED_CUSTOM_TOKENS = (
|
|||
|
|
" rm ", " reboot", " shutdown", " poweroff", " init 0", " init 6", " mkfs", " dd ", " chmod ", " chown ",
|
|||
|
|
" useradd", " userdel", " passwd", " systemctl restart", " systemctl stop", " systemctl start", " docker stop", " docker restart",
|
|||
|
|
" docker rm", " apt ", " apt-get", " yum ", " dnf ", " apk ", " pacman ", " pip install", " npm install", " sed -i", " tee ",
|
|||
|
|
">", "| sh", "| bash", "&&", ";", "\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Node:
|
|||
|
|
id: int
|
|||
|
|
name: str
|
|||
|
|
group_name: str
|
|||
|
|
host: str
|
|||
|
|
port: int
|
|||
|
|
user: str
|
|||
|
|
auth_type: str
|
|||
|
|
auth_value: str
|
|||
|
|
enabled: int
|
|||
|
|
created_at: int
|
|||
|
|
remark: str = ""
|
|||
|
|
expires_at: int = 0
|
|||
|
|
monthly_price: float = 0.0
|
|||
|
|
price_currency: str = "U"
|
|||
|
|
price_cycle: str = "month"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def db():
|
|||
|
|
conn = sqlite3.connect(DB_PATH)
|
|||
|
|
conn.row_factory = sqlite3.Row
|
|||
|
|
return conn
|
|||
|
|
|
|||
|
|
|
|||
|
|
def init_db():
|
|||
|
|
conn = db()
|
|||
|
|
conn.executescript(
|
|||
|
|
"""
|
|||
|
|
CREATE TABLE IF NOT EXISTS nodes (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
name TEXT NOT NULL,
|
|||
|
|
group_name TEXT NOT NULL DEFAULT 'default',
|
|||
|
|
host TEXT NOT NULL,
|
|||
|
|
port INTEGER NOT NULL DEFAULT 22,
|
|||
|
|
user TEXT NOT NULL DEFAULT 'root',
|
|||
|
|
password TEXT NOT NULL DEFAULT '',
|
|||
|
|
auth_type TEXT DEFAULT 'password',
|
|||
|
|
auth_value TEXT DEFAULT '',
|
|||
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|||
|
|
created_at INTEGER NOT NULL,
|
|||
|
|
remark TEXT NOT NULL DEFAULT '',
|
|||
|
|
expires_at INTEGER NOT NULL DEFAULT 0,
|
|||
|
|
monthly_price REAL NOT NULL DEFAULT 0,
|
|||
|
|
price_currency TEXT NOT NULL DEFAULT 'U',
|
|||
|
|
price_cycle TEXT NOT NULL DEFAULT 'month'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|||
|
|
key TEXT PRIMARY KEY,
|
|||
|
|
value TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS node_state (
|
|||
|
|
node_id INTEGER PRIMARY KEY,
|
|||
|
|
fail_count INTEGER NOT NULL DEFAULT 0,
|
|||
|
|
is_online INTEGER NOT NULL DEFAULT 1,
|
|||
|
|
last_error TEXT DEFAULT '',
|
|||
|
|
last_change INTEGER NOT NULL DEFAULT 0,
|
|||
|
|
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS resource_state (
|
|||
|
|
node_id INTEGER NOT NULL,
|
|||
|
|
metric TEXT NOT NULL,
|
|||
|
|
alerted INTEGER NOT NULL DEFAULT 0,
|
|||
|
|
last_value REAL NOT NULL DEFAULT 0,
|
|||
|
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|||
|
|
PRIMARY KEY (node_id, metric),
|
|||
|
|
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS expiry_state (
|
|||
|
|
node_id INTEGER PRIMARY KEY,
|
|||
|
|
last_days_left INTEGER NOT NULL DEFAULT 999999,
|
|||
|
|
last_notified_at INTEGER NOT NULL DEFAULT 0,
|
|||
|
|
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|||
|
|
);
|
|||
|
|
"""
|
|||
|
|
)
|
|||
|
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(nodes)").fetchall()}
|
|||
|
|
if "auth_type" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN auth_type TEXT DEFAULT 'password'")
|
|||
|
|
if "auth_value" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN auth_value TEXT DEFAULT ''")
|
|||
|
|
if "group_name" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN group_name TEXT DEFAULT 'default'")
|
|||
|
|
if "password" in cols:
|
|||
|
|
conn.execute("UPDATE nodes SET auth_value=password WHERE (auth_value='' OR auth_value IS NULL) AND password IS NOT NULL")
|
|||
|
|
conn.execute("UPDATE nodes SET auth_type='password' WHERE auth_type IS NULL OR auth_type='' ")
|
|||
|
|
if "remark" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN remark TEXT NOT NULL DEFAULT ''")
|
|||
|
|
if "expires_at" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0")
|
|||
|
|
if "monthly_price" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN monthly_price REAL NOT NULL DEFAULT 0")
|
|||
|
|
if "price_currency" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN price_currency TEXT NOT NULL DEFAULT 'U'")
|
|||
|
|
if "price_cycle" not in cols:
|
|||
|
|
conn.execute("ALTER TABLE nodes ADD COLUMN price_cycle TEXT NOT NULL DEFAULT 'month'")
|
|||
|
|
for k, v in SETTINGS_DEFAULTS.items():
|
|||
|
|
conn.execute("INSERT OR IGNORE INTO settings(key,value) VALUES(?,?)", (k, v))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_setting(key: str) -> str:
|
|||
|
|
conn = db()
|
|||
|
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
|||
|
|
conn.close()
|
|||
|
|
if row:
|
|||
|
|
return row[0]
|
|||
|
|
return SETTINGS_DEFAULTS[key]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_setting(key: str, value: str):
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, str(value)))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
if key in {"default_user", "default_port", "default_password", "default_key_path"}:
|
|||
|
|
cfg = load_config_file()
|
|||
|
|
cfg[key] = int(value) if key == "default_port" else value
|
|||
|
|
save_config_file(cfg)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def defaults_dict():
|
|||
|
|
return {
|
|||
|
|
"user": get_setting("default_user"),
|
|||
|
|
"port": int(get_setting("default_port")),
|
|||
|
|
"password": get_setting("default_password"),
|
|||
|
|
"key_path": get_setting("default_key_path"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def format_expiry(ts: int) -> str:
|
|||
|
|
if not ts:
|
|||
|
|
return "未设置"
|
|||
|
|
return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def days_left_text(ts: int) -> str:
|
|||
|
|
if not ts:
|
|||
|
|
return "未设置"
|
|||
|
|
days = int((int(ts) - int(time.time())) // 86400)
|
|||
|
|
if days > 0:
|
|||
|
|
return f"{days} 天"
|
|||
|
|
if days == 0:
|
|||
|
|
return "今天到期"
|
|||
|
|
return f"已过期 {abs(days)} 天"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def alert_settings_text() -> str:
|
|||
|
|
return (
|
|||
|
|
f"巡检间隔:<code>{get_setting('check_interval')}</code> 秒\n"
|
|||
|
|
f"失败阈值:<code>{get_setting('fail_threshold')}</code> 次\n"
|
|||
|
|
f"掉线通知:<code>{'开' if get_setting('notify_offline') == '1' else '关'}</code>\n"
|
|||
|
|
f"恢复通知:<code>{'开' if get_setting('notify_recovery') == '1' else '关'}</code>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_prompts():
|
|||
|
|
d = defaults_dict()
|
|||
|
|
return {
|
|||
|
|
"name": "发我节点名称,例如:<code>HK-1</code>",
|
|||
|
|
"group_name": f"发我节点分组,例如:<code>HK</code> / <code>生产</code>。留空默认 <code>default</code>\n现有分组:<code>{', '.join([g for g,_ in list_groups()][:8]) or 'default'}</code>",
|
|||
|
|
"host": "发我节点 IP 或域名,例如:<code>1.2.3.4</code>",
|
|||
|
|
"port": f"发我 SSH 端口,默认 <code>{d['port']}</code>,留空也行。",
|
|||
|
|
"user": f"发我 SSH 用户,默认 <code>{d['user']}</code>,留空也行。",
|
|||
|
|
"custom_password": "发我这台机器的 SSH 密码。",
|
|||
|
|
"custom_key": "发我私钥路径,或者直接把私钥内容整段发过来。",
|
|||
|
|
"set_default_password": "发我新的默认 SSH 密码。",
|
|||
|
|
"set_default_key": "发我新的默认私钥路径,或者直接发整段私钥内容。",
|
|||
|
|
"set_default_user": "发我新的默认 SSH 用户。",
|
|||
|
|
"set_default_port": "发我新的默认 SSH 端口。",
|
|||
|
|
"set_check_interval": "发我新的巡检间隔(秒),例如 60。",
|
|||
|
|
"set_fail_threshold": "发我新的失败阈值,例如 3。",
|
|||
|
|
"set_cpu_alert_threshold": "发我新的 CPU 告警阈值。建议用 load 值,例如 4。填 0 为关闭。",
|
|||
|
|
"set_mem_alert_threshold": "发我新的内存告警阈值(百分比)。填 0 为关闭。",
|
|||
|
|
"set_disk_alert_threshold": "发我新的磁盘告警阈值(百分比)。填 0 为关闭。",
|
|||
|
|
"set_expire_remind_days": "发我新的到期提前提醒天数,例如 7。",
|
|||
|
|
"edit_name": "发我新的节点名称。",
|
|||
|
|
"edit_host": "发我新的 IP 或域名。",
|
|||
|
|
"edit_port": "发我新的 SSH 端口。",
|
|||
|
|
"edit_user": "发我新的 SSH 用户。",
|
|||
|
|
"edit_auth_password": "发我新的 SSH 密码。",
|
|||
|
|
"edit_auth_key": "发我新的私钥路径,或者直接发整段私钥内容。",
|
|||
|
|
"edit_remark": "发我新的节点备注。留空可发一个 - 代表清空。",
|
|||
|
|
"edit_monthly_price": "发我新的月付(U),例如 4.99。填 0 代表未设置。",
|
|||
|
|
"edit_expires_at": "发我新的到期日期,格式:YYYY-MM-DD。填 0 代表清空。",
|
|||
|
|
"batch_service_name": "发我要批量操作的 systemd 服务名,例如:nginx / docker / openclaw。",
|
|||
|
|
"batch_readonly_command": "发我要批量执行的只读命令,例如:df -h /、free -m、docker ps、systemctl status nginx。",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def maybe_store_private_key(raw: str, prefix: str = "custom") -> str:
|
|||
|
|
text = (raw or "").strip()
|
|||
|
|
if "BEGIN OPENSSH PRIVATE KEY" in text or "BEGIN RSA PRIVATE KEY" in text or "BEGIN PRIVATE KEY" in text:
|
|||
|
|
path = os.path.join(KEYS_DIR, f"{prefix}_{int(time.time()*1000)}.key")
|
|||
|
|
with open(path, "w", encoding="utf-8") as f:
|
|||
|
|
f.write(text)
|
|||
|
|
if not text.endswith("\n"):
|
|||
|
|
f.write("\n")
|
|||
|
|
os.chmod(path, 0o600)
|
|||
|
|
return path
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_admin(update: Update) -> bool:
|
|||
|
|
user = update.effective_user
|
|||
|
|
return bool(user and user.id in ADMIN_IDS)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def guard(update: Update):
|
|||
|
|
if not is_admin(update):
|
|||
|
|
if update.message:
|
|||
|
|
await update.message.reply_text("你不在管理员白名单里。")
|
|||
|
|
elif update.callback_query:
|
|||
|
|
await update.callback_query.answer("你不在管理员白名单里。", show_alert=True)
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main_menu():
|
|||
|
|
return InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("📊 总览", callback_data="nodes:overview"), InlineKeyboardButton("🖥 节点", callback_data="nodes:list")],
|
|||
|
|
[InlineKeyboardButton("➕ 添加", callback_data="nodes:add"), InlineKeyboardButton("🗂 分组", callback_data="groups:list")],
|
|||
|
|
[InlineKeyboardButton("🔔 告警", callback_data="settings:alerts"), InlineKeyboardButton("🔐 认证", callback_data="settings:auth")],
|
|||
|
|
[InlineKeyboardButton("💰 账单", callback_data="billing:summary"), InlineKeyboardButton("⏰ 到期", callback_data="billing:expiring")],
|
|||
|
|
[InlineKeyboardButton("🧰 批量操作", callback_data="batch:menu")],
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_node(node_id: int) -> Optional[Node]:
|
|||
|
|
conn = db()
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT id,name,group_name,host,port,user,COALESCE(auth_type,'password') auth_type, COALESCE(NULLIF(auth_value,''),password) auth_value, enabled, created_at, COALESCE(remark,'') remark, COALESCE(expires_at,0) expires_at, COALESCE(monthly_price,0) monthly_price, COALESCE(price_currency,'U') price_currency, COALESCE(price_cycle,'month') price_cycle FROM nodes WHERE id=?",
|
|||
|
|
(node_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
conn.close()
|
|||
|
|
return Node(**dict(row)) if row else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def all_nodes(enabled_only=True):
|
|||
|
|
conn = db()
|
|||
|
|
sql = "SELECT id,name,group_name,host,port,user,COALESCE(auth_type,'password') auth_type, COALESCE(NULLIF(auth_value,''),password) auth_value, enabled, created_at, COALESCE(remark,'') remark, COALESCE(expires_at,0) expires_at, COALESCE(monthly_price,0) monthly_price, COALESCE(price_currency,'U') price_currency, COALESCE(price_cycle,'month') price_cycle FROM nodes"
|
|||
|
|
if enabled_only:
|
|||
|
|
sql += " WHERE enabled=1"
|
|||
|
|
sql += " ORDER BY group_name ASC, id DESC"
|
|||
|
|
rows = conn.execute(sql).fetchall()
|
|||
|
|
conn.close()
|
|||
|
|
return [Node(**dict(r)) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def nodes_by_group(group_name: str):
|
|||
|
|
conn = db()
|
|||
|
|
rows = conn.execute(
|
|||
|
|
"SELECT id,name,group_name,host,port,user,COALESCE(auth_type,'password') auth_type, COALESCE(NULLIF(auth_value,''),password) auth_value, enabled, created_at, COALESCE(remark,'') remark, COALESCE(expires_at,0) expires_at, COALESCE(monthly_price,0) monthly_price, COALESCE(price_currency,'U') price_currency, COALESCE(price_cycle,'month') price_cycle FROM nodes WHERE enabled=1 AND group_name=? ORDER BY id DESC",
|
|||
|
|
(group_name,),
|
|||
|
|
).fetchall()
|
|||
|
|
conn.close()
|
|||
|
|
return [Node(**dict(r)) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_groups():
|
|||
|
|
conn = db()
|
|||
|
|
rows = conn.execute("SELECT group_name, COUNT(*) c FROM nodes WHERE enabled=1 GROUP BY group_name ORDER BY group_name ASC").fetchall()
|
|||
|
|
conn.close()
|
|||
|
|
return [(r[0], r[1]) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def auth_label(node: Node) -> str:
|
|||
|
|
d = defaults_dict()
|
|||
|
|
if node.auth_type == "key":
|
|||
|
|
if node.auth_value == d["key_path"]:
|
|||
|
|
return "默认Key"
|
|||
|
|
return "自定义Key"
|
|||
|
|
if node.auth_value == d["password"] and d["password"]:
|
|||
|
|
return "默认密码"
|
|||
|
|
return "自定义密码"
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def run_ssh(node: Node, command: str, timeout: int = 12):
|
|||
|
|
def _run():
|
|||
|
|
client = paramiko.SSHClient()
|
|||
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|||
|
|
kwargs = dict(
|
|||
|
|
hostname=node.host,
|
|||
|
|
port=node.port,
|
|||
|
|
username=node.user,
|
|||
|
|
timeout=timeout,
|
|||
|
|
banner_timeout=timeout,
|
|||
|
|
auth_timeout=timeout,
|
|||
|
|
look_for_keys=False,
|
|||
|
|
allow_agent=False,
|
|||
|
|
)
|
|||
|
|
if node.auth_type == "key":
|
|||
|
|
kwargs["key_filename"] = node.auth_value
|
|||
|
|
else:
|
|||
|
|
kwargs["password"] = node.auth_value
|
|||
|
|
try:
|
|||
|
|
client.connect(**kwargs)
|
|||
|
|
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
|||
|
|
out = stdout.read().decode("utf-8", errors="ignore")
|
|||
|
|
err = stderr.read().decode("utf-8", errors="ignore")
|
|||
|
|
code = stdout.channel.recv_exit_status()
|
|||
|
|
return code, out.strip(), err.strip()
|
|||
|
|
finally:
|
|||
|
|
client.close()
|
|||
|
|
return await asyncio.to_thread(_run)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def collect_node_summary(node: Node):
|
|||
|
|
cmd = r'''bash -lc '
|
|||
|
|
HOST=$(hostname 2>/dev/null || echo unknown)
|
|||
|
|
UP=$(uptime -p 2>/dev/null || uptime)
|
|||
|
|
LOAD=$(cat /proc/loadavg 2>/dev/null | awk "{print \$1,\$2,\$3}" || echo -)
|
|||
|
|
MEM=$(free -m 2>/dev/null | awk "/Mem:/ {printf \"%s/%s MB\", \$3, \$2}" || echo -)
|
|||
|
|
DISK=$(df -h / 2>/dev/null | awk "NR==2 {printf \"%s/%s (%s)\", \$3, \$2, \$5}" || echo -)
|
|||
|
|
echo "HOST=$HOST"
|
|||
|
|
echo "UP=$UP"
|
|||
|
|
echo "LOAD=$LOAD"
|
|||
|
|
echo "MEM=$MEM"
|
|||
|
|
echo "DISK=$DISK"
|
|||
|
|
' '''
|
|||
|
|
code, out, err = await run_ssh(node, cmd, timeout=12)
|
|||
|
|
if code != 0:
|
|||
|
|
raise RuntimeError(err or out or "SSH 执行失败")
|
|||
|
|
data = {}
|
|||
|
|
for line in out.splitlines():
|
|||
|
|
if "=" in line:
|
|||
|
|
k, v = line.split("=", 1)
|
|||
|
|
data[k] = v
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def collect_docker_list(node: Node):
|
|||
|
|
cmd = r'''bash -lc '
|
|||
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|||
|
|
echo "DOCKER_MISSING=1"
|
|||
|
|
exit 0
|
|||
|
|
fi
|
|||
|
|
docker ps -a --format "{{.Names}}|{{.Status}}|{{.Image}}" 2>/dev/null
|
|||
|
|
' '''
|
|||
|
|
code, out, err = await run_ssh(node, cmd, timeout=15)
|
|||
|
|
if code != 0:
|
|||
|
|
raise RuntimeError(err or out or "获取 Docker 列表失败")
|
|||
|
|
if "DOCKER_MISSING=1" in out:
|
|||
|
|
return None
|
|||
|
|
items = []
|
|||
|
|
for line in out.splitlines():
|
|||
|
|
if not line.strip() or "|" not in line:
|
|||
|
|
continue
|
|||
|
|
name, status, image = (line.split("|", 2) + ["", "", ""])[:3]
|
|||
|
|
items.append({"name": name, "status": status, "image": image})
|
|||
|
|
return items
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def docker_action(node: Node, container: str, action: str):
|
|||
|
|
return await run_ssh(node, f"docker {action} {container}", timeout=20)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def docker_logs(node: Node, container: str):
|
|||
|
|
return await run_ssh(node, f"docker logs --tail 40 {container}", timeout=20)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_node_state(node_id: int, fail_count: int, is_online: int, last_error: str):
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute(
|
|||
|
|
"INSERT INTO node_state(node_id,fail_count,is_online,last_error,last_change) VALUES(?,?,?,?,?) ON CONFLICT(node_id) DO UPDATE SET fail_count=excluded.fail_count,is_online=excluded.is_online,last_error=excluded.last_error,last_change=excluded.last_change",
|
|||
|
|
(node_id, fail_count, is_online, last_error[:500], int(time.time())),
|
|||
|
|
)
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_node_state(node_id: int):
|
|||
|
|
conn = db()
|
|||
|
|
row = conn.execute("SELECT fail_count,is_online,last_error,last_change FROM node_state WHERE node_id=?", (node_id,)).fetchone()
|
|||
|
|
conn.close()
|
|||
|
|
if row:
|
|||
|
|
return dict(row)
|
|||
|
|
return {"fail_count": 0, "is_online": 1, "last_error": "", "last_change": 0}
|
|||
|
|
|
|||
|
|
def update_node_group(node_id: int, group_name: str):
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute("UPDATE nodes SET group_name=? WHERE id=?", (group_name or 'default', node_id))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
def move_group_nodes(old_group: str, new_group: str = 'default'):
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute("UPDATE nodes SET group_name=? WHERE group_name=?", (new_group or 'default', old_group))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
def update_node_field(node_id: int, field: str, value):
|
|||
|
|
if field not in {"name", "host", "port", "user", "auth_type", "auth_value", "group_name", "remark", "expires_at", "monthly_price", "price_currency", "price_cycle"}:
|
|||
|
|
raise ValueError("bad field")
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute(f"UPDATE nodes SET {field}=? WHERE id=?", (value, node_id))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
def get_resource_state(node_id: int, metric: str):
|
|||
|
|
conn = db()
|
|||
|
|
row = conn.execute("SELECT alerted,last_value,updated_at FROM resource_state WHERE node_id=? AND metric=?", (node_id, metric)).fetchone()
|
|||
|
|
conn.close()
|
|||
|
|
if row:
|
|||
|
|
return dict(row)
|
|||
|
|
return {"alerted": 0, "last_value": 0, "updated_at": 0}
|
|||
|
|
|
|||
|
|
def set_resource_state(node_id: int, metric: str, alerted: int, last_value: float):
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute("INSERT INTO resource_state(node_id,metric,alerted,last_value,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(node_id,metric) DO UPDATE SET alerted=excluded.alerted,last_value=excluded.last_value,updated_at=excluded.updated_at", (node_id, metric, int(alerted), float(last_value), int(time.time())))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
def parse_resource_usage(info: dict):
|
|||
|
|
result = {"cpu": None, "mem": None, "disk": None}
|
|||
|
|
try:
|
|||
|
|
result["cpu"] = float((info.get("LOAD") or "-").split()[0])
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
mem = info.get("MEM", "")
|
|||
|
|
left = mem.split(" MB")[0]
|
|||
|
|
used, total = left.split("/")
|
|||
|
|
result["mem"] = round(float(used) / float(total) * 100, 1)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
disk = info.get("DISK", "")
|
|||
|
|
pct = disk.split("(")[-1].split("%)")[0].replace("%", "")
|
|||
|
|
result["disk"] = float(pct)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_expiry_state(node_id: int):
|
|||
|
|
conn = db()
|
|||
|
|
row = conn.execute("SELECT last_days_left,last_notified_at FROM expiry_state WHERE node_id=?", (node_id,)).fetchone()
|
|||
|
|
conn.close()
|
|||
|
|
if row:
|
|||
|
|
return dict(row)
|
|||
|
|
return {"last_days_left": 999999, "last_notified_at": 0}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_expiry_state(node_id: int, last_days_left: int):
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute("INSERT INTO expiry_state(node_id,last_days_left,last_notified_at) VALUES(?,?,?) ON CONFLICT(node_id) DO UPDATE SET last_days_left=excluded.last_days_left,last_notified_at=excluded.last_notified_at", (node_id, int(last_days_left), int(time.time())))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
def choose_group_buttons(node_id: int):
|
|||
|
|
buttons = []
|
|||
|
|
groups = [g for g, _ in list_groups()]
|
|||
|
|
for g in groups[:12]:
|
|||
|
|
buttons.append([InlineKeyboardButton(f"🗂 {g}", callback_data=f"node:setgroupto:{node_id}:{g}")])
|
|||
|
|
buttons.append([InlineKeyboardButton("✍️ 新建/输入新分组", callback_data=f"node:setgroupinput:{node_id}")])
|
|||
|
|
buttons.append([InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node_id}")])
|
|||
|
|
return InlineKeyboardMarkup(buttons)
|
|||
|
|
|
|||
|
|
def expiring_nodes(limit_days: int = 30):
|
|||
|
|
now = int(time.time())
|
|||
|
|
end = now + limit_days * 86400
|
|||
|
|
conn = db()
|
|||
|
|
rows = conn.execute(
|
|||
|
|
"SELECT id,name,group_name,host,port,user,COALESCE(auth_type,'password') auth_type, COALESCE(NULLIF(auth_value,''),password) auth_value, enabled, created_at, COALESCE(remark,'') remark, COALESCE(expires_at,0) expires_at, COALESCE(monthly_price,0) monthly_price, COALESCE(price_currency,'U') price_currency, COALESCE(price_cycle,'month') price_cycle FROM nodes WHERE enabled=1 AND expires_at>0 AND expires_at<=? ORDER BY expires_at ASC, id DESC",
|
|||
|
|
(end,),
|
|||
|
|
).fetchall()
|
|||
|
|
conn.close()
|
|||
|
|
return [Node(**dict(r)) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def monthly_cost_total():
|
|||
|
|
conn = db()
|
|||
|
|
row = conn.execute("SELECT COALESCE(SUM(monthly_price),0) s FROM nodes WHERE enabled=1").fetchone()
|
|||
|
|
conn.close()
|
|||
|
|
return float(row[0] or 0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def monthly_cost_by_group():
|
|||
|
|
conn = db()
|
|||
|
|
rows = conn.execute("SELECT group_name, COUNT(*) c, COALESCE(SUM(monthly_price),0) s FROM nodes WHERE enabled=1 GROUP BY group_name ORDER BY s DESC, group_name ASC").fetchall()
|
|||
|
|
conn.close()
|
|||
|
|
return [(r[0], int(r[1]), float(r[2] or 0)) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_safe_readonly_command(command: str) -> bool:
|
|||
|
|
c = (command or "").strip().lower()
|
|||
|
|
if not c:
|
|||
|
|
return False
|
|||
|
|
if any(tok in c for tok in BLOCKED_CUSTOM_TOKENS):
|
|||
|
|
return False
|
|||
|
|
return c.startswith(READONLY_CUSTOM_PREFIXES)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cycle_label(cycle: str) -> str:
|
|||
|
|
return {"month": "月付", "quarter": "季付", "year": "年付"}.get((cycle or "month"), "月付")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def format_price(node: Node) -> str:
|
|||
|
|
amount = float(node.monthly_price or 0)
|
|||
|
|
if amount <= 0:
|
|||
|
|
return "未设置"
|
|||
|
|
currency = (node.price_currency or "U").upper()
|
|||
|
|
return f"{amount:.2f} {currency}/{cycle_label(node.price_cycle)}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def annual_cost_total():
|
|||
|
|
return round(monthly_cost_total() * 12, 2)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def top_cost_nodes(limit: int = 10):
|
|||
|
|
conn = db()
|
|||
|
|
rows = conn.execute(
|
|||
|
|
"SELECT id,name,group_name,host,port,user,COALESCE(auth_type,'password') auth_type, COALESCE(NULLIF(auth_value,''),password) auth_value, enabled, created_at, COALESCE(remark,'') remark, COALESCE(expires_at,0) expires_at, COALESCE(monthly_price,0) monthly_price, COALESCE(price_currency,'U') price_currency, COALESCE(price_cycle,'month') price_cycle FROM nodes WHERE enabled=1 ORDER BY monthly_price DESC, expires_at ASC, id DESC LIMIT ?",
|
|||
|
|
(limit,),
|
|||
|
|
).fetchall()
|
|||
|
|
conn.close()
|
|||
|
|
return [Node(**dict(r)) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def notify_admins(app: Application, text: str):
|
|||
|
|
for uid in ADMIN_IDS:
|
|||
|
|
try:
|
|||
|
|
await app.bot.send_message(chat_id=uid, text=text)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def monitor_once(app: Application):
|
|||
|
|
nodes = all_nodes(enabled_only=True)
|
|||
|
|
threshold = max(1, int(get_setting("fail_threshold")))
|
|||
|
|
expire_days = max(0, int(get_setting("expire_remind_days") or "0"))
|
|||
|
|
now = int(time.time())
|
|||
|
|
for node in nodes:
|
|||
|
|
state = get_node_state(node.id)
|
|||
|
|
try:
|
|||
|
|
code, out, err = await run_ssh(node, "echo ok", timeout=8)
|
|||
|
|
if code == 0:
|
|||
|
|
if state["is_online"] == 0 and get_setting("notify_recovery") == "1":
|
|||
|
|
await notify_admins(app, f"✅ 节点恢复\n{node.name} ({node.host})\n分组: {node.group_name}")
|
|||
|
|
set_node_state(node.id, 0, 1, "")
|
|||
|
|
else:
|
|||
|
|
fail_count = state["fail_count"] + 1
|
|||
|
|
is_online = 0 if fail_count >= threshold else 1
|
|||
|
|
if is_online == 0 and state["is_online"] == 1 and get_setting("notify_offline") == "1":
|
|||
|
|
await notify_admins(app, f"🚨 节点掉线\n{node.name} ({node.host})\n分组: {node.group_name}\n错误: {(err or out or 'SSH failed')[:200]}")
|
|||
|
|
set_node_state(node.id, fail_count, is_online, err or out or "SSH failed")
|
|||
|
|
except Exception as e:
|
|||
|
|
fail_count = state["fail_count"] + 1
|
|||
|
|
is_online = 0 if fail_count >= threshold else 1
|
|||
|
|
if is_online == 0 and state["is_online"] == 1 and get_setting("notify_offline") == "1":
|
|||
|
|
await notify_admins(app, f"🚨 节点掉线\n{node.name} ({node.host})\n分组: {node.group_name}\n错误: {str(e)[:200]}")
|
|||
|
|
set_node_state(node.id, fail_count, is_online, str(e))
|
|||
|
|
|
|||
|
|
if int(node.expires_at or 0) > 0 and expire_days > 0:
|
|||
|
|
days_left = math.floor((int(node.expires_at) - now) / 86400)
|
|||
|
|
exp_state = get_expiry_state(node.id)
|
|||
|
|
remind_points = sorted({expire_days, 7, 3, 0}, reverse=True)
|
|||
|
|
should_notify = False
|
|||
|
|
title = None
|
|||
|
|
if days_left < 0:
|
|||
|
|
last_at = int(exp_state.get("last_notified_at") or 0)
|
|||
|
|
if last_at <= 0 or (now - last_at) >= 86400:
|
|||
|
|
should_notify = True
|
|||
|
|
title = "⛔ 节点已过期"
|
|||
|
|
else:
|
|||
|
|
for point in remind_points:
|
|||
|
|
if point < 0:
|
|||
|
|
continue
|
|||
|
|
if days_left <= point and int(exp_state.get("last_days_left", 999999)) > point:
|
|||
|
|
should_notify = True
|
|||
|
|
title = "⏰ 节点即将到期" if days_left > 0 else "⚠️ 节点今天到期"
|
|||
|
|
break
|
|||
|
|
if should_notify:
|
|||
|
|
await notify_admins(app, f"{title}\n{node.name} ({node.host})\n分组: {node.group_name}\n到期: {format_expiry(node.expires_at)}\n剩余: {days_left_text(node.expires_at)}\n账单: {format_price(node)}")
|
|||
|
|
set_expiry_state(node.id, days_left)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def monitor_loop(app: Application):
|
|||
|
|
await asyncio.sleep(8)
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
await monitor_once(app)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
await asyncio.sleep(max(15, int(get_setting("check_interval"))))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
total = len(all_nodes())
|
|||
|
|
groups = len(list_groups())
|
|||
|
|
text = (
|
|||
|
|
f"<b>{BOT_NAME}</b>\n"
|
|||
|
|
f"多 VPS 中控台\n\n"
|
|||
|
|
f"节点数:<code>{total}</code>\n"
|
|||
|
|
f"分组数:<code>{groups}</code>\n"
|
|||
|
|
f"告警:<code>{'开' if get_setting('notify_offline') == '1' else '关'}</code>"
|
|||
|
|
)
|
|||
|
|
if update.message:
|
|||
|
|
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=ReplyKeyboardRemove())
|
|||
|
|
await update.message.reply_text("主菜单", reply_markup=main_menu())
|
|||
|
|
else:
|
|||
|
|
await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=main_menu())
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_nodes(query, group_name: Optional[str] = None):
|
|||
|
|
rows = nodes_by_group(group_name) if group_name else all_nodes()
|
|||
|
|
title = f"分组:{group_name}" if group_name else "节点列表(按分组)"
|
|||
|
|
if not rows:
|
|||
|
|
await query.edit_message_text(
|
|||
|
|
f"{title}\n\n还没有节点。",
|
|||
|
|
reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("➕ 添加节点", callback_data="nodes:add")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
buttons = []
|
|||
|
|
last_group = None
|
|||
|
|
for r in rows[:50]:
|
|||
|
|
if r.group_name != last_group:
|
|||
|
|
buttons.append([InlineKeyboardButton(f"🗂 {r.group_name}", callback_data=f"batch:group:{r.group_name}")])
|
|||
|
|
last_group = r.group_name
|
|||
|
|
buttons.append([InlineKeyboardButton(f" └ 🖥️ {r.name} ({r.host})", callback_data=f"node:view:{r.id}")])
|
|||
|
|
buttons.append([InlineKeyboardButton("🧰 批量操作", callback_data="batch:menu")])
|
|||
|
|
buttons.append([InlineKeyboardButton("🏠 首页", callback_data="home")])
|
|||
|
|
await query.edit_message_text(title, reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_groups(query):
|
|||
|
|
groups = list_groups()
|
|||
|
|
if not groups:
|
|||
|
|
await query.edit_message_text("还没有任何分组。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🏠 首页", callback_data="home")]]))
|
|||
|
|
return
|
|||
|
|
buttons = []
|
|||
|
|
for g, c in groups:
|
|||
|
|
buttons.append([
|
|||
|
|
InlineKeyboardButton(f"🗂 {g} ({c})", callback_data=f"groups:view:{g}"),
|
|||
|
|
InlineKeyboardButton("🧰 批量", callback_data=f"batch:group:{g}"),
|
|||
|
|
InlineKeyboardButton("🗑 删除", callback_data=f"groups:delask:{g}"),
|
|||
|
|
])
|
|||
|
|
buttons.append([InlineKeyboardButton("🌐 全部节点批量", callback_data="batch:scope:all")])
|
|||
|
|
buttons.append([InlineKeyboardButton("🏠 首页", callback_data="home")])
|
|||
|
|
await query.edit_message_text("分组中心", reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def begin_add(query, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
context.user_data["add_node"] = {}
|
|||
|
|
context.user_data["add_step"] = ADD_FLOW[0]
|
|||
|
|
await query.edit_message_text(
|
|||
|
|
"开始添加节点\n\n" + add_prompts()[ADD_FLOW[0]],
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🏠 首页", callback_data="home")]]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def prompt_auth_choice(message, data):
|
|||
|
|
summary = (
|
|||
|
|
f"名称:<code>{esc(data['name'])}</code>\n"
|
|||
|
|
f"分组:<code>{esc(data['group_name'])}</code>\n"
|
|||
|
|
f"地址:<code>{esc(data['host'])}:{data['port']}</code>\n"
|
|||
|
|
f"用户:<code>{esc(data['user'])}</code>\n\n"
|
|||
|
|
f"现在选 SSH 认证方式:"
|
|||
|
|
)
|
|||
|
|
await message.reply_text(summary, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("🔐 默认密码", callback_data="auth:default_password"), InlineKeyboardButton("🗝️ 默认Key", callback_data="auth:default_key")],
|
|||
|
|
[InlineKeyboardButton("✍️ 自定义密码", callback_data="auth:custom_password"), InlineKeyboardButton("📁 自定义Key", callback_data="auth:custom_key")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def save_node_from_context(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
data = context.user_data.get("add_node", {})
|
|||
|
|
d = defaults_dict()
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute(
|
|||
|
|
"INSERT INTO nodes(name,group_name,host,port,user,password,auth_type,auth_value,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|||
|
|
(data["name"], data["group_name"], data["host"], data["port"], data["user"], data.get("auth_value", ""), data["auth_type"], data["auth_value"], 1, int(time.time())),
|
|||
|
|
)
|
|||
|
|
conn.commit()
|
|||
|
|
node_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|||
|
|
conn.close()
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
context.user_data.pop("add_node", None)
|
|||
|
|
auth_text = "默认密码" if data["auth_type"] == "password" and data["auth_value"] == d["password"] else ("默认Key" if data["auth_type"] == "key" and data["auth_value"] == d["key_path"] else ("自定义密码" if data["auth_type"] == "password" else "自定义Key"))
|
|||
|
|
await update.effective_chat.send_message(
|
|||
|
|
f"✅ 节点已添加\n\n名称:{esc(data['name'])}\n分组:{esc(data['group_name'])}\n地址:{esc(data['host'])}:{data['port']}\n用户:{esc(data['user'])}\n认证:{esc(auth_text)}",
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("🔌 测试连接", callback_data=f"node:test:{node_id}"), InlineKeyboardButton("📄 查看节点", callback_data=f"node:view:{node_id}")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_node(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
state = get_node_state(node.id)
|
|||
|
|
status = "在线" if state["is_online"] else f"离线({state['fail_count']})"
|
|||
|
|
price = format_price(node)
|
|||
|
|
expiry = format_expiry(node.expires_at)
|
|||
|
|
expiry_left = days_left_text(node.expires_at) if int(node.expires_at or 0) > 0 else "未设置"
|
|||
|
|
text = (
|
|||
|
|
f"<b>{esc(node.name)}</b>\n"
|
|||
|
|
f"分组:<code>{esc(node.group_name)}</code>\n"
|
|||
|
|
f"地址:<code>{esc(node.host)}:{node.port}</code>\n"
|
|||
|
|
f"用户:<code>{esc(node.user)}</code>\n"
|
|||
|
|
f"认证:<code>{esc(auth_label(node))}</code>\n"
|
|||
|
|
f"状态:<code>{esc(status)}</code>\n"
|
|||
|
|
f"账单:<code>{esc(price)}</code>\n"
|
|||
|
|
f"到期:<code>{esc(expiry)}</code>\n"
|
|||
|
|
f"剩余:<code>{esc(expiry_left)}</code>"
|
|||
|
|
)
|
|||
|
|
if node.remark:
|
|||
|
|
text += f"\n备注:<code>{esc(node.remark)}</code>"
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("🔌 测试", callback_data=f"node:test:{node.id}"), InlineKeyboardButton("📊 概览", callback_data=f"node:summary:{node.id}")],
|
|||
|
|
[InlineKeyboardButton("🐳 Docker", callback_data=f"node:docker:{node.id}"), InlineKeyboardButton("💰 账单", callback_data=f"node:billing:{node.id}")],
|
|||
|
|
[InlineKeyboardButton("✏️ 编辑", callback_data=f"node:edit:{node.id}"), InlineKeyboardButton("🗂 改分组", callback_data=f"node:setgroup:{node.id}")],
|
|||
|
|
[InlineKeyboardButton("🧰 批量同组", callback_data=f"batch:group:{node.group_name}"), InlineKeyboardButton("🗑 删除", callback_data=f"node:delask:{node.id}")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def node_test(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(f"正在测试 <b>{esc(node.name)}</b>…", parse_mode=ParseMode.HTML)
|
|||
|
|
try:
|
|||
|
|
code, out, err = await run_ssh(node, "echo ok && hostname && whoami", timeout=10)
|
|||
|
|
if code == 0:
|
|||
|
|
lines = [x for x in out.splitlines() if x.strip()]
|
|||
|
|
host = lines[1] if len(lines) > 1 else "-"
|
|||
|
|
user = lines[2] if len(lines) > 2 else node.user
|
|||
|
|
text = f"✅ 连接成功\n\n节点:<b>{esc(node.name)}</b>\n主机:<code>{esc(host)}</code>\n用户:<code>{esc(user)}</code>"
|
|||
|
|
else:
|
|||
|
|
text = f"❌ 连接失败\n\n<code>{esc(err or out or '未知错误')}</code>"
|
|||
|
|
except Exception as e:
|
|||
|
|
text = f"❌ 连接失败\n\n<code>{esc(str(e))}</code>"
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node_id}")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def node_summary(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(f"正在拉取 <b>{esc(node.name)}</b> 概览…", parse_mode=ParseMode.HTML)
|
|||
|
|
try:
|
|||
|
|
info = await collect_node_summary(node)
|
|||
|
|
text = (
|
|||
|
|
f"<b>{esc(node.name)}</b>\n\n"
|
|||
|
|
f"主机名:<code>{esc(info.get('HOST','-'))}</code>\n"
|
|||
|
|
f"运行时间:<code>{esc(info.get('UP','-'))}</code>\n"
|
|||
|
|
f"负载:<code>{esc(info.get('LOAD','-'))}</code>\n"
|
|||
|
|
f"内存:<code>{esc(info.get('MEM','-'))}</code>\n"
|
|||
|
|
f"磁盘:<code>{esc(info.get('DISK','-'))}</code>"
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
text = f"❌ 拉取失败\n\n<code>{esc(str(e))}</code>"
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node_id}")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def nodes_overview(query):
|
|||
|
|
nodes = all_nodes()
|
|||
|
|
if not nodes:
|
|||
|
|
await query.edit_message_text("还没有节点。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("➕ 添加节点", callback_data="nodes:add")], [InlineKeyboardButton("🏠 首页", callback_data="home")]]))
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text("正在拉取全部节点概览…")
|
|||
|
|
results = await asyncio.gather(*[collect_node_summary(n) for n in nodes], return_exceptions=True)
|
|||
|
|
lines = ["<b>批量总览</b>"]
|
|||
|
|
ok = 0
|
|||
|
|
for node, result in zip(nodes, results):
|
|||
|
|
if isinstance(result, Exception):
|
|||
|
|
lines.append(f"\n🔴 <b>{esc(node.name)}</b> [{esc(node.group_name)}]\n<code>{esc(str(result))[:120]}</code>")
|
|||
|
|
else:
|
|||
|
|
ok += 1
|
|||
|
|
lines.append(f"\n🟢 <b>{esc(node.name)}</b> [{esc(node.group_name)}]\n负载 <code>{esc(result.get('LOAD','-'))}</code> | 内存 <code>{esc(result.get('MEM','-'))}</code>\n磁盘 <code>{esc(result.get('DISK','-'))}</code>")
|
|||
|
|
lines.insert(1, f"在线/可连:<code>{ok}/{len(nodes)}</code>\n")
|
|||
|
|
await query.edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("🔄 刷新", callback_data="nodes:overview")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_auth_settings(query):
|
|||
|
|
d = defaults_dict()
|
|||
|
|
text = (
|
|||
|
|
"<b>默认认证设置</b>\n\n"
|
|||
|
|
f"默认用户:<code>{esc(d['user'])}</code>\n"
|
|||
|
|
f"默认端口:<code>{d['port']}</code>\n"
|
|||
|
|
f"默认密码:<code>{'已设置' if d['password'] else '未设置'}</code>\n"
|
|||
|
|
f"默认Key:<code>{esc(d['key_path'])}</code>"
|
|||
|
|
)
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("改默认密码", callback_data="settings:set_default_password"), InlineKeyboardButton("改默认Key", callback_data="settings:set_default_key")],
|
|||
|
|
[InlineKeyboardButton("改默认用户", callback_data="settings:set_default_user"), InlineKeyboardButton("改默认端口", callback_data="settings:set_default_port")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_alert_settings(query):
|
|||
|
|
text = "<b>告警设置</b>\n\n" + alert_settings_text()
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("改巡检间隔", callback_data="settings:set_check_interval"), InlineKeyboardButton("改失败阈值", callback_data="settings:set_fail_threshold")],
|
|||
|
|
[InlineKeyboardButton("CPU 阈值", callback_data="settings:set_cpu_alert_threshold"), InlineKeyboardButton("内存阈值", callback_data="settings:set_mem_alert_threshold")],
|
|||
|
|
[InlineKeyboardButton("磁盘阈值", callback_data="settings:set_disk_alert_threshold"), InlineKeyboardButton("到期提醒天数", callback_data="settings:set_expire_remind_days")],
|
|||
|
|
[InlineKeyboardButton("切换掉线通知", callback_data="settings:toggle_notify_offline"), InlineKeyboardButton("切换恢复通知", callback_data="settings:toggle_notify_recovery")],
|
|||
|
|
[InlineKeyboardButton("立即巡检一次", callback_data="monitor:run")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_billing_summary(query):
|
|||
|
|
nodes = all_nodes()
|
|||
|
|
priced = [n for n in nodes if float(n.monthly_price or 0) > 0]
|
|||
|
|
upcoming = expiring_nodes(30)
|
|||
|
|
group_rows = monthly_cost_by_group()
|
|||
|
|
top_nodes = [n for n in top_cost_nodes(8) if float(n.monthly_price or 0) > 0]
|
|||
|
|
lines = ["<b>账单总览</b>"]
|
|||
|
|
lines.append(f"节点:<code>{len(nodes)}</code> | 已填账单:<code>{len(priced)}</code>")
|
|||
|
|
lines.append("总成本:<code>多币种,按节点明细查看</code>")
|
|||
|
|
if group_rows:
|
|||
|
|
lines.append("\n<b>分组覆盖</b>")
|
|||
|
|
for g, c, s in group_rows[:8]:
|
|||
|
|
lines.append(f"• <b>{esc(g)}</b>:<code>{c} 台</code>")
|
|||
|
|
if top_nodes:
|
|||
|
|
lines.append("\n<b>账单节点</b>")
|
|||
|
|
for n in top_nodes[:5]:
|
|||
|
|
lines.append(f"• <b>{esc(n.name)}</b> [{esc(n.group_name)}]:<code>{esc(format_price(n))}</code>")
|
|||
|
|
if upcoming:
|
|||
|
|
lines.append("\n<b>30 天内到期</b>")
|
|||
|
|
for n in upcoming[:5]:
|
|||
|
|
lines.append(f"• <b>{esc(n.name)}</b>:<code>{esc(format_expiry(n.expires_at))}</code>({esc(days_left_text(n.expires_at))})")
|
|||
|
|
else:
|
|||
|
|
lines.append("\n30 天内没有到期节点。")
|
|||
|
|
await query.edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⏰ 即将到期", callback_data="billing:expiring"), InlineKeyboardButton("🔄 刷新", callback_data="billing:summary")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_expiring_nodes(query, limit_days: int = 30):
|
|||
|
|
rows = expiring_nodes(limit_days)
|
|||
|
|
if not rows:
|
|||
|
|
await query.edit_message_text(f"未来 {limit_days} 天没有到期节点。", reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("💰 返回账单", callback_data="billing:summary")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
return
|
|||
|
|
lines = [f"<b>{limit_days} 天内到期</b>"]
|
|||
|
|
for n in rows[:30]:
|
|||
|
|
price = f" | {format_price(n)}" if float(n.monthly_price or 0) > 0 else ""
|
|||
|
|
remark = f"\n<code>{esc(n.remark)}</code>" if n.remark else ""
|
|||
|
|
lines.append(f"\n• <b>{esc(n.name)}</b> [{esc(n.group_name)}]\n<code>{esc(format_expiry(n.expires_at))}</code>({esc(days_left_text(n.expires_at))}){price}{remark}")
|
|||
|
|
await query.edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("7 天内", callback_data="billing:expiring:7"), InlineKeyboardButton("30 天内", callback_data="billing:expiring:30")],
|
|||
|
|
[InlineKeyboardButton("💰 返回账单", callback_data="billing:summary")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_batch_menu(query):
|
|||
|
|
buttons = [
|
|||
|
|
[InlineKeyboardButton("🖥 预置只读命令", callback_data="batch:readonly")],
|
|||
|
|
[InlineKeyboardButton("⌨️ 自定义只读命令", callback_data="batchcustom:menu")],
|
|||
|
|
[InlineKeyboardButton("⚙️ 批量服务管理", callback_data="batchsvc:menu")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]
|
|||
|
|
await query.edit_message_text("选择批量操作类型:", reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_batch_actions(query, scope: str):
|
|||
|
|
buttons = [[InlineKeyboardButton(title, callback_data=f"batch:run:{scope}:{action}")] for action, (title, _) in READ_ONLY_BATCH.items()]
|
|||
|
|
buttons.append([InlineKeyboardButton("⬅️ 返回", callback_data="batch:menu")])
|
|||
|
|
await query.edit_message_text(f"范围:<code>{esc(scope)}</code>\n选择一个只读批量动作:", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def run_batch_action(query, scope: str, action: str):
|
|||
|
|
nodes = all_nodes() if scope == "all" else nodes_by_group(scope)
|
|||
|
|
if not nodes:
|
|||
|
|
await query.edit_message_text("这个范围下没有节点。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回", callback_data="batch:menu")]]))
|
|||
|
|
return
|
|||
|
|
title, command = READ_ONLY_BATCH[action]
|
|||
|
|
await query.edit_message_text(f"正在执行 {title}…")
|
|||
|
|
results = await asyncio.gather(*[run_ssh(n, command, timeout=15) for n in nodes], return_exceptions=True)
|
|||
|
|
lines = [f"<b>{esc(title)}</b> | 范围 <code>{esc(scope)}</code>"]
|
|||
|
|
for node, result in zip(nodes, results):
|
|||
|
|
if isinstance(result, Exception):
|
|||
|
|
lines.append(f"\n🔴 <b>{esc(node.name)}</b>\n<code>{esc(str(result))[:250]}</code>")
|
|||
|
|
else:
|
|||
|
|
code, out, err = result
|
|||
|
|
body = (out or err or "(空输出)")[:350]
|
|||
|
|
status = "🟢" if code == 0 else "🔴"
|
|||
|
|
lines.append(f"\n{status} <b>{esc(node.name)}</b>\n<pre>{esc(body)}</pre>")
|
|||
|
|
await query.edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回批量菜单", callback_data="batch:menu")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_batch_custom_scope(query):
|
|||
|
|
groups = list_groups()
|
|||
|
|
buttons = [[InlineKeyboardButton("全部节点", callback_data="batchcustom:scope:all")]]
|
|||
|
|
for g, c in groups[:20]:
|
|||
|
|
buttons.append([InlineKeyboardButton(f"分组 {g} ({c})", callback_data=f"batchcustom:scope:{g}")])
|
|||
|
|
buttons.append([InlineKeyboardButton("⬅️ 返回批量菜单", callback_data="batch:menu")])
|
|||
|
|
await query.edit_message_text("选择自定义只读命令范围:", reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_batch_service_scope(query):
|
|||
|
|
groups = list_groups()
|
|||
|
|
buttons = [[InlineKeyboardButton("全部节点", callback_data="batchsvc:scope:all")]]
|
|||
|
|
for g, c in groups[:20]:
|
|||
|
|
buttons.append([InlineKeyboardButton(f"分组 {g} ({c})", callback_data=f"batchsvc:scope:{g}")])
|
|||
|
|
buttons.append([InlineKeyboardButton("⬅️ 返回批量菜单", callback_data="batch:menu")])
|
|||
|
|
await query.edit_message_text("选择批量服务管理范围:", reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_batch_service_actions(query, scope: str):
|
|||
|
|
buttons = [
|
|||
|
|
[InlineKeyboardButton("查看状态", callback_data=f"batchsvc:ask:{scope}:status"), InlineKeyboardButton("重启服务", callback_data=f"batchsvc:ask:{scope}:restart")],
|
|||
|
|
[InlineKeyboardButton("启动服务", callback_data=f"batchsvc:ask:{scope}:start"), InlineKeyboardButton("停止服务", callback_data=f"batchsvc:ask:{scope}:stop")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回范围选择", callback_data="batchsvc:menu")],
|
|||
|
|
]
|
|||
|
|
await query.edit_message_text(f"范围:<code>{esc(scope)}</code>\n选择服务动作:", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def run_batch_service_action(query, scope: str, service_name: str, action: str):
|
|||
|
|
nodes = all_nodes() if scope == "all" else nodes_by_group(scope)
|
|||
|
|
if not nodes:
|
|||
|
|
await query.edit_message_text("这个范围下没有节点。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回", callback_data="batchsvc:menu")]]))
|
|||
|
|
return
|
|||
|
|
service_name = service_name.strip()
|
|||
|
|
action_label = SERVICE_ACTIONS.get(action, action)
|
|||
|
|
if action == "status":
|
|||
|
|
command = f"systemctl status --no-pager --full {service_name} 2>&1 || systemctl is-active {service_name} 2>&1 || true"
|
|||
|
|
timeout = 20
|
|||
|
|
else:
|
|||
|
|
command = f"systemctl {action} {service_name} 2>&1"
|
|||
|
|
timeout = 25
|
|||
|
|
await query.edit_message_text(f"正在对 <code>{esc(scope)}</code> 执行 <b>{esc(action_label)}</b>:<code>{esc(service_name)}</code>", parse_mode=ParseMode.HTML)
|
|||
|
|
results = await asyncio.gather(*[run_ssh(n, command, timeout=timeout) for n in nodes], return_exceptions=True)
|
|||
|
|
lines = [f"<b>批量服务 {esc(action_label)}</b> | 范围 <code>{esc(scope)}</code> | 服务 <code>{esc(service_name)}</code>"]
|
|||
|
|
for node, result in zip(nodes, results):
|
|||
|
|
if isinstance(result, Exception):
|
|||
|
|
lines.append(f"\n🔴 <b>{esc(node.name)}</b>\n<code>{esc(str(result))[:220]}</code>")
|
|||
|
|
else:
|
|||
|
|
code, out, err = result
|
|||
|
|
body = (out or err or "(空输出)")[:320]
|
|||
|
|
status = "🟢" if code == 0 else "🔴"
|
|||
|
|
lines.append(f"\n{status} <b>{esc(node.name)}</b>\n<pre>{esc(body)}</pre>")
|
|||
|
|
await query.edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回服务菜单", callback_data=f"batchsvc:scope:{scope}")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回批量菜单", callback_data="batch:menu")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_docker(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(f"正在拉取 <b>{esc(node.name)}</b> 的 Docker 列表…", parse_mode=ParseMode.HTML)
|
|||
|
|
try:
|
|||
|
|
items = await collect_docker_list(node)
|
|||
|
|
if items is None:
|
|||
|
|
await query.edit_message_text("这台机器没装 Docker。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node.id}")]]))
|
|||
|
|
return
|
|||
|
|
if not items:
|
|||
|
|
await query.edit_message_text("这台机器当前没有容器。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node.id}")]]))
|
|||
|
|
return
|
|||
|
|
lines = [f"<b>{esc(node.name)}</b> Docker"]
|
|||
|
|
buttons = []
|
|||
|
|
for item in items[:12]:
|
|||
|
|
lines.append(f"\n• <b>{esc(item['name'])}</b>\n<code>{esc(item['status'])}</code>\n<code>{esc(item['image'])}</code>")
|
|||
|
|
buttons.append([InlineKeyboardButton(f"⚙️ {item['name']}", callback_data=f"docker:menu:{node.id}:{item['name']}")])
|
|||
|
|
buttons.append([InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node.id}")])
|
|||
|
|
await query.edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
except Exception as e:
|
|||
|
|
await query.edit_message_text(f"❌ 获取 Docker 列表失败\n\n<code>{esc(str(e))}</code>", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node.id}")]]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_docker_menu(query, node_id: int, container: str):
|
|||
|
|
await query.edit_message_text(f"容器:<b>{esc(container)}</b>", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("▶️ 启动", callback_data=f"docker:act:{node_id}:{container}:start"), InlineKeyboardButton("⏹️ 停止", callback_data=f"docker:act:{node_id}:{container}:stop")],
|
|||
|
|
[InlineKeyboardButton("🔄 重启", callback_data=f"docker:act:{node_id}:{container}:restart")],
|
|||
|
|
[InlineKeyboardButton("📜 日志", callback_data=f"docker:logs:{node_id}:{container}")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回 Docker", callback_data=f"node:docker:{node_id}")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def do_docker_action(query, node_id: int, container: str, action: str):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(f"正在执行 {action}…")
|
|||
|
|
try:
|
|||
|
|
code, out, err = await docker_action(node, container, action)
|
|||
|
|
text = f"{'✅' if code == 0 else '❌'} {action}\n\n<code>{esc(out or err or '(空输出)')[:3500]}</code>"
|
|||
|
|
except Exception as e:
|
|||
|
|
text = f"❌ 执行失败\n\n<code>{esc(str(e))}</code>"
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回容器菜单", callback_data=f"docker:menu:{node_id}:{container}")]]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_docker_logs(query, node_id: int, container: str):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text("正在读取日志…")
|
|||
|
|
try:
|
|||
|
|
code, out, err = await docker_logs(node, container)
|
|||
|
|
text = f"<b>{esc(container)}</b> 日志\n\n<pre>{esc((out or err or '(空日志)')[:3500])}</pre>"
|
|||
|
|
except Exception as e:
|
|||
|
|
text = f"❌ 日志获取失败\n\n<code>{esc(str(e))}</code>"
|
|||
|
|
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回容器菜单", callback_data=f"docker:menu:{node_id}:{container}")]]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_billing_menu(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
price = format_price(node)
|
|||
|
|
expiry = format_expiry(node.expires_at)
|
|||
|
|
left = days_left_text(node.expires_at) if int(node.expires_at or 0) > 0 else "未设置"
|
|||
|
|
remark = node.remark or "未设置"
|
|||
|
|
await query.edit_message_text(
|
|||
|
|
f"<b>账单信息</b>\n\n节点:<b>{esc(node.name)}</b>\n备注:<code>{esc(remark)}</code>\n账单:<code>{esc(price)}</code>\n到期:<code>{esc(expiry)}</code>\n剩余:<code>{esc(left)}</code>",
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("改备注", callback_data=f"node:editfield:{node_id}:remark"), InlineKeyboardButton("改金额", callback_data=f"node:editfield:{node_id}:monthly_price")],
|
|||
|
|
[InlineKeyboardButton("周期", callback_data=f"node:cycle:{node_id}"), InlineKeyboardButton("货币", callback_data=f"node:currency:{node_id}")],
|
|||
|
|
[InlineKeyboardButton("改到期日", callback_data=f"node:editfield:{node_id}:expires_at")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node_id}")],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_cycle_menu(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(
|
|||
|
|
f"<b>选择账单周期</b>\n\n节点:<b>{esc(node.name)}</b>\n当前:<code>{esc(cycle_label(node.price_cycle))}</code>",
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("月付", callback_data=f"node:setcycle:{node_id}:month"), InlineKeyboardButton("季付", callback_data=f"node:setcycle:{node_id}:quarter")],
|
|||
|
|
[InlineKeyboardButton("年付", callback_data=f"node:setcycle:{node_id}:year")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回账单", callback_data=f"node:billing:{node_id}")],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_currency_menu(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
current = (node.price_currency or "U").upper()
|
|||
|
|
await query.edit_message_text(
|
|||
|
|
f"<b>选择货币单位</b>\n\n节点:<b>{esc(node.name)}</b>\n当前:<code>{esc(current)}</code>",
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("U", callback_data=f"node:setcurrency:{node_id}:U"), InlineKeyboardButton("CNY", callback_data=f"node:setcurrency:{node_id}:CNY")],
|
|||
|
|
[InlineKeyboardButton("USD", callback_data=f"node:setcurrency:{node_id}:USD"), InlineKeyboardButton("HKD", callback_data=f"node:setcurrency:{node_id}:HKD")],
|
|||
|
|
[InlineKeyboardButton("TWD", callback_data=f"node:setcurrency:{node_id}:TWD")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回账单", callback_data=f"node:billing:{node_id}")],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def show_edit_menu(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(
|
|||
|
|
f"编辑节点:<b>{esc(node.name)}</b>",
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("改名称", callback_data=f"node:editfield:{node_id}:name"), InlineKeyboardButton("改地址", callback_data=f"node:editfield:{node_id}:host")],
|
|||
|
|
[InlineKeyboardButton("改端口", callback_data=f"node:editfield:{node_id}:port"), InlineKeyboardButton("改用户", callback_data=f"node:editfield:{node_id}:user")],
|
|||
|
|
[InlineKeyboardButton("改密码", callback_data=f"node:editauth:{node_id}:password"), InlineKeyboardButton("改Key", callback_data=f"node:editauth:{node_id}:key")],
|
|||
|
|
[InlineKeyboardButton("💰 账单信息", callback_data=f"node:billing:{node_id}")],
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node_id}")],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def ask_delete(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await query.edit_message_text(f"确认删除 <b>{esc(node.name)}</b> 吗?", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⚠️ 确认删除", callback_data=f"node:del:{node.id}")],
|
|||
|
|
[InlineKeyboardButton("取消", callback_data=f"node:view:{node.id}")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def delete_node(query, node_id: int):
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await query.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
conn = db()
|
|||
|
|
conn.execute("DELETE FROM node_state WHERE node_id=?", (node_id,))
|
|||
|
|
conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
|
|||
|
|
conn.commit()
|
|||
|
|
conn.close()
|
|||
|
|
await query.edit_message_text(f"✅ 已删除 {esc(node.name)}", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🏠 首页", callback_data="home")]]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def on_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
step = context.user_data.get("add_step")
|
|||
|
|
if not step:
|
|||
|
|
return
|
|||
|
|
value = (update.message.text or "").strip()
|
|||
|
|
prompts = add_prompts()
|
|||
|
|
defaults = defaults_dict()
|
|||
|
|
data = context.user_data.get("add_node", {})
|
|||
|
|
|
|||
|
|
if step == "port":
|
|||
|
|
value = value or str(defaults["port"])
|
|||
|
|
if not value.isdigit():
|
|||
|
|
await update.message.reply_text("端口必须是数字,重新发。")
|
|||
|
|
return
|
|||
|
|
data["port"] = int(value)
|
|||
|
|
elif step == "user":
|
|||
|
|
data["user"] = value or defaults["user"]
|
|||
|
|
elif step == "group_name":
|
|||
|
|
data["group_name"] = value or "default"
|
|||
|
|
elif step == "custom_password":
|
|||
|
|
if not value:
|
|||
|
|
await update.message.reply_text("密码不能为空。")
|
|||
|
|
return
|
|||
|
|
data["auth_type"] = "password"
|
|||
|
|
data["auth_value"] = value
|
|||
|
|
context.user_data["add_node"] = data
|
|||
|
|
await save_node_from_context(update, context)
|
|||
|
|
return
|
|||
|
|
elif step == "custom_key":
|
|||
|
|
if not value:
|
|||
|
|
await update.message.reply_text("Key 路径或私钥内容不能为空。")
|
|||
|
|
return
|
|||
|
|
data["auth_type"] = "key"
|
|||
|
|
data["auth_value"] = maybe_store_private_key(value, "node")
|
|||
|
|
context.user_data["add_node"] = data
|
|||
|
|
await save_node_from_context(update, context)
|
|||
|
|
return
|
|||
|
|
elif step == "edit_group_name":
|
|||
|
|
node_id = context.user_data.get("edit_node_id")
|
|||
|
|
if not node_id:
|
|||
|
|
await update.message.reply_text("没有找到要修改的节点。")
|
|||
|
|
return
|
|||
|
|
group_name = value or "default"
|
|||
|
|
update_node_group(int(node_id), group_name)
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
context.user_data.pop("edit_node_id", None)
|
|||
|
|
await update.message.reply_text(f"✅ 分组已改为:{group_name}", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("📄 返回节点", callback_data=f"node:view:{node_id}")]]))
|
|||
|
|
return
|
|||
|
|
elif step in {"edit_name", "edit_host", "edit_port", "edit_user", "edit_auth_password", "edit_auth_key", "edit_remark", "edit_monthly_price", "edit_expires_at"}:
|
|||
|
|
node_id = context.user_data.get("edit_node_id")
|
|||
|
|
if not node_id:
|
|||
|
|
await update.message.reply_text("没有找到要修改的节点。")
|
|||
|
|
return
|
|||
|
|
if step == "edit_port":
|
|||
|
|
if not value.isdigit():
|
|||
|
|
await update.message.reply_text("端口必须是数字。")
|
|||
|
|
return
|
|||
|
|
update_node_field(int(node_id), "port", int(value))
|
|||
|
|
elif step == "edit_name":
|
|||
|
|
update_node_field(int(node_id), "name", value)
|
|||
|
|
elif step == "edit_host":
|
|||
|
|
update_node_field(int(node_id), "host", value)
|
|||
|
|
elif step == "edit_user":
|
|||
|
|
update_node_field(int(node_id), "user", value)
|
|||
|
|
elif step == "edit_auth_password":
|
|||
|
|
update_node_field(int(node_id), "auth_type", "password")
|
|||
|
|
update_node_field(int(node_id), "auth_value", value)
|
|||
|
|
elif step == "edit_auth_key":
|
|||
|
|
update_node_field(int(node_id), "auth_type", "key")
|
|||
|
|
update_node_field(int(node_id), "auth_value", maybe_store_private_key(value, "edit"))
|
|||
|
|
elif step == "edit_remark":
|
|||
|
|
update_node_field(int(node_id), "remark", "" if value == "-" else value)
|
|||
|
|
elif step == "edit_monthly_price":
|
|||
|
|
try:
|
|||
|
|
price = float(value)
|
|||
|
|
except Exception:
|
|||
|
|
await update.message.reply_text("金额格式不对,重新发,例如 4.99")
|
|||
|
|
return
|
|||
|
|
update_node_field(int(node_id), "monthly_price", price)
|
|||
|
|
elif step == "edit_expires_at":
|
|||
|
|
if value == "0":
|
|||
|
|
update_node_field(int(node_id), "expires_at", 0)
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
ts = int(datetime.strptime(value, "%Y-%m-%d").timestamp())
|
|||
|
|
except Exception:
|
|||
|
|
await update.message.reply_text("日期格式不对,按 YYYY-MM-DD 发。")
|
|||
|
|
return
|
|||
|
|
update_node_field(int(node_id), "expires_at", ts)
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
context.user_data.pop("edit_node_id", None)
|
|||
|
|
await update.message.reply_text("✅ 节点已更新。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("📄 返回节点", callback_data=f"node:view:{node_id}")]]))
|
|||
|
|
return
|
|||
|
|
elif step == "batch_service_name":
|
|||
|
|
scope = context.user_data.get("batch_service_scope")
|
|||
|
|
action = context.user_data.get("batch_service_action")
|
|||
|
|
if not scope or not action:
|
|||
|
|
await update.message.reply_text("批量服务上下文丢了,请重新进入批量服务菜单。")
|
|||
|
|
return
|
|||
|
|
if not value:
|
|||
|
|
await update.message.reply_text("服务名不能为空。")
|
|||
|
|
return
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
context.user_data.pop("batch_service_scope", None)
|
|||
|
|
context.user_data.pop("batch_service_action", None)
|
|||
|
|
wait = await update.message.reply_text(f"收到,开始批量{SERVICE_ACTIONS.get(action, action)}:{value}")
|
|||
|
|
class _Q:
|
|||
|
|
async def edit_message_text(self, *args, **kwargs):
|
|||
|
|
return await wait.reply_text(*args, **kwargs)
|
|||
|
|
await run_batch_service_action(_Q(), scope, value, action)
|
|||
|
|
return
|
|||
|
|
elif step == "batch_readonly_command":
|
|||
|
|
scope = context.user_data.get("batch_custom_scope")
|
|||
|
|
if not scope:
|
|||
|
|
await update.message.reply_text("批量命令上下文丢了,请重新进入自定义只读命令菜单。")
|
|||
|
|
return
|
|||
|
|
if not is_safe_readonly_command(value):
|
|||
|
|
await update.message.reply_text("这条命令不在只读白名单里。\n可用示例:df -h /、free -m、docker ps、systemctl status nginx")
|
|||
|
|
return
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
context.user_data.pop("batch_custom_scope", None)
|
|||
|
|
wait = await update.message.reply_text(f"收到,开始批量执行只读命令:{value}")
|
|||
|
|
class _Q:
|
|||
|
|
async def edit_message_text(self, *args, **kwargs):
|
|||
|
|
return await wait.reply_text(*args, **kwargs)
|
|||
|
|
nodes = all_nodes() if scope == "all" else nodes_by_group(scope)
|
|||
|
|
if not nodes:
|
|||
|
|
await wait.reply_text("这个范围下没有节点。")
|
|||
|
|
return
|
|||
|
|
results = await asyncio.gather(*[run_ssh(n, value, timeout=20) for n in nodes], return_exceptions=True)
|
|||
|
|
lines = [f"<b>批量只读命令</b> | 范围 <code>{esc(scope)}</code>", f"<code>{esc(value)}</code>"]
|
|||
|
|
for node, result in zip(nodes, results):
|
|||
|
|
if isinstance(result, Exception):
|
|||
|
|
lines.append(f"\n🔴 <b>{esc(node.name)}</b>\n<code>{esc(str(result))[:220]}</code>")
|
|||
|
|
else:
|
|||
|
|
code, out, err = result
|
|||
|
|
body = (out or err or "(空输出)")[:320]
|
|||
|
|
status = "🟢" if code == 0 else "🔴"
|
|||
|
|
lines.append(f"\n{status} <b>{esc(node.name)}</b>\n<pre>{esc(body)}</pre>")
|
|||
|
|
await _Q().edit_message_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⬅️ 返回自定义命令", callback_data="batchcustom:menu")],
|
|||
|
|
[InlineKeyboardButton("🏠 首页", callback_data="home")],
|
|||
|
|
]))
|
|||
|
|
return
|
|||
|
|
elif step in {"set_default_password", "set_default_key", "set_default_user", "set_default_port", "set_check_interval", "set_fail_threshold", "set_cpu_alert_threshold", "set_mem_alert_threshold", "set_disk_alert_threshold", "set_expire_remind_days"}:
|
|||
|
|
if step in {"set_default_port", "set_check_interval", "set_fail_threshold", "set_cpu_alert_threshold", "set_mem_alert_threshold", "set_disk_alert_threshold", "set_expire_remind_days"} and not value.isdigit():
|
|||
|
|
await update.message.reply_text("这里必须是数字,重新发。")
|
|||
|
|
return
|
|||
|
|
if step == "set_default_password":
|
|||
|
|
set_setting("default_password", value)
|
|||
|
|
elif step == "set_default_key":
|
|||
|
|
set_setting("default_key_path", maybe_store_private_key(value, "default"))
|
|||
|
|
elif step == "set_default_user":
|
|||
|
|
set_setting("default_user", value or "root")
|
|||
|
|
elif step == "set_default_port":
|
|||
|
|
set_setting("default_port", value)
|
|||
|
|
elif step == "set_check_interval":
|
|||
|
|
set_setting("check_interval", value)
|
|||
|
|
elif step == "set_fail_threshold":
|
|||
|
|
set_setting("fail_threshold", value)
|
|||
|
|
elif step == "set_cpu_alert_threshold":
|
|||
|
|
set_setting("cpu_alert_threshold", value)
|
|||
|
|
elif step == "set_mem_alert_threshold":
|
|||
|
|
set_setting("mem_alert_threshold", value)
|
|||
|
|
elif step == "set_disk_alert_threshold":
|
|||
|
|
set_setting("disk_alert_threshold", value)
|
|||
|
|
elif step == "set_expire_remind_days":
|
|||
|
|
set_setting("expire_remind_days", value)
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
await update.message.reply_text("✅ 设置已更新。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🏠 首页", callback_data="home")]]))
|
|||
|
|
return
|
|||
|
|
else:
|
|||
|
|
if not value:
|
|||
|
|
await update.message.reply_text("这一项不能为空。")
|
|||
|
|
return
|
|||
|
|
data[step] = value
|
|||
|
|
|
|||
|
|
if step in ADD_FLOW:
|
|||
|
|
idx = ADD_FLOW.index(step)
|
|||
|
|
if idx == len(ADD_FLOW) - 1:
|
|||
|
|
context.user_data["add_node"] = data
|
|||
|
|
context.user_data["add_step"] = "auth_choice"
|
|||
|
|
await prompt_auth_choice(update.message, data)
|
|||
|
|
return
|
|||
|
|
next_step = ADD_FLOW[idx + 1]
|
|||
|
|
context.user_data["add_node"] = data
|
|||
|
|
context.user_data["add_step"] = next_step
|
|||
|
|
await update.message.reply_text(prompts[next_step], parse_mode=ParseMode.HTML)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
q = update.callback_query
|
|||
|
|
await q.answer()
|
|||
|
|
data = q.data or ""
|
|||
|
|
|
|||
|
|
if data == "home":
|
|||
|
|
await start(update, context)
|
|||
|
|
elif data == "nodes:list":
|
|||
|
|
await show_nodes(q)
|
|||
|
|
elif data == "nodes:overview":
|
|||
|
|
await nodes_overview(q)
|
|||
|
|
elif data == "nodes:add":
|
|||
|
|
await begin_add(q, context)
|
|||
|
|
elif data == "groups:list":
|
|||
|
|
await show_groups(q)
|
|||
|
|
elif data.startswith("groups:view:"):
|
|||
|
|
await show_nodes(q, data.split(":", 2)[2])
|
|||
|
|
elif data == "settings:auth":
|
|||
|
|
await show_auth_settings(q)
|
|||
|
|
elif data == "settings:alerts":
|
|||
|
|
await show_alert_settings(q)
|
|||
|
|
elif data == "billing:summary":
|
|||
|
|
await show_billing_summary(q)
|
|||
|
|
elif data == "billing:expiring":
|
|||
|
|
await show_expiring_nodes(q, 30)
|
|||
|
|
elif data.startswith("billing:expiring:"):
|
|||
|
|
await show_expiring_nodes(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("settings:set_"):
|
|||
|
|
step = data.split(":", 1)[1]
|
|||
|
|
context.user_data["add_step"] = step
|
|||
|
|
await q.edit_message_text(add_prompts()[step], parse_mode=ParseMode.HTML)
|
|||
|
|
elif data == "settings:toggle_notify_offline":
|
|||
|
|
set_setting("notify_offline", "0" if get_setting("notify_offline") == "1" else "1")
|
|||
|
|
await show_alert_settings(q)
|
|||
|
|
elif data == "settings:toggle_notify_recovery":
|
|||
|
|
set_setting("notify_recovery", "0" if get_setting("notify_recovery") == "1" else "1")
|
|||
|
|
await show_alert_settings(q)
|
|||
|
|
elif data == "monitor:run":
|
|||
|
|
await q.edit_message_text("正在手动巡检一次…")
|
|||
|
|
await monitor_once(context.application)
|
|||
|
|
await q.message.reply_text("✅ 已手动巡检一次。", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔔 返回告警设置", callback_data="settings:alerts")]]))
|
|||
|
|
elif data.startswith("auth:"):
|
|||
|
|
current = context.user_data.get("add_node")
|
|||
|
|
if not current:
|
|||
|
|
await q.edit_message_text("当前没有进行中的添加流程。", reply_markup=main_menu())
|
|||
|
|
return
|
|||
|
|
defaults = defaults_dict()
|
|||
|
|
choice = data.split(":", 1)[1]
|
|||
|
|
if choice == "default_password":
|
|||
|
|
current["auth_type"] = "password"
|
|||
|
|
current["auth_value"] = defaults["password"]
|
|||
|
|
context.user_data["add_node"] = current
|
|||
|
|
context.user_data["add_step"] = None
|
|||
|
|
await save_node_from_context(update, context)
|
|||
|
|
elif choice == "default_key":
|
|||
|
|
current["auth_type"] = "key"
|
|||
|
|
current["auth_value"] = defaults["key_path"]
|
|||
|
|
context.user_data["add_node"] = current
|
|||
|
|
context.user_data["add_step"] = None
|
|||
|
|
await save_node_from_context(update, context)
|
|||
|
|
elif choice == "custom_password":
|
|||
|
|
context.user_data["add_step"] = "custom_password"
|
|||
|
|
await q.edit_message_text(add_prompts()["custom_password"], parse_mode=ParseMode.HTML)
|
|||
|
|
elif choice == "custom_key":
|
|||
|
|
context.user_data["add_step"] = "custom_key"
|
|||
|
|
await q.edit_message_text(add_prompts()["custom_key"], parse_mode=ParseMode.HTML)
|
|||
|
|
elif data.startswith("node:setgroupto:"):
|
|||
|
|
_, _, rest = data.split(":", 2)
|
|||
|
|
node_id_str, group_name = rest.split(":", 1)
|
|||
|
|
node_id = int(node_id_str)
|
|||
|
|
update_node_group(node_id, group_name)
|
|||
|
|
await q.edit_message_text(f"✅ 分组已改为:<code>{esc(group_name)}</code>", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("📄 返回节点", callback_data=f"node:view:{node_id}")]]))
|
|||
|
|
elif data.startswith("node:setgroupinput:"):
|
|||
|
|
node_id = int(data.split(":")[-1])
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await q.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
context.user_data["add_step"] = "edit_group_name"
|
|||
|
|
context.user_data["edit_node_id"] = node_id
|
|||
|
|
groups = ', '.join([g for g,_ in list_groups()][:8]) or 'default'
|
|||
|
|
await q.edit_message_text(f"当前节点:<b>{esc(node.name)}</b>\n当前分组:<code>{esc(node.group_name)}</code>\n\n直接发新的分组名即可。\n现有分组:<code>{esc(groups)}</code>", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回分组选择", callback_data=f"node:setgroup:{node_id}")]]))
|
|||
|
|
elif data.startswith("node:setgroup:"):
|
|||
|
|
node_id = int(data.split(":")[-1])
|
|||
|
|
node = load_node(node_id)
|
|||
|
|
if not node:
|
|||
|
|
await q.answer("节点不存在", show_alert=True)
|
|||
|
|
return
|
|||
|
|
await q.edit_message_text(
|
|||
|
|
f"当前节点:<b>{esc(node.name)}</b>\n当前分组:<code>{esc(node.group_name)}</code>\n\n选一个现有分组,或者新建分组。",
|
|||
|
|
parse_mode=ParseMode.HTML,
|
|||
|
|
reply_markup=choose_group_buttons(node_id),
|
|||
|
|
)
|
|||
|
|
elif data.startswith("node:billing:"):
|
|||
|
|
await show_billing_menu(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:cycle:"):
|
|||
|
|
await show_cycle_menu(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:setcycle:"):
|
|||
|
|
_, _, node_id, cycle = data.split(":", 3)
|
|||
|
|
update_node_field(int(node_id), "price_cycle", cycle)
|
|||
|
|
await show_billing_menu(q, int(node_id))
|
|||
|
|
elif data.startswith("node:currency:"):
|
|||
|
|
await show_currency_menu(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:setcurrency:"):
|
|||
|
|
_, _, node_id, currency = data.split(":", 3)
|
|||
|
|
update_node_field(int(node_id), "price_currency", currency.upper())
|
|||
|
|
await show_billing_menu(q, int(node_id))
|
|||
|
|
elif data.startswith("node:edit:"):
|
|||
|
|
await show_edit_menu(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:editfield:"):
|
|||
|
|
_, _, node_id, field = data.split(":", 3)
|
|||
|
|
context.user_data["add_step"] = f"edit_{field}"
|
|||
|
|
context.user_data["edit_node_id"] = int(node_id)
|
|||
|
|
await q.edit_message_text(add_prompts()[f"edit_{field}"], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回编辑", callback_data=f"node:edit:{node_id}")]]))
|
|||
|
|
elif data.startswith("node:editauth:"):
|
|||
|
|
_, _, node_id, kind = data.split(":", 3)
|
|||
|
|
step = "edit_auth_password" if kind == "password" else "edit_auth_key"
|
|||
|
|
context.user_data["add_step"] = step
|
|||
|
|
context.user_data["edit_node_id"] = int(node_id)
|
|||
|
|
await q.edit_message_text(add_prompts()[step], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回编辑", callback_data=f"node:edit:{node_id}")]]))
|
|||
|
|
elif data.startswith("node:view:"):
|
|||
|
|
await show_node(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:test:"):
|
|||
|
|
await node_test(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:summary:"):
|
|||
|
|
await node_summary(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:docker:"):
|
|||
|
|
await show_docker(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:delask:"):
|
|||
|
|
await ask_delete(q, int(data.split(":")[-1]))
|
|||
|
|
elif data.startswith("node:del:"):
|
|||
|
|
await delete_node(q, int(data.split(":")[-1]))
|
|||
|
|
elif data == "batch:menu":
|
|||
|
|
await show_batch_menu(q)
|
|||
|
|
elif data == "batch:readonly":
|
|||
|
|
await show_batch_actions(q, "all")
|
|||
|
|
elif data == "batchcustom:menu":
|
|||
|
|
await show_batch_custom_scope(q)
|
|||
|
|
elif data.startswith("batchcustom:scope:"):
|
|||
|
|
scope = data.split(":", 2)[2]
|
|||
|
|
context.user_data["add_step"] = "batch_readonly_command"
|
|||
|
|
context.user_data["batch_custom_scope"] = scope
|
|||
|
|
await q.edit_message_text(f"范围:<code>{esc(scope)}</code>\n\n发我要批量执行的只读命令。\n示例:<code>df -h /</code>、<code>free -m</code>、<code>docker ps</code>、<code>systemctl status nginx</code>", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回范围选择", callback_data="batchcustom:menu")]]))
|
|||
|
|
elif data == "batchsvc:menu":
|
|||
|
|
await show_batch_service_scope(q)
|
|||
|
|
elif data.startswith("batchsvc:scope:"):
|
|||
|
|
await show_batch_service_actions(q, data.split(":", 2)[2])
|
|||
|
|
elif data.startswith("batchsvc:ask:"):
|
|||
|
|
_, _, scope, action = data.split(":", 3)
|
|||
|
|
context.user_data["add_step"] = "batch_service_name"
|
|||
|
|
context.user_data["batch_service_scope"] = scope
|
|||
|
|
context.user_data["batch_service_action"] = action
|
|||
|
|
await q.edit_message_text(f"范围:<code>{esc(scope)}</code>\n动作:<b>{esc(SERVICE_ACTIONS.get(action, action))}</b>\n\n发我要操作的 systemd 服务名。", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回服务菜单", callback_data=f"batchsvc:scope:{scope}")]]))
|
|||
|
|
elif data.startswith("batch:scope:"):
|
|||
|
|
await show_batch_actions(q, data.split(":", 2)[2])
|
|||
|
|
elif data.startswith("batch:run:"):
|
|||
|
|
_, _, scope, action = data.split(":", 3)
|
|||
|
|
await run_batch_action(q, scope, action)
|
|||
|
|
elif data.startswith("batch:group:"):
|
|||
|
|
await show_batch_actions(q, data.split(":", 2)[2])
|
|||
|
|
elif data.startswith("docker:menu:"):
|
|||
|
|
_, _, node_id, container = data.split(":", 3)
|
|||
|
|
await show_docker_menu(q, int(node_id), container)
|
|||
|
|
elif data.startswith("docker:act:"):
|
|||
|
|
_, _, node_id, container, action = data.split(":", 4)
|
|||
|
|
await do_docker_action(q, int(node_id), container, action)
|
|||
|
|
elif data.startswith("docker:logs:"):
|
|||
|
|
_, _, node_id, container = data.split(":", 3)
|
|||
|
|
await show_docker_logs(q, int(node_id), container)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
context.user_data.pop("add_step", None)
|
|||
|
|
context.user_data.pop("add_node", None)
|
|||
|
|
await update.message.reply_text("已取消当前操作。", reply_markup=ReplyKeyboardRemove())
|
|||
|
|
await update.message.reply_text("主菜单", reply_markup=main_menu())
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cmd_nodes(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
rows = all_nodes()
|
|||
|
|
if not rows:
|
|||
|
|
await update.message.reply_text("还没有节点,先 /add。")
|
|||
|
|
return
|
|||
|
|
buttons = [[InlineKeyboardButton(f"🖥️ {r.name} [{r.group_name}]", callback_data=f"node:view:{r.id}")] for r in rows[:50]]
|
|||
|
|
await update.message.reply_text("节点列表", reply_markup=InlineKeyboardMarkup(buttons))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cmd_add(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
context.user_data["add_node"] = {}
|
|||
|
|
context.user_data["add_step"] = ADD_FLOW[0]
|
|||
|
|
await update.message.reply_text("开始添加节点\n\n" + add_prompts()[ADD_FLOW[0]], parse_mode=ParseMode.HTML)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cmd_overview(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
nodes = all_nodes()
|
|||
|
|
if not nodes:
|
|||
|
|
await update.message.reply_text("还没有节点,先 /add。")
|
|||
|
|
return
|
|||
|
|
wait = await update.message.reply_text("正在拉取全部节点概览…")
|
|||
|
|
results = await asyncio.gather(*[collect_node_summary(n) for n in nodes], return_exceptions=True)
|
|||
|
|
lines = ["<b>批量总览</b>"]
|
|||
|
|
ok = 0
|
|||
|
|
for node, result in zip(nodes, results):
|
|||
|
|
if isinstance(result, Exception):
|
|||
|
|
lines.append(f"\n🔴 <b>{esc(node.name)}</b> [{esc(node.group_name)}]\n<code>{esc(str(result))[:120]}</code>")
|
|||
|
|
else:
|
|||
|
|
ok += 1
|
|||
|
|
lines.append(f"\n🟢 <b>{esc(node.name)}</b> [{esc(node.group_name)}]\n负载 <code>{esc(result.get('LOAD','-'))}</code> | 内存 <code>{esc(result.get('MEM','-'))}</code>\n磁盘 <code>{esc(result.get('DISK','-'))}</code>")
|
|||
|
|
lines.insert(1, f"在线/可连:<code>{ok}/{len(nodes)}</code>\n")
|
|||
|
|
await wait.reply_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cmd_auth(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
d = defaults_dict()
|
|||
|
|
text = (
|
|||
|
|
"<b>默认认证设置</b>\n\n"
|
|||
|
|
f"默认用户:<code>{esc(d['user'])}</code>\n"
|
|||
|
|
f"默认端口:<code>{d['port']}</code>\n"
|
|||
|
|
f"默认密码:<code>{'已设置' if d['password'] else '未设置'}</code>\n"
|
|||
|
|
f"默认Key:<code>{esc(d['key_path'])}</code>"
|
|||
|
|
)
|
|||
|
|
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("改默认密码", callback_data="settings:set_default_password"), InlineKeyboardButton("改默认Key", callback_data="settings:set_default_key")],
|
|||
|
|
[InlineKeyboardButton("改默认用户", callback_data="settings:set_default_user"), InlineKeyboardButton("改默认端口", callback_data="settings:set_default_port")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cmd_alerts(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
await update.message.reply_text("<b>告警设置</b>\n\n" + alert_settings_text(), parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("改巡检间隔", callback_data="settings:set_check_interval"), InlineKeyboardButton("改失败阈值", callback_data="settings:set_fail_threshold")],
|
|||
|
|
[InlineKeyboardButton("CPU 阈值", callback_data="settings:set_cpu_alert_threshold"), InlineKeyboardButton("内存阈值", callback_data="settings:set_mem_alert_threshold")],
|
|||
|
|
[InlineKeyboardButton("磁盘阈值", callback_data="settings:set_disk_alert_threshold"), InlineKeyboardButton("到期提醒天数", callback_data="settings:set_expire_remind_days")],
|
|||
|
|
[InlineKeyboardButton("切换掉线通知", callback_data="settings:toggle_notify_offline"), InlineKeyboardButton("切换恢复通知", callback_data="settings:toggle_notify_recovery")],
|
|||
|
|
[InlineKeyboardButton("立即巡检一次", callback_data="monitor:run")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cmd_billing(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
|
if not await guard(update):
|
|||
|
|
return
|
|||
|
|
nodes = all_nodes()
|
|||
|
|
priced = [n for n in nodes if float(n.monthly_price or 0) > 0]
|
|||
|
|
group_rows = monthly_cost_by_group()
|
|||
|
|
upcoming = expiring_nodes(30)
|
|||
|
|
top_nodes = [n for n in top_cost_nodes(8) if float(n.monthly_price or 0) > 0]
|
|||
|
|
lines = ["<b>账单总览</b>"]
|
|||
|
|
lines.append(f"节点:<code>{len(nodes)}</code> | 已填账单:<code>{len(priced)}</code>")
|
|||
|
|
lines.append("总成本:<code>多币种,按节点明细查看</code>")
|
|||
|
|
if group_rows:
|
|||
|
|
lines.append("\n<b>分组覆盖</b>")
|
|||
|
|
for g, c, s in group_rows[:8]:
|
|||
|
|
lines.append(f"• <b>{esc(g)}</b>:<code>{c} 台</code>")
|
|||
|
|
if top_nodes:
|
|||
|
|
lines.append("\n<b>账单节点</b>")
|
|||
|
|
for n in top_nodes[:5]:
|
|||
|
|
lines.append(f"• <b>{esc(n.name)}</b> [{esc(n.group_name)}]:<code>{esc(format_price(n))}</code>")
|
|||
|
|
if upcoming:
|
|||
|
|
lines.append("\n<b>30 天内到期</b>")
|
|||
|
|
for n in upcoming[:5]:
|
|||
|
|
lines.append(f"• <b>{esc(n.name)}</b>:<code>{esc(format_expiry(n.expires_at))}</code>({esc(days_left_text(n.expires_at))})")
|
|||
|
|
else:
|
|||
|
|
lines.append("\n30 天内没有到期节点。")
|
|||
|
|
await update.message.reply_text("\n".join(lines)[:3900], parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([
|
|||
|
|
[InlineKeyboardButton("⏰ 即将到期", callback_data="billing:expiring"), InlineKeyboardButton("🔄 刷新", callback_data="billing:summary")],
|
|||
|
|
]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def setup_bot(app: Application):
|
|||
|
|
await app.bot.set_my_commands([
|
|||
|
|
BotCommand("start", "打开主菜单"),
|
|||
|
|
BotCommand("nodes", "节点列表"),
|
|||
|
|
BotCommand("add", "添加节点"),
|
|||
|
|
BotCommand("overview", "批量总览"),
|
|||
|
|
BotCommand("auth", "默认认证设置"),
|
|||
|
|
BotCommand("alerts", "告警设置"),
|
|||
|
|
BotCommand("billing", "账单总览"),
|
|||
|
|
BotCommand("cancel", "取消当前操作"),
|
|||
|
|
])
|
|||
|
|
try:
|
|||
|
|
await app.bot.set_chat_menu_button(menu_button=MenuButtonCommands())
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
app.bot_data["monitor_task"] = asyncio.create_task(monitor_loop(app))
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
init_db()
|
|||
|
|
app = Application.builder().token(TOKEN).post_init(setup_bot).build()
|
|||
|
|
app.add_handler(CommandHandler("start", start))
|
|||
|
|
app.add_handler(CommandHandler("cancel", cancel))
|
|||
|
|
app.add_handler(CommandHandler("nodes", cmd_nodes))
|
|||
|
|
app.add_handler(CommandHandler("add", cmd_add))
|
|||
|
|
app.add_handler(CommandHandler("overview", cmd_overview))
|
|||
|
|
app.add_handler(CommandHandler("auth", cmd_auth))
|
|||
|
|
app.add_handler(CommandHandler("alerts", cmd_alerts))
|
|||
|
|
app.add_handler(CommandHandler("billing", cmd_billing))
|
|||
|
|
app.add_handler(CallbackQueryHandler(on_callback))
|
|||
|
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
|
|||
|
|
print("bot starting...")
|
|||
|
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|