romele / git-trace public
62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 12m ago
src/mirror.c
/* M15: repository mirroring / lightweight federation. * * A mirror row says "keep refs/mirror/… in this repo in sync with * <url>". Synchronization is a plain `git fetch` with a constrained * refspec whose destination side is locked to refs/mirror/, so a * hostile or broken upstream can never overwrite local branches. * * Triggers: the CLI (git-trace mirror-sync [o/r]) or the webhook-shaped * endpoint POST /<o>/<r>/mirror-sync. An upstream git-trace instance * registers a push webhook pointing at that path, signed with the * mirror's secret — event-driven federation, no polling daemon. * * Trust boundary: mirror URLs are configured by repo admins and the * fetch runs with daemon privileges. Schemes are restricted to * http/https/git/ssh — file:// is refused outright (it would let an * admin exfiltrate arbitrary local paths into a repo). SSRF against * internal addresses is possible via http/https/git URLs; that is a * documented admin-trust decision, same as outbound webhooks. */ #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <strings.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <sqlite3.h> #include "mirror.h" #include "collab.h" #include "gitx.h" #include "repo.h" #include "util.h" #include "crypto.h" #include "db.h" int mirror_url_ok(const char *url) { static const char *const sch[] = { "http://", "https://", "git://", "ssh://" }; size_t n = strlen(url); if (n == 0 || n > 500 || strcspn(url, " \t\r\n'\"`$\\") != n) return 0; for (size_t i = 0; i < sizeof sch / sizeof sch[0]; i++) { size_t sl = strlen(sch[i]); if (strncmp(url, sch[i], sl) == 0 && url[sl] && url[sl] != '/' && url[sl] != '?') return 1; } /* scp-like syntax: user@host:path — user must be sane */ const char *at = strchr(url, '@'); const char *co = at ? strchr(at, ':') : NULL; if (at && co && at - url >= 1 && at - url <= 64) { for (const char *p = url; p < at; p++) if (!(isalnum((unsigned char)*p) || *p == '.' || *p == '_' || *p == '-')) return 0; return co > at + 1 ? 1 : 0; } return 0; } /* shared refspec character whitelist (plus already consumed) */ static int refspec_chars_ok(const char *spec) { for (const char *p = spec; *p; p++) { unsigned char c = (unsigned char)*p; if (!(isalnum(c) || c == '*' || c == '?' || c == '.' || c == '_' || c == '-' || c == '/' || c == ':' || c == '+')) return 0; } return 1; } int mirror_refspec_ok(const char *spec) { if (!*spec || strlen(spec) > 255) return 0; if (*spec == '+') spec++; const char *colon = strchr(spec, ':'); if (!colon || strchr(colon + 1, ':')) return 0; if (!starts_with(spec, "refs/")) return 0; if (!starts_with(colon + 1, "refs/mirror/")) return 0; /* destination is locked down */ return refspec_chars_ok(spec) && colon - spec > 5 && strlen(colon + 1) > 12; } int mirror_refspec_push_ok(const char *spec) { if (!*spec || strlen(spec) > 255) return 0; if (*spec == '+') spec++; const char *colon = strchr(spec, ':'); if (!colon || strchr(colon + 1, ':')) return 0; /* source is locked to local branches — a push mirror must never * leak refs/mirror (or anything else) as remote branches */ if (!starts_with(spec, "refs/heads/")) return 0; if (!starts_with(colon + 1, "refs/heads/")) return 0; return refspec_chars_ok(spec) && colon - spec > 10 && strlen(colon + 1) > 11; } void mirror_url_mask(const char *url, char *out, size_t n) { /* hide an embedded password: scheme://user:pass@host -> user:*** */ const char *sc = strstr(url, "://"); const char *at = sc ? strchr(sc + 3, '@') : NULL; const char *co = at ? memchr(sc + 3, ':', (size_t)(at - (sc + 3))) : NULL; if (!co) { snprintf(out, n, "%s", url); return; } int pre = (int)(co + 1 - url); snprintf(out, n, "%.*s***%s", pre, url, at); } /* run `timeout 120 git -C <rdir> <op> -v <url> <spec>` capturing the * child's stdout+stderr into log. op is "fetch" (pull mirrors) or * "push" (push mirrors); pull adds --no-tags --prune. */ static int remote_run(const char *rdir, const char *op, const char *url, const char *spec, char *log, size_t lcap) { int pfd[2]; if (pipe(pfd) < 0) return -1; pid_t pid = fork(); if (pid < 0) { close(pfd[0]); close(pfd[1]); return -1; } if (pid == 0) { dup2(pfd[1], 1); dup2(pfd[1], 2); close(pfd[0]); close(pfd[1]); if (chdir(rdir) < 0) _exit(126); setenv("GIT_TERMINAL_PROMPT", "0", 1); setenv("GIT_HTTP_LOW_SPEED_LIMIT", "1000", 1); setenv("GIT_HTTP_LOW_SPEED_TIME", "30", 1); if (strcmp(op, "fetch") == 0) execlp("timeout", "timeout", "120", "git", "fetch", "--no-tags", "--prune", "-v", url, spec, (char *)NULL); execlp("timeout", "timeout", "120", "git", "push", "-v", url, spec, (char *)NULL); _exit(127); } close(pfd[1]); size_t n = 0; for (;;) { char tmp[4096]; ssize_t rd = read(pfd[0], tmp, sizeof tmp); if (rd <= 0) break; if (n + (size_t)rd < lcap) { memcpy(log + n, tmp, (size_t)rd); n += (size_t)rd; } } close(pfd[0]); if (lcap) log[n < lcap ? n : lcap - 1] = '\0'; int st = 0; waitpid(pid, &st, 0); return WIFEXITED(st) ? WEXITSTATUS(st) : -1; } static int mirror_count_refs(const char *rdir) { struct buf out; buf_init(&out); const char *argv[] = { "git", "for-each-ref", "--format=%(refname)", "refs/mirror", NULL }; git_capture(rdir, argv, &out); int nrefs = 0; for (char *p = out.data; p && *p; p++) if (*p == '\n') nrefs++; buf_free(&out); return nrefs; } static void sync_row(struct app_ctx *app, sqlite3_int64 mid, sqlite3_int64 repo_id, const char *rdir, const char *url, const char *spec) { char log[4096]; int rc = remote_run(rdir, "fetch", url, spec, log, sizeof log - 1); int nrefs = rc == 0 ? mirror_count_refs(rdir) : -1; char *e = log + strlen(log); while (e > log && (e[-1] == '\n' || e[-1] == ' ' || e[-1] == '\t')) *--e = '\0'; sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "UPDATE repo_mirrors SET last_ts=strftime('%s','now')," " last_ok=?, last_err=?, last_refs=? WHERE id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, rc == 0); sqlite3_bind_text(st, 2, log, -1, SQLITE_TRANSIENT); sqlite3_bind_int(st, 3, nrefs < 0 ? 0 : nrefs); sqlite3_bind_int64(st, 4, mid); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, 0, "mirror", NULL, repo_id, "mirror_sync", "rc=%d refs=%d", rc, nrefs); } int gt_mirror_sync(struct app_ctx *app, const char *owner, const char *name) { sqlite3_stmt *st; if (owner && name) sqlite3_prepare_v2(app->db, "SELECT m.id,m.url,m.refspec,r.id,m.repo_id,u.name,r.name" " FROM repo_mirrors m" " JOIN repos r ON r.id=m.repo_id" " JOIN users u ON u.id=r.owner_id" " WHERE m.enabled=1 AND m.direction='pull'" " AND u.name=? AND r.name=?", -1, &st, NULL); else sqlite3_prepare_v2(app->db, "SELECT m.id,m.url,m.refspec,r.id,m.repo_id,u.name,r.name" " FROM repo_mirrors m" " JOIN repos r ON r.id=m.repo_id" " JOIN users u ON u.id=r.owner_id WHERE m.enabled=1" " AND m.direction='pull'", -1, &st, NULL); if (owner && name) { sqlite3_bind_text(st, 1, owner, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); } int fails = 0; while (sqlite3_step(st) == SQLITE_ROW) { sqlite3_int64 mid = sqlite3_column_int64(st, 0); const char *url = (const char *)sqlite3_column_text(st, 1); const char *spec = (const char *)sqlite3_column_text(st, 2); sqlite3_int64 rid = sqlite3_column_int64(st, 4); const char *o = (const char *)sqlite3_column_text(st, 5); const char *n = (const char *)sqlite3_column_text(st, 6); /* copy row text: sync_row writes to the db while this * SELECT is still iterating */ char ubuf[512], sbuf[256], obuf[128], nbuf[128]; snprintf(ubuf, sizeof ubuf, "%s", url ? url : ""); snprintf(sbuf, sizeof sbuf, "%s", spec ? spec : ""); snprintf(obuf, sizeof obuf, "%s", o ? o : ""); snprintf(nbuf, sizeof nbuf, "%s", n ? n : ""); printf("mirror %s/%s <- %s\n", obuf, nbuf, ubuf); char rdir[PATH_MAX]; if (repo_path(rdir, sizeof rdir, app->repos_root, obuf, nbuf) == 0) sync_row(app, mid, rid, rdir, ubuf, sbuf); /* fetch rc is recorded in the row; read it back for exit code */ sqlite3_stmt *q; sqlite3_prepare_v2(app->db, "SELECT last_ok FROM repo_mirrors WHERE id=?", -1, &q, NULL); sqlite3_bind_int64(q, 1, mid); if (sqlite3_step(q) == SQLITE_ROW && !sqlite3_column_int(q, 0)) fails++; sqlite3_finalize(q); } sqlite3_finalize(st); return fails; } /* --- push mirrors (direction='push') ------------------------------- * Triggered by the local post-receive hook after refs/heads moves. * Loop safety: pushing an up-to-date ref runs no remote hooks, so a * two-way A<->B configuration converges in one extra hop; genuinely * divergent mirrors conflict only under concurrent human pushes. * Credentials may live inside the URL (https://user:pass@host) — * the stored error log is scrubbed before it can leak them. */ static void scrub_creds(char *log, const char *url) { const char *sc = strstr(url, "://"); const char *at = sc ? strchr(sc + 3, '@') : NULL; const char *co = at ? memchr(sc + 3, ':', (size_t)(at - (sc + 3))) : NULL; if (!co) return; /* rewrite "user:pass@" occurrences as "user:***@" */ char pat[160]; int plen = (int)(at - (sc + 3)); if (plen <= 0 || plen >= (int)sizeof pat) return; memcpy(pat, sc + 3, (size_t)plen); pat[plen] = '\0'; size_t unlen = (size_t)(co - (sc + 3)); char rep[170]; snprintf(rep, sizeof rep, "%.*s***@", (int)unlen, sc + 3); char *p = log; while ((p = strstr(p, pat)) != NULL) { size_t tlen = strlen(p + plen); memmove(p + strlen(rep), p + plen, tlen + 1); memcpy(p, rep, strlen(rep)); p += strlen(rep); /* replacement never re-matches pat */ } } static void push_row(struct app_ctx *app, sqlite3_int64 mid, sqlite3_int64 repo_id, const char *rdir, const char *url, const char *spec) { char log[4096]; int rc = remote_run(rdir, "push", url, spec, log, sizeof log - 1); scrub_creds(log, url); /* how many local heads does the spec cover */ struct buf out; buf_init(&out); const char *argv[] = { "git", "for-each-ref", "--format=%(refname)", "refs/heads", NULL }; git_capture(rdir, argv, &out); int nrefs = 0; for (char *p = out.data; p && *p; p++) if (*p == '\n') nrefs++; buf_free(&out); char *e = log + strlen(log); while (e > log && (e[-1] == '\n' || e[-1] == ' ' || e[-1] == '\t')) *--e = '\0'; sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "UPDATE repo_mirrors SET last_ts=strftime('%s','now')," " last_ok=?, last_err=?, last_refs=? WHERE id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, rc == 0); sqlite3_bind_text(st, 2, log, -1, SQLITE_TRANSIENT); sqlite3_bind_int(st, 3, nrefs); sqlite3_bind_int64(st, 4, mid); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, 0, "mirror", NULL, repo_id, "mirror_push", "rc=%d refs=%d", rc, nrefs); } int gt_mirror_push(struct app_ctx *app, const char *owner, const char *name) { sqlite3_stmt *st; if (owner && name) sqlite3_prepare_v2(app->db, "SELECT m.id,m.url,m.refspec,m.repo_id,u.name,r.name" " FROM repo_mirrors m" " JOIN repos r ON r.id=m.repo_id" " JOIN users u ON u.id=r.owner_id" " WHERE m.enabled=1 AND m.direction='push'" " AND u.name=? AND r.name=?", -1, &st, NULL); else sqlite3_prepare_v2(app->db, "SELECT m.id,m.url,m.refspec,m.repo_id,u.name,r.name" " FROM repo_mirrors m" " JOIN repos r ON r.id=m.repo_id" " JOIN users u ON u.id=r.owner_id WHERE m.enabled=1" " AND m.direction='push'", -1, &st, NULL); if (owner && name) { sqlite3_bind_text(st, 1, owner, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); } int fails = 0; while (sqlite3_step(st) == SQLITE_ROW) { sqlite3_int64 mid = sqlite3_column_int64(st, 0); const char *url = (const char *)sqlite3_column_text(st, 1); const char *spec = (const char *)sqlite3_column_text(st, 2); sqlite3_int64 rid = sqlite3_column_int64(st, 3); const char *o = (const char *)sqlite3_column_text(st, 4); const char *n = (const char *)sqlite3_column_text(st, 5); char ubuf[512], sbuf[256], obuf[128], nbuf[128]; snprintf(ubuf, sizeof ubuf, "%s", url ? url : ""); snprintf(sbuf, sizeof sbuf, "%s", spec ? spec : ""); snprintf(obuf, sizeof obuf, "%s", o ? o : ""); snprintf(nbuf, sizeof nbuf, "%s", n ? n : ""); char masked[512]; mirror_url_mask(ubuf, masked, sizeof masked); printf("push-mirror %s/%s -> %s\n", obuf, nbuf, masked); char rdir[PATH_MAX]; if (repo_path(rdir, sizeof rdir, app->repos_root, obuf, nbuf) == 0) push_row(app, mid, rid, rdir, ubuf, sbuf); sqlite3_stmt *q; sqlite3_prepare_v2(app->db, "SELECT last_ok FROM repo_mirrors WHERE id=?", -1, &q, NULL); sqlite3_bind_int64(q, 1, mid); if (sqlite3_step(q) == SQLITE_ROW && !sqlite3_column_int(q, 0)) fails++; sqlite3_finalize(q); } sqlite3_finalize(st); return fails; } void mirror_trigger(struct app_ctx *app, struct request *r, const char *owner, const char *name) { if (strcmp(r->method, "POST") != 0) { http_error(r, 405, "method not allowed"); return; } sqlite3_int64 repo_id = 0; { sqlite3_stmt *q; sqlite3_prepare_v2(app->db, "SELECT r.id FROM repos r JOIN users u ON u.id=r.owner_id" " WHERE u.name=? AND r.name=?", -1, &q, NULL); sqlite3_bind_text(q, 1, owner, -1, SQLITE_TRANSIENT); sqlite3_bind_text(q, 2, name, -1, SQLITE_TRANSIENT); if (sqlite3_step(q) == SQLITE_ROW) repo_id = sqlite3_column_int64(q, 0); sqlite3_finalize(q); } /* collect the secrets of enabled mirrors before touching the * signature: a repo with no mirrors must look exactly like a * repo that does not exist */ char secs[8][200]; int nsec = 0; if (repo_id) { sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT secret FROM repo_mirrors" " WHERE repo_id=? AND enabled=1 AND secret<>''" " AND direction='pull'", -1, &st, NULL); sqlite3_bind_int64(st, 1, repo_id); while (sqlite3_step(st) == SQLITE_ROW && nsec < 8) { const char *s = (const char *)sqlite3_column_text(st, 0); snprintf(secs[nsec++], sizeof secs[0], "%s", s ? s : ""); } sqlite3_finalize(st); } if (!repo_id || !nsec) { http_error(r, 404, "no such repository"); return; } /* req_header values are not NUL-terminated — the value ends at * \r or \n. Validate exactly "sha256=" + 64 hex digits. */ const char *sig = req_header(r, "x-git-trace-signature"); int sigok = sig && strncmp(sig, "sha256=", 7) == 0; if (sigok) { for (int i = 7; i < 71 && sigok; i++) sigok = isxdigit((unsigned char)sig[i]) != 0; sigok = sigok && (sig[71] == '\r' || sig[71] == '\n' || sig[71] == '\0'); } if (!sigok) { http_error(r, 403, "bad signature"); return; } size_t blen = 0; char *body = gt_read_form(r, 65536, &blen); if (!body) { http_error(r, 400, "bad request"); return; } int ok = 0; for (int i = 0; i < nsec && !ok; i++) { uint8_t mac[32]; char hex[65]; gt_hmac_sha256((const uint8_t *)secs[i], strlen(secs[i]), (const uint8_t *)body, blen, mac); to_hex(mac, 32, hex); if (strncasecmp(hex, sig + 7, 64) == 0) ok = 1; } free(body); if (!ok) { http_error(r, 403, "bad signature"); return; } char arg[300]; snprintf(arg, sizeof arg, "%s/%s", owner, name); gt_spawn_task_arg("mirror-sync", arg); db_audit(app->db, 0, "webhook", r->remote_ip, repo_id, "mirror_trigger", "%s/%s", owner, name); const char *msg = "sync queued\n"; http_respond(r, 202, "text/plain", msg, strlen(msg)); }