#!/usr/bin/env python3 # ============================================================ # GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板 # 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以 # setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync) # 页面: / 控制面板(开关/立即同步/快捷入口) # /status 项目状态(每个仓库的详细同步状态) # /manage 项目管理(从 Gitea 快捷添加/移除同步项目) # /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库) # 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token), # 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走 # HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。 # ============================================================ import atexit import json import os import re import signal import subprocess import threading import time import uuid from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import quote APP_DIR = Path(__file__).resolve().parent.parent DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync")) REPO_DIR = DATA_DIR / "repos" STATE_DIR = DATA_DIR / ".last-pushed" LOG_FILE = DATA_DIR / "sync.log" PID_FILE = DATA_DIR / "daemon.pid" PANEL_STATE = DATA_DIR / "state.json" REPOS_FILE = DATA_DIR / "repos.json" STATS_FILE = DATA_DIR / "stats.json" ACCOUNTS_FILE = DATA_DIR / "accounts.json" # GitHub 多账号(含 Token,权限 600) SETTINGS_FILE = DATA_DIR / "settings.json" # Gitea 用户名/密码、自动创建开关 GITCREDS_FILE = DATA_DIR / ".gitcreds" # git credential store(Gitea 密码,权限 600) SSH_KEY = APP_DIR / "ssh" / "id_ed25519" if (DATA_DIR / "id_ed25519").exists(): SSH_KEY = DATA_DIR / "id_ed25519" KNOWN_HOSTS = DATA_DIR / "known_hosts" GITEA_API = "http://127.0.0.1:3000/api/v1" GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000") DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController", "Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"] LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移 LEGACY_GITEA_USER = "cbb" # 旧版硬编码的 Gitea 用户名,仅作默认值 INTERVAL = 10 PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899")) START_TS = time.time() NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$") ENV = dict(os.environ) ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes " f"-o StrictHostKeyChecking=accept-new " f"-o UserKnownHostsFile={KNOWN_HOSTS}") ENV["GIT_TERMINAL_PROMPT"] = "0" # ---------------- 应用设置(settings.json) ---------------- cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True} def load_settings(): try: d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) if isinstance(d.get("gitea_user"), str) and valid_name(d["gitea_user"]): cfg["gitea_user"] = d["gitea_user"] cfg["gitea_pass"] = str(d.get("gitea_pass") or "") cfg["auto_create"] = bool(d.get("auto_create", True)) except Exception: pass write_gitcreds() def save_settings(): try: SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) SETTINGS_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8") os.chmod(SETTINGS_FILE, 0o600) # 含 Gitea 密码,仅属主可读 except Exception as e: log(f"保存 settings.json 失败: {e}") write_gitcreds() def gitea_base(): """Gitea 上当前用户的仓库前缀""" return f"http://127.0.0.1:3000/{cfg['gitea_user']}" # ---------------- GitHub 多账号(accounts.json) ---------------- accounts = [] # [{id,label,gh_user,token,private,created}] active_id = None def load_accounts(): """加载账号;首次运行从旧版迁移:生成「默认(SSH 私钥)」账号,行为与旧版一致""" global accounts, active_id try: d = json.loads(ACCOUNTS_FILE.read_text(encoding="utf-8")) accounts = [a for a in d.get("accounts", []) if a.get("id") and a.get("gh_user")] active_id = d.get("active") except Exception: accounts = [] if not accounts: accounts = [{"id": "default", "label": "默认(SSH 私钥)", "gh_user": LEGACY_GH_USER, "token": "", "private": True, "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] active_id = "default" migrate_flat_state() save_accounts() if active_id not in [a["id"] for a in accounts]: active_id = accounts[0]["id"] save_accounts() def save_accounts(): try: ACCOUNTS_FILE.parent.mkdir(parents=True, exist_ok=True) ACCOUNTS_FILE.write_text( json.dumps({"active": active_id, "accounts": accounts}, ensure_ascii=False, indent=1), encoding="utf-8") os.chmod(ACCOUNTS_FILE, 0o600) # 含 Token,仅属主可读 except Exception as e: log(f"保存 accounts.json 失败: {e}") def active_account(): for a in accounts: if a["id"] == active_id: return a return accounts[0] if accounts else None def find_account(aid): for a in accounts: if a["id"] == aid: return a return None def migrate_flat_state(): """旧版同步状态平铺在 STATE_DIR/;新版按账号分目录 STATE_DIR//""" try: legacy_dir = STATE_DIR / "default" legacy_dir.mkdir(parents=True, exist_ok=True) for f in STATE_DIR.iterdir(): if f.is_file() and valid_name(f.name): f.rename(legacy_dir / f.name) except Exception: pass def state_path(acc, name): return STATE_DIR / ((acc or {}).get("id") or "default") / name def mask_token(tok): if not tok: return "" if len(tok) <= 8: return "****" return tok[:7] + "****" + tok[-4:] def scrub(s, acc): """日志/报错脱敏:GitHub Token 和 Gitea 密码不进日志""" if not s: return s if acc and acc.get("token"): s = s.replace(acc["token"], "***") if cfg.get("gitea_pass"): s = s.replace(cfg["gitea_pass"], "***") return s state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0, "last_result": "尚未运行", "loop_last": None} stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0} op_lock = threading.Lock() # ---------------- 基础工具 ---------------- def log(msg): try: DATA_DIR.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") except Exception: pass def write_gitcreds(): """把 Gitea 凭据写成 git credential store 文件(600),供 clone/fetch 读取; 这样镜像仓库的 remote 地址里不保存密码。清除密码时删除该文件。""" try: if cfg.get("gitea_pass"): user = quote(str(cfg["gitea_user"]), safe="") pw = quote(str(cfg["gitea_pass"]), safe="") GITCREDS_FILE.write_text(f"http://{user}:{pw}@127.0.0.1:3000\n", encoding="utf-8") os.chmod(GITCREDS_FILE, 0o600) elif GITCREDS_FILE.exists(): GITCREDS_FILE.unlink() except Exception: pass def git_creds_args(): if cfg.get("gitea_pass") and GITCREDS_FILE.exists(): return ["-c", f"credential.helper=store --file={GITCREDS_FILE}"] return [] def git(args, timeout=300): return subprocess.run(["git"] + git_creds_args() + args, capture_output=True, text=True, env=ENV, timeout=timeout) def load_stats(): global stats try: stats.update(json.loads(STATS_FILE.read_text(encoding="utf-8"))) except Exception: pass def save_stats(): try: STATS_FILE.write_text(json.dumps(stats), encoding="utf-8") except Exception: pass def load_repos(): """同步项目列表(repos.json),首次运行播种默认 7 个""" try: data = json.loads(REPOS_FILE.read_text(encoding="utf-8")) repos = [r for r in data.get("repos", []) if valid_name(r)] if repos: return repos except Exception: pass save_repos(DEFAULT_REPOS) return list(DEFAULT_REPOS) def save_repos(repos): try: DATA_DIR.mkdir(parents=True, exist_ok=True) REPOS_FILE.write_text(json.dumps({"repos": repos}, ensure_ascii=False, indent=1), encoding="utf-8") except Exception as e: log(f"保存 repos.json 失败: {e}") def valid_name(name): return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..") def gitea_api(path): """访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)""" try: cmd = ["curl", "-sS", "-m", "10"] if cfg.get("gitea_pass"): cmd += ["-u", f"{cfg['gitea_user']}:{cfg['gitea_pass']}"] cmd.append(f"{GITEA_API}{path}") r = subprocess.run(cmd, capture_output=True, text=True, timeout=15) return r.returncode == 0, json.loads(r.stdout or "{}") except Exception: return False, {} def gitea_repo_exists(name): ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}") return ok and isinstance(d, dict) and d.get("full_name") == f"{cfg['gitea_user']}/{name}" def gh_api(acc, path, method="GET", body=None, timeout=15): """调用 GitHub API(需 Token)。返回 (ok, http_status, json)""" if not acc or not acc.get("token"): return False, 0, {} cmd = ["curl", "-sS", "-m", str(timeout), "-X", method, "-H", f"Authorization: token {acc['token']}", "-H", "Accept: application/vnd.github+json", "-H", "Content-Type: application/json", "-w", "\n%{http_code}"] if body is not None: cmd += ["-d", json.dumps(body, ensure_ascii=False)] cmd.append(f"https://api.github.com{path}") try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5) out, _, code = r.stdout.rpartition("\n") status = int(code) if code.strip().isdigit() else 0 try: d = json.loads(out or "{}") except Exception: d = {} return (200 <= status < 300), status, d except Exception: return False, 0, {} def gh_verify_token(token): """验证 Token 有效性,返回 (ok, login, msg)""" token = (token or "").strip() if not token: return False, "", "Token 不能为空" try: r = subprocess.run( ["curl", "-sS", "-m", "15", "-w", "\n%{http_code}", "-H", f"Authorization: token {token}", "-H", "Accept: application/vnd.github+json", "https://api.github.com/user"], capture_output=True, text=True, timeout=20) out, _, code = r.stdout.rpartition("\n") status = int(code) if code.strip().isdigit() else 0 if 200 <= status < 300: d = json.loads(out or "{}") return True, (d.get("login") or ""), "" if status == 401: return False, "", "Token 无效或已过期(请重新生成)" return False, "", f"GitHub API 返回 HTTP {status}" except Exception as e: return False, "", f"网络错误: {e}" def gh_push_url(acc, name): """当前账号的 GitHub 推送地址:有 Token 走 HTTPS,否则回退 SSH 私钥""" if acc.get("token"): return f"https://x-access-token:{acc['token']}@github.com/{acc['gh_user']}/{name}.git" return f"git@github.com:{acc['gh_user']}/{name}.git" def github_repo_exists(acc, name): if acc.get("token"): ok, status, d = gh_api(acc, f"/repos/{acc['gh_user']}/{name}") return ok and status == 200 and isinstance(d, dict) \ and d.get("full_name") == f"{acc['gh_user']}/{name}" try: r = git(["ls-remote", gh_push_url(acc, name), "HEAD"], timeout=30) return r.returncode == 0 except Exception: return False def github_create_repo(acc, name): """在 GitHub 自动创建仓库(需 Token)。返回 (ok, msg)""" if not acc.get("token"): return False, "当前账号未配置 Token,无法自动创建(请到设置页添加带 Token 的 GitHub 账号)" ok, status, d = gh_api(acc, "/user/repos", "POST", { "name": name, "private": bool(acc.get("private", True)), "description": f"镜像自飞牛 Gitea 仓库 {name}(GiteaHubSync 自动创建)"}) if ok and status == 201: return True, "已创建" if status == 422 and isinstance(d, dict) and "errors" in d: return True, "已存在" # 重名等情况,按已存在继续推送 msg = d.get("message") if isinstance(d, dict) else str(d) return False, f"HTTP {status} {msg}" def list_gitea_repos(): ok, d = gitea_api(f"/users/{cfg['gitea_user']}/repos?limit=100") if ok and isinstance(d, list): return [x.get("name") for x in d if x.get("name")] return [] # ---------------- 同步逻辑 ---------------- def fingerprint(repo): r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"]) if r.returncode != 0: return None import hashlib return hashlib.sha1(r.stdout.encode()).hexdigest() def sync_repo(name): """返回 'changed' / 'same' / 'error:...' """ if not valid_name(name): return "error: 非法仓库名" acc = active_account() if not acc: return "error: 没有可用的 GitHub 账号,请到设置页添加" repo = REPO_DIR / f"{name}.git" try: if not repo.exists(): REPO_DIR.mkdir(parents=True, exist_ok=True) r = git(["clone", "--quiet", "--mirror", f"{gitea_base()}/{name}.git", str(repo)]) if r.returncode != 0: return "error: 克隆失败 " + scrub((r.stderr or "")[:120], acc) log(f"{name}: 首次克隆完成") r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"]) if r.returncode != 0: return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc) cur = fingerprint(repo) if cur is None: return "error: 读取 refs 失败" st = state_path(acc, name) prev = st.read_text().strip() if st.exists() else "" if cur == prev: return "same" # 推送前确保 GitHub 仓库存在;不存在时按设置自动创建 if not github_repo_exists(acc, name): if acc.get("token") and cfg.get("auto_create", True): ok, msg = github_create_repo(acc, name) if not ok: return f"error: 自动创建 GitHub 仓库失败: {msg}" log(f"{name}: GitHub 仓库不存在,已自动创建" f"({'私有' if acc.get('private', True) else '公开'})") else: hint = ("当前账号无 Token 或未开启自动创建,请到设置页配置" if not acc.get("token") else "自动创建已关闭,请到设置页开启或在 GitHub 手动创建") return f"error: GitHub 上不存在 {acc['gh_user']}/{name},{hint}" p = git(["-C", str(repo), "push", "--mirror", gh_push_url(acc, name)]) if p.returncode != 0: return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc) st.parent.mkdir(parents=True, exist_ok=True) st.write_text(cur) detail = " ".join(l.strip() for l in p.stdout.splitlines() if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->"))) log(f"{name}: 已同步到 GitHub({acc['gh_user']})" + (f" | {scrub(detail, acc)}" if detail else "")) return "changed" except Exception as e: return f"error: {scrub(str(e), acc)}" def run_sync(repos=None): with op_lock: if state["syncing"]: return False, "已有同步任务正在进行" state["syncing"] = True try: repos = repos if repos is not None else load_repos() errs, changed = [], 0 for r in repos: res = sync_repo(r) if res.startswith("error"): errs.append(f"{r} {res}") stats["total_failures"] += 1 elif res == "changed": changed += 1 stats["total_pushes"] += 1 state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") state["checks"] += 1 stats["total_checks"] += 1 save_stats() if errs: state["last_result"] = "失败: " + "; ".join(errs)[:200] return False, state["last_result"] state["last_result"] = "成功" return True, ("成功" if changed == 0 else f"成功(同步了 {changed} 个仓库)") except Exception as e: state["last_result"] = f"异常: {e}" return False, state["last_result"] finally: state["syncing"] = False def loop(): while True: state["loop_last"] = time.time() if state["enabled"]: run_sync() time.sleep(INTERVAL) def load_panel_state(): try: state["enabled"] = bool(json.loads(PANEL_STATE.read_text(encoding="utf-8")).get("enabled", True)) except Exception: pass def save_panel_state(): try: PANEL_STATE.write_text(json.dumps({"enabled": state["enabled"]}, ensure_ascii=False), encoding="utf-8") except Exception: pass # ---------------- 状态详情 ---------------- def repo_detail(name): repo = REPO_DIR / f"{name}.git" st = state_path(active_account(), name) d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0, "head": None, "last_commit": None, "size_mb": 0.0, "status": "未同步", "last_push": None} if st.exists(): d["status"] = "已同步" d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S") if not repo.exists(): return d try: r = git(["-C", str(repo), "for-each-ref", "--format=%(refname)"], timeout=20) if r.returncode == 0: refs = r.stdout.split() d["branches"] = sum(1 for x in refs if x.startswith("refs/heads/")) d["tags"] = sum(1 for x in refs if x.startswith("refs/tags/")) h = git(["-C", str(repo), "symbolic-ref", "--quiet", "HEAD"], timeout=10) if h.returncode == 0 and h.stdout.strip(): d["head"] = h.stdout.strip().replace("refs/heads/", "") if d["head"]: c = git(["-C", str(repo), "log", "-1", "--format=%h\t%s\t%an\t%ad", "--date=format:%m-%d %H:%M", d["head"]], timeout=10) if c.returncode == 0 and c.stdout.strip(): p = c.stdout.strip().split("\t", 3) d["last_commit"] = {"hash": p[0], "msg": p[1] if len(p) > 1 else "", "author": p[2] if len(p) > 2 else "", "time": p[3] if len(p) > 3 else ""} d["size_mb"] = round(sum(f.stat().st_size for f in repo.rglob("*") if f.is_file()) / 1048576, 1) except Exception: pass return d def fmt_uptime(sec): sec = int(sec) d, rem = divmod(sec, 86400) h, rem = divmod(rem, 3600) m, s = divmod(rem, 60) return (f"{d}天 " if d else "") + f"{h:02d}:{m:02d}:{s:02d}" def account_public(acc): """账号信息(脱敏后可下发到前端)""" if not acc: return None return {"id": acc["id"], "label": acc.get("label") or acc["gh_user"], "gh_user": acc["gh_user"], "token": mask_token(acc.get("token")), "has_token": bool(acc.get("token")), "private": bool(acc.get("private", True)), "method": "Token(HTTPS)" if acc.get("token") else "SSH 私钥"} def global_status(): repos = load_repos() details = [repo_detail(r) for r in repos] now = time.time() next_in = None if state["enabled"] and state["loop_last"]: next_in = max(0, int(state["loop_last"] + INTERVAL - now)) gitea_all = list_gitea_repos() acc = active_account() return { "enabled": state["enabled"], "syncing": state["syncing"], "uptime": fmt_uptime(now - START_TS), "interval": INTERVAL, "last_check": state["last_check"], "next_in": next_in, "checks": state["checks"], "stats": stats, "last_result": state["last_result"], "repo_count": len(repos), "synced_count": sum(1 for x in details if x["status"] == "已同步"), "total_size_mb": round(sum(x["size_mb"] for x in details), 1), "repos": details, "gitea_url": GITEA_UI, "gitea_user": cfg["gitea_user"], "gitea_online": bool(gitea_all), "available": [r for r in gitea_all if r not in repos], "account": account_public(acc), "auto_create": bool(cfg.get("auto_create", True)), "gh_url": f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "", } def tail_log(n=80): try: return LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-n:] except Exception: return [] # ---------------- 页面 ---------------- CSS = """ *{box-sizing:border-box;margin:0;padding:0} body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1f2329;min-height:100vh} .wrap{width:100%;padding:20px 20px 60px} .top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px} .top h1{font-size:20px;display:flex;align-items:center;gap:8px;min-width:0} .pill{font-size:12px;padding:4px 12px;border-radius:999px;font-weight:600;white-space:nowrap;flex:none} .pill.on{background:#e3f5e9;color:#0a7d34}.pill.off{background:#fee;color:#c0392b} nav{display:flex;gap:6px;margin-bottom:16px;background:#fff;border-radius:12px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex-wrap:wrap} nav a{padding:8px 16px;border-radius:8px;text-decoration:none;color:#646a73;font-size:14px;font-weight:600;white-space:nowrap} nav a.active{background:#e8f0fe;color:#1f6feb} .card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);min-width:0} .row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px} .switchbox{display:flex;align-items:center;gap:14px;min-width:0} .switch{position:relative;width:64px;height:34px;flex:none} .switch input{opacity:0;width:0;height:0} .slider{position:absolute;inset:0;background:#cbd2d9;border-radius:34px;cursor:pointer;transition:.25s} .slider:before{content:"";position:absolute;width:26px;height:26px;left:4px;top:4px;background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.25)} input:checked+.slider{background:#0a7d34} input:checked+.slider:before{transform:translateX(30px)} .swlabel{font-size:16px;font-weight:600}.swsub{font-size:12px;color:#646a73;margin-top:2px} .btns{display:flex;gap:10px;flex-wrap:wrap} button,a.btn{border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap} button:active,a.btn:active{transform:scale(.97)} .b-sync{background:#0a7d34;color:#fff}.b-sync:hover{background:#086b2c} .b-gitea{background:#1f6feb;color:#fff}.b-gitea:hover{background:#1a5fd0} .b-gh{background:#24292f;color:#fff}.b-gh:hover{background:#000} .b-add{background:#7c3aed;color:#fff}.b-add:hover{background:#6d28d9} .b-del{background:#fee2e2;color:#b91c1c;padding:6px 12px;font-size:12px} .b-del:hover{background:#fecaca} .b-mini{background:#eef2ff;color:#4338ca;padding:6px 12px;font-size:12px} button:disabled{background:#cbd2d9;color:#fff;cursor:not-allowed} .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px} .stat{background:#f6f8fa;border-radius:10px;padding:12px 14px;min-width:0} .stat .k{font-size:12px;color:#646a73} .stat .v{font-size:15px;font-weight:700;margin-top:4px;word-break:break-word} .tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch} table{width:100%;border-collapse:collapse;font-size:13px} th,td{padding:9px 8px;text-align:left;border-bottom:1px solid #eef0f3;vertical-align:top} th{color:#646a73;font-weight:600;font-size:12px;white-space:nowrap} td{word-break:break-word} td.ok{color:#0a7d34;font-weight:600;white-space:nowrap} td.warn{color:#b45309;font-weight:600;white-space:nowrap} .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} .commit{color:#646a73;font-size:12px;margin-top:2px;line-height:1.5} h2{font-size:15px;margin-bottom:12px;color:#1f2329} pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:12px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all} .tip{font-size:12px;color:#646a73;margin-top:10px;line-height:1.6} .addrow{display:flex;gap:10px;flex-wrap:wrap} .addrow input{flex:1;min-width:180px;border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none} .addrow input:focus{border-color:#7c3aed} .avail{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px} .chip{border:1px dashed #7c3aed;color:#7c3aed;border-radius:999px;padding:6px 14px;font-size:13px;cursor:pointer;background:#faf5ff;white-space:nowrap} .chip:hover{background:#f3e8ff} .msg{margin-top:10px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;line-height:1.6;word-break:break-word} .msg.err{display:block;background:#fee;color:#b91c1c} .msg.ok{display:block;background:#e3f5e9;color:#0a7d34} input[type=checkbox]{accent-color:#7c3aed;cursor:pointer} .addrow input[type=password]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px} td .pill{font-size:12px} /* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */ @media (max-width:760px){ .wrap{padding:14px 10px 40px} .top h1{font-size:16px} nav{gap:4px} nav a{padding:8px 12px;font-size:13px;flex:1;text-align:center} .stats{grid-template-columns:repeat(2,1fr);gap:8px} .stat{padding:10px} .stat .v{font-size:13px} .card{padding:14px;border-radius:12px} button,a.btn{padding:10px 14px;font-size:13px} .btns{width:100%} .btns button,.btns a.btn{flex:1;justify-content:center} .addrow{flex-direction:column} .addrow input{min-width:0;width:100%} .addrow button{width:100%;justify-content:center} table.resp{min-width:0} table.resp thead{display:none} table.resp tbody tr{display:block;border:1px solid #e5e8ec;border-radius:12px;margin-bottom:10px;padding:10px 12px;background:#fbfcfd} table.resp tbody td{display:flex;justify-content:space-between;align-items:baseline;gap:6px 14px;border:none;padding:5px 0;flex-wrap:wrap} table.resp tbody td::before{content:attr(data-label);color:#646a73;font-size:12px;font-weight:600;flex:none} table.resp tbody td .val{flex:1;text-align:right;min-width:0;word-break:break-word} table.resp tbody td:has(button){justify-content:flex-end} table.resp tbody td:has(button)::before{margin-right:auto} pre{font-size:11px;padding:10px} } """ NAV = """""" def page(title, body, active): nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "") .replace("__B__", 'class="active"' if active == "status" else "") .replace("__C__", 'class="active"' if active == "manage" else "") .replace("__D__", 'class="active"' if active == "settings" else "")) return f""" {title} · GiteaHubSync

