romele / git-trace public
62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 11m ago
src/main.c
#include "auth.h" #include "cgi.h" #include "ci.h" #include "collab.h" #include "crypto.h" #include "db.h" #include "export.h" #include "gitx.h" #include "hooks.h" #include "mirror.h" #include "ops.h" #include "httpd.h" #include "patchset.h" #include "ratelimit.h" #include "repo.h" #include "ssh.h" #include "util.h" #include "web.h" #include "webhook.h" #include <dirent.h> #include <errno.h> #include <ftw.h> #include <limits.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/stat.h> #include <sys/wait.h> #include <time.h> #include <unistd.h> /* ---------------- git smart/dumb HTTP routing ---------------- */ /* * Basic auth with per-IP throttling. Returns uid, 0 when no * credentials were sent, or -1 when a 429 was already emitted. */ static int basic_auth_throttled(struct app_ctx *c, struct request *r) { if (!req_header(r, "authorization")) return 0; int d = rl_check(r->remote_ip); if (d < 0) { http_error(r, 429, "too many failed attempts"); return -1; } if (d > 0) usleep((useconds_t)d * 1000); int uid = auth_basic(c->db, r); if (uid < 0) { /* credentials were sent but rejected: answer 401 rather than silently degrading to anonymous (a typo'd password must not look like a successful auth) */ rl_fail(r->remote_ip); http_unauthorized(r, "invalid credentials"); return -1; } if (uid > 0) rl_ok(r->remote_ip); else rl_fail(r->remote_ip); return uid; } static void git_route(struct app_ctx *c, struct request *r, const char *owner, const char *name, const char *rest) { if (!owner_ok(owner) || !name_ok(name)) { http_error(r, 404, "bad repository name"); return; } if (!repo_exists(c->repos_root, owner, name)) { http_error(r, 404, "no such repository"); return; } if (!git_rest_ok(rest)) { http_error(r, 400, "bad git path"); return; } int is_get = strcmp(r->method, "GET") == 0; int is_post = strcmp(r->method, "POST") == 0; /* is this request part of a push? */ int wants_push = 0; if (is_post && strcmp(rest, "/git-receive-pack") == 0) wants_push = 1; if (is_get && strcmp(rest, "/info/refs") == 0) { char service[64] = ""; req_query(r, "service", service, sizeof service); if (strcmp(service, "git-receive-pack") == 0) wants_push = 1; } /* authenticate via HTTP Basic; uid 0 = anonymous */ int uid = basic_auth_throttled(c, r); if (uid < 0) return; int perm = perm_rank(repo_perm(c->db, owner, name, uid)); int need = wants_push ? 2 : 1; /* 'w' or 'r' */ if (perm < need) { if (uid > 0) http_error(r, 403, wants_push ? "no write permission" : "no read permission"); else http_unauthorized(r, wants_push ? "authentication required to push" : "authentication required"); return; } if (is_get) { /* info/refs?service=git-upload-pack or dumb-protocol object GETs */ cgi_git_backend(r, c->repos_root, r->path, uid > 0 ? r->user_name : NULL); return; } if (is_post) { if (strcmp(rest, "/git-upload-pack") == 0 || strcmp(rest, "/git-receive-pack") == 0) { if (r->content_length < 0) { http_error(r, 411, "no request body framing"); return; } cgi_git_backend(r, c->repos_root, r->path, uid > 0 ? r->user_name : NULL); return; } http_error(r, 404, "unknown git endpoint"); return; } http_error(r, 405, "method not allowed"); } /* ---------------- top-level dispatch ---------------- */ static void route(void *vctx, struct request *r) { struct app_ctx *c = vctx; auth_identify(c->db, r); /* session cookie -> r->user_* */ r->open_reg = c->open_reg; if (r->user_id == 0 && basic_auth_throttled(c, r) < 0) /* curl -u on web pages too */ return; if (strcmp(r->path, "/login") == 0) { web_login(c, r); return; } if (strcmp(r->path, "/logout") == 0) { web_logout(c, r); return; } if (strcmp(r->path, "/register") == 0) { web_register(c, r); return; } if (strcmp(r->path, "/audit") == 0) { web_audit(c, r); return; } if (strcmp(r->path, "/new") == 0) { web_new(c, r); return; } if (strcmp(r->path, "/account") == 0) { web_account(c, r); return; } if (strcmp(r->path, "/feed.xsl") == 0) { web_feed_xsl(c, r); return; } if (strcmp(r->path, "/") == 0) { if (strcmp(r->method, "GET") != 0) { http_error(r, 405, "method not allowed"); return; } web_index(c, r); return; } /* split "/owner/repo[.git][/rest]" */ const char *s1 = r->path + 1; const char *s2 = strchr(s1, '/'); if (!s2) { http_error(r, 404, "not found"); return; } char owner[64]; size_t ol = (size_t)(s2 - s1); if (ol == 0 || ol >= sizeof owner) { http_error(r, 404, "not found"); return; } memcpy(owner, s1, ol); owner[ol] = '\0'; const char *s3 = s2 + 1; const char *s4 = strchr(s3, '/'); char repo[64]; size_t rl = s4 ? (size_t)(s4 - s3) : strlen(s3); if (rl == 0 || rl >= sizeof repo) { http_error(r, 404, "not found"); return; } memcpy(repo, s3, rl); repo[rl] = '\0'; const char *rest = s4 ? s4 : ""; /* includes leading '/' */ if (ends_with(repo, ".git")) { repo[rl - 4] = '\0'; /* strip ".git" */ if (rest[0] == '\0') { http_error(r, 404, "not found"); return; } git_route(c, r, owner, repo, rest); return; } web_repo(c, r, owner, repo, rest); } /* ---------------- mkrepo ---------------- */ static int cmd_mkrepo(struct app_ctx *c, const char *owner, const char *name, const char *desc, int is_private) { /* owner "_" = ownerless/public repo */ if (!owner_ok(owner)) { fprintf(stderr, "git-trace: invalid owner name\n"); fprintf(stderr, "allowed: [A-Za-z0-9][A-Za-z0-9._-]{0,62}\n"); return 1; } if (!name_ok(name)) { fprintf(stderr, "git-trace: invalid repo name\n"); fprintf(stderr, "allowed: [A-Za-z0-9][A-Za-z0-9._-]{0,62}\n"); return 1; } int owner_uid = 0; if (strcmp(owner, "_") != 0) { owner_uid = auth_user_id(c->db, owner); if (owner_uid < 0) { fprintf(stderr, "git-trace: unknown user '%s'" " (create it with useradd, or use '_' for ownerless)\n", owner); return 1; } } if (repo_create(c, owner_uid, owner, name, desc, is_private) != 0) { fprintf(stderr, "git-trace: cannot create %s/%s.git\n", owner, name); return 1; } sqlite3_stmt *st; int rid = 0; if (sqlite3_prepare_v2(c->db, "SELECT id FROM repos WHERE name=? AND" " (owner_id=? OR (owner_id IS NULL AND ?=0))", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int(st, 2, owner_uid); sqlite3_bind_int(st, 3, owner_uid); if (sqlite3_step(st) == SQLITE_ROW) rid = sqlite3_column_int(st, 0); sqlite3_finalize(st); } db_audit(c->db, 0, getenv("USER") ? getenv("USER") : "cli", NULL, rid, "repo_create", "%s/%s.git%s", owner, name, is_private ? " (private)" : ""); printf("created %s/%s.git (%s)\n", owner, name, is_private ? "private" : "public"); return 0; } static int read_stdin_secret(char *out, size_t n) { fprintf(stderr, "password: "); if (!fgets(out, (int)n, stdin)) { fprintf(stderr, "\ngit-trace: no password on stdin\n"); return -1; } out[strcspn(out, "\r\n")] = '\0'; if (!out[0]) { fprintf(stderr, "git-trace: empty password\n"); return -1; } return 0; } static int cmd_useradd(struct app_ctx *c, const char *name, const char *email, int is_admin) { if (!name_ok(name)) { fprintf(stderr, "git-trace: invalid user name\n"); return 1; } char pw[256]; if (read_stdin_secret(pw, sizeof pw) != 0) return 1; if (auth_user_add(c->db, name, pw, email, is_admin) != 0) { memset(pw, 0, sizeof pw); fprintf(stderr, "git-trace: cannot create user '%s'" " (already exists?)\n", name); return 1; } memset(pw, 0, sizeof pw); printf("user '%s' created\n", name); return 0; } static int cmd_passwd(struct app_ctx *c, const char *name) { char pw[256]; if (read_stdin_secret(pw, sizeof pw) != 0) return 1; int rc = auth_user_passwd(c->db, name, pw); memset(pw, 0, sizeof pw); if (rc != 0) { fprintf(stderr, "git-trace: no such user '%s'\n", name); return 1; } printf("password updated\n"); return 0; } static int cmd_mktoken(struct app_ctx *c, const char *name, const char *label) { char tok[80]; if (auth_mktoken(c->db, name, label, tok, sizeof tok) != 0) { fprintf(stderr, "git-trace: no such user '%s'\n", name); return 1; } printf("%s\n", tok); fprintf(stderr, "store it now: it is shown once.\n"); return 0; } /* ---------------- storage quota ---------------- */ static long long du_acc; static int du_cb(const char *p, const struct stat *sb, int tf, struct FTW *w) { (void)p; (void)tf; (void)w; du_acc += sb->st_blocks; return 0; } /* disk usage in KiB, du -sk semantics via st_blocks (512 B units) */ static long long du_kb(const char *path) { du_acc = 0; if (nftw(path, du_cb, 16, FTW_PHYS) < 0) return -1; return du_acc / 2; } /* * cwd must be <repos_root>/<owner>/<name>.git — the layout hooks run * in. Fills owner/name; -1 for anything outside the managed tree. */ static int repo_parts_from_cwd(struct app_ctx *c, char owner[64], char name[80]) { char rdir[PATH_MAX]; if (!realpath(".", rdir)) return -1; size_t rootlen = strlen(c->repos_root); if (strncmp(rdir, c->repos_root, rootlen) != 0 || rdir[rootlen] != '/') return -1; const char *rel = rdir + rootlen + 1; /* "owner/name.git" */ const char *sl = strchr(rel, '/'); if (!sl) return -1; size_t ol = (size_t)(sl - rel); if (ol == 0 || ol >= 64) return -1; memcpy(owner, rel, ol); owner[ol] = '\0'; size_t nl = strlen(sl + 1); if (nl <= 4 || strcmp(sl + 1 + nl - 4, ".git") != 0 || nl - 4 >= 80) return -1; memcpy(name, sl + 1, nl - 4); name[nl - 4] = '\0'; return 0; } /* * Invoked by hooks/update before quota-check (cwd = the bare repo). * Enforces branch_rules: a push to a branch matching a rule requires * repo rank >= min_role, plus optional fast-forward-only update and * no-delete constraints. Rules only tighten access — no matching * rule falls through to the built-in policy. Local pushes (empty * REMOTE_USER) bypass: filesystem access already trumps any policy. */ static int cmd_branch_check(struct app_ctx *c, const char *ref, const char *old_sha, const char *new_sha) { char owner[64], name[80]; if (!ref || repo_parts_from_cwd(c, owner, name) != 0) return 0; if (!starts_with(ref, "refs/heads/")) return 0; /* branches only, no tags */ const char *branch = ref + 11; const char *actor = getenv("REMOTE_USER"); if (!actor || !*actor) return 0; /* local push bypass */ int repo_id = gt_repo_rowid(c->db, owner, name); if (repo_id < 0) return 0; sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT pattern,min_role,ff_only,no_delete" " FROM branch_rules WHERE repo_id=?", -1, &st, NULL) != SQLITE_OK) return 0; sqlite3_bind_int(st, 1, repo_id); int need = 0, ff_only = 0, no_delete = 0; while (sqlite3_step(st) == SQLITE_ROW) { const char *pat = (const char *)sqlite3_column_text(st, 0); if (!pat || !gt_glob(pat, branch)) continue; const char *mr = (const char *)sqlite3_column_text(st, 1); int r = mr ? perm_rank(mr[0]) : 0; if (r > need) need = r; ff_only |= sqlite3_column_int(st, 2); no_delete |= sqlite3_column_int(st, 3); } sqlite3_finalize(st); if (!need) return 0; /* no matching rules */ const char *zero = "0000000000000000000000000000000000000000"; int del = !new_sha || strcmp(new_sha, zero) == 0; int uid = auth_user_id(c->db, actor); int rank = perm_rank(repo_perm(c->db, owner, name, uid)); int deny = 0; const char *why = NULL; if (del) { deny = no_delete; why = "protected branch: deletion denied"; } else if (rank < need) { deny = 1; why = need >= 3 ? "protected branch: push requires admin" : "protected branch: push requires write"; } else if (ff_only && old_sha && strcmp(old_sha, zero) != 0) { struct buf out; buf_init(&out); deny = git_capture(".", (const char *const[]){ "git", "merge-base", "--is-ancestor", old_sha, new_sha, NULL}, &out) != 0; buf_free(&out); why = "protected branch: non-fast-forward push denied"; } if (!deny) return 0; fprintf(stderr, "git-trace: %s\n", why); if (!del && rank < need) fprintf(stderr, "git-trace: submit a merge request or a" " patch set instead\n"); db_audit(c->db, uid > 0 ? uid : 0, actor, NULL, repo_id, "push_denied", "%s: %s", ref, why); return 1; } /* * post-receive: stdin carries "old new ref" per accepted ref update. * Queue a 'push' delivery per ref, then hand off to the detached * dispatcher — the push itself must never wait on outbound HTTP. */ static int cmd_hook_post(struct app_ctx *c) { char owner[64], name[80]; if (repo_parts_from_cwd(c, owner, name) != 0) return 0; long repo_id = gt_repo_rowid(c->db, owner, name); if (repo_id < 0) return 0; const char *actor = getenv("REMOTE_USER"); if (!actor) actor = ""; char on[200]; snprintf(on, sizeof on, "%s/%s", owner, name); /* CI opt-in lives on the repos row */ int ci_on = 0; sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT ci_enabled FROM repos WHERE id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, repo_id); if (sqlite3_step(st) == SQLITE_ROW) ci_on = sqlite3_column_int(st, 0); sqlite3_finalize(st); } char line[512]; int queued = 0, ciq = 0, heads = 0; while (fgets(line, sizeof line, stdin)) { char old[80], new[80], ref[200]; if (sscanf(line, "%79s %79s %199s", old, new, ref) != 3) continue; if (starts_with(ref, "refs/heads/") && strcmp(old, new) != 0) heads++; struct buf p; buf_init(&p); buf_puts(&p, "{\"event\":\"push\",\"repo\":\""); json_put_esc(&p, on, strlen(on)); buf_puts(&p, "\",\"ref\":\""); json_put_esc(&p, ref, strlen(ref)); buf_puts(&p, "\",\"old\":\""); json_put_esc(&p, old, strlen(old)); buf_puts(&p, "\",\"new\":\""); json_put_esc(&p, new, strlen(new)); buf_puts(&p, "\",\"actor\":\""); json_put_esc(&p, actor, strlen(actor)); buf_puts(&p, "\"}"); if (p.data && wh_enqueue(c->db, repo_id, "push", p.data) > 0) queued++; buf_free(&p); /* branch pushes on a CI-enabled repo queue a build; the * pipeline script still comes from the default branch */ if (ci_on && starts_with(ref, "refs/heads/") && strcmp(new, "0000000000000000000000000000000000000000")) { if (sqlite3_prepare_v2(c->db, "INSERT INTO ci_runs(repo_id,sha,branch,actor)" " VALUES(?,?,?,?)", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, repo_id); sqlite3_bind_text(st, 2, new, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, ref + 11, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, actor, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) == SQLITE_DONE) ciq++; sqlite3_finalize(st); } } } if (queued > 0) wh_dispatch_async(c); if (ciq > 0) gt_spawn_task("ci-run"); /* push mirrors: a real heads/ update fans out to configured * remotes. convergence is structural — an up-to-date push * runs no remote hooks, so A<->B cycles die in one hop. */ if (heads > 0) { int np = 0; if (sqlite3_prepare_v2(c->db, "SELECT count(*) FROM repo_mirrors WHERE repo_id=?" " AND enabled=1 AND direction='push'", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, repo_id); if (sqlite3_step(st) == SQLITE_ROW) np = sqlite3_column_int(st, 0); sqlite3_finalize(st); } if (np > 0) gt_spawn_task_arg("mirror-push", on); } return 0; } /* * Rewrite hooks/update in every managed bare repo — needed * after upgrading git-trace so existing repos pick up new policy * (e.g. branch_rules enforcement) baked into the hook script. */ static int cmd_rehook(struct app_ctx *c) { DIR *od = opendir(c->repos_root); if (!od) { perror("rehook"); return 1; } int n = 0, bad = 0; struct dirent *oe; while ((oe = readdir(od))) { if (oe->d_name[0] == '.') continue; char odir[PATH_MAX]; if (snprintf(odir, sizeof odir, "%s/%s", c->repos_root, oe->d_name) >= (int)sizeof odir) continue; DIR *rd = opendir(odir); if (!rd) continue; struct dirent *re; while ((re = readdir(rd))) { size_t l = strlen(re->d_name); if (l <= 4 || strcmp(re->d_name + l - 4, ".git") != 0) continue; char rdir[PATH_MAX]; if (snprintf(rdir, sizeof rdir, "%s/%s", odir, re->d_name) >= (int)sizeof rdir) continue; if (repo_install_hooks(c, rdir) == 0) { printf("%s/%s\n", oe->d_name, re->d_name); n++; } else { bad++; } } closedir(rd); } closedir(od); fprintf(stderr, "rehook: %d updated, %d failed\n", n, bad); return bad ? 1 : 0; } /* * Invoked by hooks/update during a push (cwd = the bare repo). * Fails open for anything outside the managed tree — same posture as * repo_perm for unregistered repos — but enforces the owner's * account-wide quota on every registered, owned repository. */ static int cmd_quota_check(struct app_ctx *c, const char *ref, const char *new_sha) { char owner[64], name[80]; if (repo_parts_from_cwd(c, owner, name) != 0) return 0; sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT r.id, r.owner_id, u.quota_kb FROM repos r" " LEFT JOIN users u ON u.id=r.owner_id" " WHERE r.name=? AND (u.name=? OR (r.owner_id IS NULL AND ?='_'))", -1, &st, NULL) != SQLITE_OK) return 0; sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 2, owner, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, owner, -1, SQLITE_TRANSIENT); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return 0; /* unregistered repo */ } int repo_id = sqlite3_column_int(st, 0); int owner_id = sqlite3_column_type(st, 1) == SQLITE_NULL ? 0 : sqlite3_column_int(st, 1); long long quota = sqlite3_column_type(st, 2) == SQLITE_NULL ? 0 : sqlite3_column_int64(st, 2); sqlite3_finalize(st); /* journal the push: this hook runs last, so reaching here means * every earlier policy check has already passed */ const char *actor = getenv("REMOTE_USER"); int auid = actor ? auth_user_id(c->db, actor) : 0; db_audit(c->db, auid > 0 ? auid : 0, actor ? actor : "local", NULL, repo_id, "push", "%s -> %.12s", ref ? ref : "?", new_sha ? new_sha : "?"); if (!owner_id || quota <= 0) return 0; /* ownerless or unlimited */ long long incoming = 0; const char *qp = getenv("GIT_QUARANTINE_PATH"); if (qp && *qp) { char qr[PATH_MAX]; if (realpath(qp, qr)) { long long q = du_kb(qr); if (q > 0) incoming = q; } } long long total = du_kb("."); if (total < 0) return 0; long long current = total - incoming; /* pre-push size */ if (current < 0) current = 0; /* refresh this repo's cached size, then sum the account */ if (sqlite3_prepare_v2(c->db, "UPDATE repos SET size_kb=? WHERE id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, current); sqlite3_bind_int(st, 2, repo_id); sqlite3_step(st); sqlite3_finalize(st); } long long used = 0; if (sqlite3_prepare_v2(c->db, "SELECT COALESCE(SUM(size_kb),0) FROM repos WHERE owner_id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int(st, 1, owner_id); if (sqlite3_step(st) == SQLITE_ROW) used = sqlite3_column_int64(st, 0); sqlite3_finalize(st); } if (used + incoming > quota) { db_audit(c->db, auid > 0 ? auid : 0, actor ? actor : "local", NULL, repo_id, "push_denied", "%s: %lld KiB used + %lld KiB incoming > %lld KiB", ref ? ref : "?", used, incoming, quota); fprintf(stderr, "git-trace: storage quota exceeded for this account\n" "git-trace: used %lld KiB + incoming %lld KiB" " > quota %lld KiB\n", used, incoming, quota); return 1; } return 0; } static int cmd_quota(struct app_ctx *c, const char *user, const char *mb) { int uid = auth_user_id(c->db, user); if (uid < 0) { fprintf(stderr, "git-trace: no such user '%s'\n", user); return 1; } if (mb) { char *end = NULL; long long v = strtoll(mb, &end, 10); if (!end || *end || v < 0) { fprintf(stderr, "git-trace: bad quota '%s' (MiB, 0=unlimited)\n", mb); return 1; } sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "UPDATE users SET quota_kb=? WHERE id=?", -1, &st, NULL) != SQLITE_OK) return 1; sqlite3_bind_int64(st, 1, v * 1024); sqlite3_bind_int(st, 2, uid); sqlite3_step(st); sqlite3_finalize(st); printf("quota for %s: %lld MiB\n", user, v); return 0; } long long quota = 0, used = 0; sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT quota_kb FROM users WHERE id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int(st, 1, uid); if (sqlite3_step(st) == SQLITE_ROW) quota = sqlite3_column_int64(st, 0); sqlite3_finalize(st); } if (sqlite3_prepare_v2(c->db, "SELECT COALESCE(SUM(size_kb),0) FROM repos WHERE owner_id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int(st, 1, uid); if (sqlite3_step(st) == SQLITE_ROW) used = sqlite3_column_int64(st, 0); sqlite3_finalize(st); } printf("%s: %lld KiB used of %lld KiB quota\n", user, used, quota); return 0; } /* `git-trace tier <user>` shows the entitlement; * `git-trace tier <user> <tier> [until]` sets it, where until is * "+30" (days), "YYYY-MM-DD" or "never" — payment itself stays * an operator workflow outside the system, the audit log records * every change */ static int cmd_tier(struct app_ctx *c, const char *user, const char *tier, const char *until) { int uid = auth_user_id(c->db, user); if (uid < 0) { fprintf(stderr, "git-trace: no such user '%s'\n", user); return 1; } if (!tier) { long exp = 0; const char *t = auth_user_tier(c->db, uid, &exp); if (exp) { char d[32]; struct tm tm; time_t tt = exp; gmtime_r(&tt, &tm); strftime(d, sizeof d, "%Y-%m-%d", &tm); printf("%s: tier=%s (expires %s)\n", user, t, d); } else { printf("%s: tier=%s\n", user, t); } return 0; } if (!auth_tier_ok(tier)) { fprintf(stderr, "git-trace: tier must be free|pro|self|enterprise\n"); return 1; } long exp = 0; if (until && until[0] == '+') { long days = strtol(until + 1, NULL, 10); if (days < 1 || days > 36500) { fprintf(stderr, "git-trace: bad validity '+days'\n"); return 1; } exp = (long)time(NULL) + days * 86400; } else if (until && strchr(until, '-')) { int y = 0, mo = 0, d = 0; if (sscanf(until, "%d-%d-%d", &y, &mo, &d) != 3 || y < 2020 || mo < 1 || mo > 12 || d < 1 || d > 31) { fprintf(stderr, "git-trace: bad date '%s' (YYYY-MM-DD)\n", until); return 1; } struct tm tm = { .tm_year = y - 1900, .tm_mon = mo - 1, .tm_mday = d, .tm_hour = 23, .tm_min = 59, .tm_sec = 59 }; exp = (long)timegm(&tm); } else if (until && strcmp(until, "never") != 0) { fprintf(stderr, "git-trace: until must be +days|YYYY-MM-DD|never\n"); return 1; } /* current value for the audit trail */ char old[16] = "free"; sqlite3_stmt *st; if (sqlite3_prepare_v2(c->db, "SELECT tier FROM users WHERE id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int(st, 1, uid); if (sqlite3_step(st) == SQLITE_ROW) { const char *s = (const char *)sqlite3_column_text(st, 0); if (s) snprintf(old, sizeof old, "%s", s); } sqlite3_finalize(st); } if (auth_tier_set(c->db, user, tier, exp) != 0) { fprintf(stderr, "git-trace: tier update failed\n"); return 1; } char w[64] = ""; if (exp) { struct tm tm; time_t tt = exp; gmtime_r(&tt, &tm); strftime(w, sizeof w, " until %Y-%m-%d", &tm); } db_audit(c->db, uid, getenv("USER") ? getenv("USER") : "cli", NULL, 0, "tier_change", "%s: %s -> %s%s", user, old, tier, w); printf("%s: tier=%s%s\n", user, tier, w); return 0; } /* RFC 7914 test vectors for the in-tree scrypt */ static int cmd_kdf_selftest(void) { static const struct { const char *pw, *salt; uint64_t N; unsigned r, p; const char *hex; } tv[] = { { "", "", 16, 1, 1, "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede214" "42fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d1" "8906" }, { "password", "NaCl", 1024, 8, 16, "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b37" "31622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdf" "a2cc0640" }, { "pleaseletmein", "SodiumChloride", 16384, 8, 1, "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1" "f2d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45" "575887" }, }; int fail = 0; for (size_t i = 0; i < sizeof tv / sizeof tv[0]; i++) { uint8_t dk[64], want[64]; int rc = from_hex(tv[i].hex, want, 64); if (rc != 0) { printf("scrypt RFC7914 vector %d: bad test data\n", (int)i + 1); return 1; } rc = scrypt_kdf((const uint8_t *)tv[i].pw, strlen(tv[i].pw), (const uint8_t *)tv[i].salt, strlen(tv[i].salt), tv[i].N, tv[i].r, tv[i].p, dk, 64); int ok = rc == 0 && consteq(dk, want, 64); printf("scrypt RFC7914 vector %d: %s\n", (int)i + 1, ok ? "ok" : "FAIL"); if (!ok) fail = 1; gt_bzero(dk, sizeof dk); gt_bzero(want, sizeof want); } return fail; } /* ---------------- entry ---------------- */ static void usage(FILE *f) { fprintf(f, "git-trace — a zero-dependency git forge\n" "\n" "usage:\n" " git-trace serve [-p port] [-R reposdir] [-d dbfile]\n" " git-trace mkrepo [--private] <owner> <name> [description]\n" " git-trace useradd [--admin] [--email=E] <name> (pw on stdin)\n" " git-trace passwd <name> (pw on stdin)\n" " git-trace mktoken <name> <label> (prints token)\n" " git-trace sshkey add <user> [key|--name=L] (key via stdin)\n" " git-trace sshkey list [user] | rm <id>\n" " git-trace quota <user> [MiB] (0 = unlimited)\n" " git-trace tier <user> [free|pro|self|enterprise\n" " [+days|YYYY-MM-DD|never]] (billing tier)\n" " git-trace keyfile [--write] (authorized_keys)\n" " git-trace shell <keyid> (forced command)\n" " git-trace mailrecv <owner> <repo> (MTA pipe alias)\n" " git-trace kdf-selftest | pwhash-status <user>\n" " git-trace export [-o dir] [-n commits] [--private]\n" " [owner/repo ...] (stagit-style)\n" " git-trace webhook-run (drain queue)\n" " git-trace doctor health check (db,repos,ssh,disk)\n" " git-trace backup <dir> | restore <dir> (snapshot)\n" "\n" "owner '_' means an ownerless (public) repository.\n" "env: GIT_TRACE_REPOS, GIT_TRACE_DB, GIT_TRACE_PORT override\n" "defaults — used by the generated authorized_keys lines.\n"); } #ifndef GT_VERSION #define GT_VERSION "v0.0-dev" #endif #ifndef GT_SHA #define GT_SHA "unknown" #endif /* is an executable on PATH? (feature probe for --version) */ static int on_path(const char *bin) { const char *p = getenv("PATH"); if (!p) return 0; char probe[PATH_MAX]; for (const char *d = p; ; ) { const char *colon = strchr(d, ':'); size_t dl = colon ? (size_t)(colon - d) : strlen(d); if (dl && dl + strlen(bin) + 2 <= sizeof probe) { memcpy(probe, d, dl); snprintf(probe + dl, sizeof probe - dl, "/%s", bin); if (access(probe, X_OK) == 0) return 1; } if (!colon) break; d = colon + 1; } return 0; } static void version(void) { printf("git-trace %s (%s)\n" " build: c11, -O2 -Wall -Wextra -Werror -D_GNU_SOURCE\n" " libraries: libc, sqlite3, pthread\n" " tools: git tar sh [curl: %s]\n" " surface: smart-http ssh-forced-cmd acl issues mr patchset\n" " webhooks ci static-export atom search mirroring\n", GT_VERSION, GT_SHA, on_path("curl") ? "found" : "absent"); } int main(int argc, char **argv) { struct app_ctx c; memset(&c, 0, sizeof c); const char *ev; snprintf(c.repos_root, sizeof c.repos_root, "%s", (ev = getenv("GIT_TRACE_REPOS")) ? ev : "repos"); snprintf(c.db_path, sizeof c.db_path, "%s", (ev = getenv("GIT_TRACE_DB")) ? ev : "var/git-trace.db"); c.port = (ev = getenv("GIT_TRACE_PORT")) ? atoi(ev) : 8080; c.open_reg = (ev = getenv("GIT_TRACE_OPEN_REG")) ? atoi(ev) != 0 : 0; snprintf(c.ssh_url, sizeof c.ssh_url, "%s", (ev = getenv("GIT_TRACE_SSH_URL")) ? ev : ""); snprintf(c.site_name, sizeof c.site_name, "%s", (ev = getenv("GIT_TRACE_NAME")) ? ev : "git-trace"); /* GIT_TRACE_BASE_URL: public URL when behind a reverse proxy or * browser-preview tunnel — its host[:port] is accepted as an * additional valid Origin for POSTs */ if ((ev = getenv("GIT_TRACE_BASE_URL"))) { const char *h = strstr(ev, "://"); h = h ? h + 3 : ev; size_t hl = strcspn(h, "/"); if (hl >= sizeof c.base_host) hl = sizeof c.base_host - 1; memcpy(c.base_host, h, hl); c.base_host[hl] = '\0'; } const char *cmd = argc > 1 ? argv[1] : "serve"; if (strcmp(cmd, "--version") == 0 || strcmp(cmd, "-V") == 0 || strcmp(cmd, "version") == 0) { version(); return 0; } int i = 2; for (; i < argc && argv[i][0] == '-'; i++) { if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) c.port = atoi(argv[++i]); else if (strcmp(argv[i], "-R") == 0 && i + 1 < argc) snprintf(c.repos_root, sizeof c.repos_root, "%s", argv[++i]); else if (strcmp(argv[i], "-d") == 0 && i + 1 < argc) snprintf(c.db_path, sizeof c.db_path, "%s", argv[++i]); else break; /* subcommand-specific flag: hands off */ } if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 || strcmp(cmd, "-h") == 0) { usage(stdout); return 0; } /* shell/keyfile are invoked by sshd: don't litter the OS user's * home with stray dirs */ int quiet = strcmp(cmd, "shell") == 0 || strcmp(cmd, "keyfile") == 0 || strcmp(cmd, "quota-check") == 0 || strcmp(cmd, "branch-check") == 0 || strcmp(cmd, "hook-post") == 0 || strcmp(cmd, "webhook-run") == 0 || strcmp(cmd, "ci-run") == 0 || strcmp(cmd, "mirror-sync") == 0 || strcmp(cmd, "mirror-push") == 0 || strcmp(cmd, "mailrecv") == 0; if (!quiet) { mkdir_p("repos"); mkdir_p("var"); } char real[PATH_MAX]; if (!realpath(c.repos_root, real)) { fprintf(stderr, "git-trace: cannot resolve %s\n", c.repos_root); return 1; } snprintf(c.repos_root, sizeof c.repos_root, "%s", real); /* db path must be absolute: authorized_keys lines embed it and the * forced command does not share our cwd */ if (c.db_path[0] != '/') { char cwd[PATH_MAX]; int n = getcwd(cwd, sizeof cwd) ? snprintf(real, sizeof real, "%s/%s", cwd, c.db_path) : -1; if (n < 0 || n >= (int)sizeof real) snprintf(real, sizeof real, "%s", c.db_path); snprintf(c.db_path, sizeof c.db_path, "%s", real); } c.db = db_open(c.db_path); if (strcmp(cmd, "shell") == 0) { if (i >= argc) { usage(stderr); return 2; } return cmd_shell(&c, argv[i]); } if (strcmp(cmd, "keyfile") == 0) { int wf = i < argc && strcmp(argv[i], "--write") == 0; return cmd_keyfile(&c, wf); } if (strcmp(cmd, "sshkey") == 0) return cmd_sshkey(&c, argc - i, argv + i); if (strcmp(cmd, "mailrecv") == 0) { if (i + 1 >= argc) { usage(stderr); return 2; } return gt_mailrecv(&c, argv[i], argv[i + 1]); } if (strcmp(cmd, "branch-check") == 0) return cmd_branch_check(&c, i < argc ? argv[i] : NULL, i + 1 < argc ? argv[i + 1] : NULL, i + 2 < argc ? argv[i + 2] : NULL); if (strcmp(cmd, "quota-check") == 0) return cmd_quota_check(&c, i < argc ? argv[i] : NULL, i + 1 < argc ? argv[i + 1] : NULL); if (strcmp(cmd, "rehook") == 0) return cmd_rehook(&c); if (strcmp(cmd, "export") == 0) return gt_export(&c, argc - i, argv + i); if (strcmp(cmd, "webhook-run") == 0) return wh_run(&c) < 0 ? 1 : 0; if (strcmp(cmd, "ci-run") == 0) return ci_run_all(&c) < 0 ? 1 : 0; if (strcmp(cmd, "mirror-sync") == 0) { if (i >= argc) return gt_mirror_sync(&c, NULL, NULL) ? 1 : 0; char orname[256]; snprintf(orname, sizeof orname, "%s", argv[i]); char *sl = strchr(orname, '/'); if (!sl) { usage(stderr); return 2; } *sl = '\0'; return gt_mirror_sync(&c, orname, sl + 1) ? 1 : 0; } if (strcmp(cmd, "mirror-push") == 0) { if (i >= argc) return gt_mirror_push(&c, NULL, NULL) ? 1 : 0; char orname[256]; snprintf(orname, sizeof orname, "%s", argv[i]); char *sl = strchr(orname, '/'); if (!sl) { usage(stderr); return 2; } *sl = '\0'; return gt_mirror_push(&c, orname, sl + 1) ? 1 : 0; } if (strcmp(cmd, "doctor") == 0) return gt_doctor(&c); if (strcmp(cmd, "backup") == 0) { if (i >= argc) { usage(stderr); return 2; } return gt_backup(&c, argv[i]); } if (strcmp(cmd, "restore") == 0) { if (i >= argc) { usage(stderr); return 2; } return gt_restore(&c, argv[i]); } if (strcmp(cmd, "hook-post") == 0) return cmd_hook_post(&c); if (strcmp(cmd, "kdf-selftest") == 0) return cmd_kdf_selftest(); if (strcmp(cmd, "pwhash-status") == 0) { if (i >= argc) { usage(stderr); return 2; } const char *s = auth_pw_scheme(c.db, argv[i]); if (!s) { fprintf(stderr, "no such user\n"); return 1; } printf("%s\n", s); return 0; } if (strcmp(cmd, "tier") == 0) { if (i >= argc) { usage(stderr); return 2; } return cmd_tier(&c, argv[i], i + 1 < argc ? argv[i + 1] : NULL, i + 2 < argc ? argv[i + 2] : NULL); } if (strcmp(cmd, "quota") == 0) { if (i >= argc) { usage(stderr); return 2; } return cmd_quota(&c, argv[i], i + 1 < argc ? argv[i + 1] : NULL); } if (strcmp(cmd, "mkrepo") == 0 || strcmp(cmd, "useradd") == 0 || strcmp(cmd, "passwd") == 0 || strcmp(cmd, "mktoken") == 0) { const char *pos[4] = {0}; int np = 0, priv = 0, adm = 0; const char *email = NULL; for (; i < argc; i++) { if (strcmp(argv[i], "--private") == 0) priv = 1; else if (strcmp(argv[i], "--admin") == 0) adm = 1; else if (starts_with(argv[i], "--email=")) email = argv[i] + 8; else if (np < 4) pos[np++] = argv[i]; else { usage(stderr); return 2; } } if (strcmp(cmd, "mkrepo") == 0) { if (np < 2) { usage(stderr); return 2; } return cmd_mkrepo(&c, pos[0], pos[1], np > 2 ? pos[2] : NULL, priv); } if (strcmp(cmd, "useradd") == 0) { if (np < 1) { usage(stderr); return 2; } return cmd_useradd(&c, pos[0], email, adm); } if (strcmp(cmd, "passwd") == 0) { if (np < 1) { usage(stderr); return 2; } return cmd_passwd(&c, pos[0]); } /* mktoken */ if (np < 2) { usage(stderr); return 2; } return cmd_mktoken(&c, pos[0], pos[1]); } if (strcmp(cmd, "serve") != 0) { usage(stderr); return 2; } if (sqlite3_threadsafe() == 0) { fprintf(stderr, "git-trace: sqlite3 built without thread" " support — refusing to serve\n"); return 1; } fprintf(stderr, "git-trace: repos at %s\n", c.repos_root); httpd_run(c.port, &c, route, c.base_host[0] ? c.base_host : NULL); return 0; }