#!/usr/bin/env python3 # ============================================================ # GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板 # 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以 # setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync) # 页面: / 控制面板(开关/立即同步/快捷入口) # /status 项目状态(每个仓库的详细同步状态) # /manage 项目管理(从 Gitea 快捷添加/移除同步项目) # ============================================================ import atexit import json import os import re import signal import subprocess import threading import time from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path 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" 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 = "http://127.0.0.1:3000/cbb" GITEA_API = "http://127.0.0.1:3000/api/v1" GH_USER = "cbb2625274797" GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000") GH_URL = f"https://github.com/{GH_USER}?tab=repositories" DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController", "Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"] 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" 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 git(args, timeout=300): return subprocess.run(["git"] + 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,返回 (ok, json)""" try: r = subprocess.run(["curl", "-sS", "-m", "10", f"{GITEA_API}{path}"], 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/cbb/{name}") return ok and isinstance(d, dict) and d.get("full_name") == f"cbb/{name}" def github_repo_exists(name): try: r = git(["ls-remote", f"git@github.com:{GH_USER}/{name}.git", "HEAD"], timeout=30) return r.returncode == 0 except Exception: return False def list_gitea_repos(): ok, d = gitea_api("/users/cbb/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: 非法仓库名" 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}/{name}.git", str(repo)]) if r.returncode != 0: return "error: 克隆失败 " + (r.stderr or "")[:120] log(f"{name}: 首次克隆完成") r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"]) if r.returncode != 0: return "error: fetch 失败 " + (r.stderr or "")[:120] cur = fingerprint(repo) if cur is None: return "error: 读取 refs 失败" st = STATE_DIR / name prev = st.read_text().strip() if st.exists() else "" if cur == prev: return "same" p = git(["-C", str(repo), "push", "--mirror", f"git@github.com:{GH_USER}/{name}.git"]) if p.returncode != 0: return "error: 推送失败 " + (p.stderr or p.stdout or "")[:120] STATE_DIR.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" + (f" | {detail}" if detail else "")) return "changed" except Exception as e: return f"error: {e}" 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_DIR / 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 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() 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_online": bool(gitea_all), "available": [r for r in gitea_all if r not in repos], } 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} /* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */ @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 "")) return f""" {title} · GiteaHubSync

🔄 {title}

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

同步日志(最近 80 行)

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

项目明细

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

快捷添加项目

下面可直接点击你 Gitea 上还未同步的仓库(需 GitHub 端已存在同名仓库):

当前同步项目(0)

项目状态最近推送同步操作
移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。
""" # ---------------- 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 == "/": body = (PAGE_HOME.replace("__GITEA__", GITEA_UI) .replace("__GH__", GH_URL) .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 == "/api/state": repos = load_repos() 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_DIR / r).exists()), "uptime": fmt_uptime(time.time() - START_TS), "last_real": last_real_sync()}) elif path == "/api/status": self._json(global_status()) elif path == "/api/log": self._json({"lines": tail_log(80)}) else: self._json({"error": "not found"}, 404) def do_POST(self): 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/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 上不存在仓库 cbb/{name},请检查名字"}) return if not github_repo_exists(name): self._json({"ok": False, "msg": f"GitHub 上还没有 {GH_USER}/{name},请先在 GitHub 建好同名仓库(建议私有)再添加"}) return 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_panel_state() load_stats() load_repos() log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, 项目 {len(load_repos())} 个)") 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()