🔄 {title}

运行中
{nav} {body}
""" PAGE_HOME = """
自动同步:开
开启后每 10 秒检查一次 Gitea,有新提交自动推送到 GitHub
自动同步
—
GitHub 目标账号
—
同步项目
—
守护运行时长
—
最近实际同步
—

同步日志(最近 80 行)

加载中…
日志文件:__LOGPATH__ · 面板每 3 秒自动刷新
""" PAGE_STATUS = """
同步项目
—
已同步
—
守护运行时长
—
距下次检查
—
累计检查轮数
—
累计实际同步
—
累计失败次数
—
镜像总占用
—

项目明细

项目状态默认分支分支/标签最近推送同步最新提交大小
""" PAGE_MANAGE = """

快捷添加项目

下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):

当前同步项目(0)

项目状态最近推送同步操作
移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。
""" PAGE_SETTINGS = """

GitHub 账号(推送目标)

备注GitHub 用户名Token推送方式新建仓库操作
切换账号后,所有项目改推到该账号名下;每个账号独立记录同步进度,切回原账号不会重复推送。无 Token 的旧账号通过 SSH 私钥推送,无法自动创建仓库。

添加 / 更新 GitHub 账号

Token 获取:GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token,勾选 repo 权限(即可自动创建仓库并推送)。
链接:github.com/settings/tokens · Token 只保存在 NAS 本地(accounts.json,权限 600),不会上传到任何地方。

Gitea 与同步设置

面板按此用户名列出你可添加同步的 Gitea 仓库(本地 Gitea 地址 __GITEAURL__)。 密码只保存在 NAS 本地,仅用于读取私有仓库;仓库全部公开可留空,留空保存可清除已存密码。
GitHub 上不存在同名仓库时自动创建
需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建
""" # ---------------- HTTP 服务 ---------------- class Handler(BaseHTTPRequestHandler): def log_message(self, *a): pass def _json(self, obj, code=200): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _html(self, html): body = html.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _body(self): try: n = int(self.headers.get("Content-Length", 0) or 0) return json.loads(self.rfile.read(n) or b"{}") except Exception: return {} def do_GET(self): path = self.path.split("?")[0].rstrip("/") or "/" if path == "/": acc = active_account() body = (PAGE_HOME.replace("__GITEA__", GITEA_UI) .replace("__GH__", f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "") .replace("__LOGPATH__", str(LOG_FILE))) self._html(page("控制面板", body, "home")) elif path == "/status": self._html(page("项目状态", PAGE_STATUS, "status")) elif path == "/manage": self._html(page("项目管理", PAGE_MANAGE, "manage")) elif path == "/settings": body = PAGE_SETTINGS.replace("__GITEAURL__", GITEA_UI) self._html(page("设置", body, "settings")) elif path == "/api/state": repos = load_repos() acc = active_account() self._json({"enabled": state["enabled"], "syncing": state["syncing"], "last_check": state["last_check"], "checks": state["checks"], "last_result": state["last_result"], "repo_count": len(repos), "synced_count": sum(1 for r in repos if state_path(acc, r).exists()), "uptime": fmt_uptime(time.time() - START_TS), "account": account_public(acc), "last_real": last_real_sync()}) elif path == "/api/status": self._json(global_status()) elif path == "/api/settings": self._json({"gitea_user": cfg["gitea_user"], "gitea_pass": mask_token(cfg.get("gitea_pass")), "gitea_pass_set": bool(cfg.get("gitea_pass")), "auto_create": bool(cfg.get("auto_create", True)), "gitea_url": GITEA_UI, "active": active_id, "accounts": [account_public(a) for a in accounts]}) elif path == "/api/log": self._json({"lines": tail_log(80)}) else: self._json({"error": "not found"}, 404) def do_POST(self): global active_id path = self.path.split("?")[0].rstrip("/") or "/" data = self._body() if path == "/api/toggle": state["enabled"] = not state["enabled"] save_panel_state() self._json({"ok": True, "enabled": state["enabled"]}) elif path == "/api/sync": ok, msg = run_sync() self._json({"ok": ok, "msg": msg}) elif path == "/api/settings/save": new_user = new_pass = None if "gitea_user" in data: u = (data.get("gitea_user") or "").strip() if not valid_name(u): self._json({"ok": False, "msg": "Gitea 用户名不合法(仅限字母/数字/._-)"}) return new_user = u if "gitea_pass" in data: new_pass = str(data.get("gitea_pass") or "") # 空串 = 清除密码 # 预校验:涉及 Gitea 账号的改动,先用新凭据调一次 /user 验证 trial = dict(cfg) if new_user is not None: trial["gitea_user"] = new_user if new_pass is not None: trial["gitea_pass"] = new_pass if (new_user is not None or new_pass is not None) and trial["gitea_pass"]: try: r = subprocess.run( ["curl", "-sS", "-m", "10", "-w", "\n%{http_code}", "-u", f"{trial['gitea_user']}:{trial['gitea_pass']}", f"{GITEA_API}/user"], capture_output=True, text=True, timeout=15) out, _, code = r.stdout.rpartition("\n") st = int(code) if code.strip().isdigit() else 0 if not (200 <= st < 300): self._json({"ok": False, "msg": f"Gitea 账号验证失败(HTTP {st}):请检查用户名/密码"}) return login = (json.loads(out or "{}") or {}).get("login") if login and login != trial["gitea_user"]: self._json({"ok": False, "msg": f"该密码对应的 Gitea 用户是 {login},与填写的 {trial['gitea_user']} 不一致"}) return except Exception as e: self._json({"ok": False, "msg": f"Gitea 账号验证出错: {e}"}) return if new_user is not None: cfg["gitea_user"] = new_user if new_pass is not None: cfg["gitea_pass"] = new_pass log("Gitea 密码已" + ("更新" if new_pass else "清除")) if "auto_create" in data: cfg["auto_create"] = bool(data.get("auto_create")) save_settings() if cfg.get("gitea_pass"): msg = "已保存(Gitea 密码验证通过 ✓,可同步私有仓库)" else: ok, d = gitea_api(f"/users/{cfg['gitea_user']}") if ok and isinstance(d, dict) and d.get("login"): msg = "已保存(未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码)" else: msg = f"已保存(Gitea 上找不到用户 {cfg['gitea_user']},请检查)" self._json({"ok": True, "msg": msg}) elif path == "/api/accounts/add": token = (data.get("token") or "").strip() label = (data.get("label") or "").strip() gh_user = (data.get("gh_user") or "").strip() private = bool(data.get("private", True)) if not token: self._json({"ok": False, "msg": "Token 不能为空"}) return ok, login, err = gh_verify_token(token) if not ok: self._json({"ok": False, "msg": f"Token 验证失败: {err}"}) return if gh_user and gh_user != login: self._json({"ok": False, "msg": f"Token 对应的账号是 {login},与填写的 {gh_user} 不一致"}) return existing = next((a for a in accounts if a["gh_user"] == login), None) if existing: existing["token"] = token existing["private"] = private if label: existing["label"] = label active_id = existing["id"] save_accounts() log(f"GitHub 账号 {login} 的 Token 已更新") self._json({"ok": True, "msg": f"账号 {login} 已存在,Token 已更新并切换为当前账号"}) return acc = {"id": uuid.uuid4().hex[:8], "label": label or login, "gh_user": login, "token": token, "private": private, "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} accounts.append(acc) # 旧版默认账号(无 Token)存在时,添加第一个 Token 账号后自动切过去 cur = active_account() if (not cur) or (not cur.get("token")): active_id = acc["id"] save_accounts() log(f"新增 GitHub 账号 {login}({'私有' if private else '公开'}仓库)") self._json({"ok": True, "msg": f"账号 {login} 验证通过,已添加" + ("并切换为当前账号" if active_id == acc["id"] else "")}) elif path == "/api/accounts/switch": aid = (data.get("id") or "").strip() if not find_account(aid): self._json({"ok": False, "msg": "账号不存在"}) return active_id = aid save_accounts() acc = find_account(aid) log(f"已切换 GitHub 账号: {acc['label']}({acc['gh_user']})") self._json({"ok": True, "msg": f"已切换到 {acc['label']}({acc['gh_user']})"}) elif path == "/api/accounts/remove": aid = (data.get("id") or "").strip() acc = find_account(aid) if not acc: self._json({"ok": False, "msg": "账号不存在"}) return if len(accounts) <= 1: self._json({"ok": False, "msg": "至少保留一个账号"}) return accounts.remove(acc) if active_id == aid: active_id = accounts[0]["id"] save_accounts() log(f"已移除 GitHub 账号 {acc['gh_user']}") self._json({"ok": True, "msg": f"账号 {acc['gh_user']} 已移除(GitHub 仓库不受影响)"}) elif path == "/api/accounts/verify": aid = (data.get("id") or "").strip() acc = find_account(aid) if not acc: self._json({"ok": False, "msg": "账号不存在"}) return if not acc.get("token"): self._json({"ok": True, "msg": f"{acc['gh_user']}:SSH 私钥推送(无需 Token)"}) return ok, login, err = gh_verify_token(acc["token"]) if ok: self._json({"ok": True, "msg": f"Token 有效 ✓({login})"}) else: self._json({"ok": False, "msg": f"Token 校验失败: {err}"}) elif path == "/api/repos/sync": name = data.get("name", "") if not valid_name(name): self._json({"ok": False, "msg": "非法仓库名"}) return with op_lock: if state["syncing"]: self._json({"ok": False, "msg": "已有同步任务正在进行"}) return state["syncing"] = True try: res = sync_repo(name) state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if res.startswith("error"): stats["total_failures"] += 1 save_stats() self._json({"ok": False, "msg": f"{name} {res}"}) elif res == "changed": stats["total_pushes"] += 1 save_stats() self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"}) else: self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"}) finally: state["syncing"] = False elif path == "/api/repos/add": name = (data.get("name") or "").strip() if not valid_name(name): self._json({"ok": False, "msg": "仓库名不合法(仅限字母/数字/._-)"}) return repos = load_repos() if name in repos: self._json({"ok": False, "msg": f"{name} 已在同步列表中"}) return if not gitea_repo_exists(name): self._json({"ok": False, "msg": f"Gitea 上不存在仓库 {cfg['gitea_user']}/{name},请检查名字"}) return acc = active_account() # GitHub 上不存在同名仓库时,按设置自动创建(需 Token) if not github_repo_exists(acc, name): if not (acc.get("token") and cfg.get("auto_create", True)): self._json({"ok": False, "msg": f"GitHub 上不存在 {acc['gh_user']}/{name},且当前账号无法自动创建(需在设置页添加带 Token 的账号并开启自动创建)"}) return ok, msg = github_create_repo(acc, name) if not ok: self._json({"ok": False, "msg": f"自动创建 GitHub 仓库失败: {msg}"}) return log(f"{name}: GitHub 仓库不存在,添加时已自动创建({'私有' if acc.get('private', True) else '公开'})") repos.append(name) save_repos(repos) log(f"{name}: 已加入同步列表") res = sync_repo(name) if res.startswith("error"): self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"}) elif res == "changed": stats["total_pushes"] += 1 save_stats() self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"}) else: self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"}) elif path == "/api/repos/remove": name = (data.get("name") or "").strip() repos = load_repos() if name not in repos: self._json({"ok": False, "msg": f"{name} 不在同步列表中"}) return repos = [r for r in repos if r != name] save_repos(repos) log(f"{name}: 已从同步列表移除") self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"}) else: self._json({"error": "not found"}, 404) def last_real_sync(): try: for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()): if "已同步到 GitHub" in line: return line.strip() except Exception: pass return "尚无记录" def write_pidfile(): try: DATA_DIR.mkdir(parents=True, exist_ok=True) PID_FILE.write_text(str(os.getpid())) except Exception: pass def main(): signal.signal(signal.SIGHUP, signal.SIG_IGN) DATA_DIR.mkdir(parents=True, exist_ok=True) (DATA_DIR / "logs").mkdir(exist_ok=True) # fnOS 解包可能把密钥权限放宽,ssh 会拒绝;守护进程即属主,启动时自愈 try: if SSH_KEY.exists(): os.chmod(SSH_KEY, 0o600) except Exception: pass write_pidfile() atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink()) load_settings() load_accounts() load_panel_state() load_stats() load_repos() acc = active_account() log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, " f"项目 {len(load_repos())} 个, GitHub 账号={acc['label'] if acc else '无'}" f"({acc['gh_user'] if acc else '-'}/{'Token' if acc and acc.get('token') else 'SSH'}), " f"Gitea 用户={cfg['gitea_user']}, 自动创建={'开' if cfg.get('auto_create') else '关'})") threading.Thread(target=loop, daemon=True).start() server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler) log(f"控制面板监听 :{PANEL_PORT}") server.serve_forever() if __name__ == "__main__": main()