git / brickware / marrow.git - b8cdced

(2 months ago)commit b8cdced: README / cleanup

Summary | History | Files

commit b8cdced

authorTanner Stenson <tanner@brickware.sh>
dateTue Oct 28 23:56:45 2025 -0400

message

README / cleanup

diff

commit b8cdced78660d4a47b7db98c656f59bcf2654b98
Author: Tanner Stenson <tanner@brickware.sh>
Date:   Tue Oct 28 23:56:45 2025 -0400

    README / cleanup

diff --git a/.gitignore b/.gitignore
index 12e43c9..62d604d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
 *.o
 marrow-auth
 marrow-shell
+marrow-static
 
 # Editor files
 .*.swp
diff --git a/Makefile b/Makefile
index 56bc3ca..a0e9aa9 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ CFLAGS = -O2 -pipe -std=c11 -Wall -Wextra -pedantic -Wstrict-overflow \
 PREFIX = /usr/local
 BINDIR = $(PREFIX)/bin
 
-TARGETS = marrow-auth marrow-shell
+TARGETS = marrow-auth marrow-shell marrow-static
 
 all: $(TARGETS)
 
@@ -14,10 +14,14 @@ marrow-auth: marrow-auth.o slice.o
 marrow-shell: marrow-shell.o slice.o
 	$(CC) $(CFLAGS) -o $@ $>
 
+marrow-static: marrow-static.o
+	$(CC) $(CFLAGS) -o $@ $>
+
 install: $(TARGETS)
 	install -d $(DESTDIR)$(BINDIR)
 	install -m 755 marrow-auth $(DESTDIR)$(BINDIR)/
 	install -m 755 marrow-shell $(DESTDIR)$(BINDIR)/
+	install -m 755 marrow-static $(DESTDIR)$(BINDIR)/
 
 clean:
 	rm -f $(TARGETS)
diff --git a/README b/README
index be1a7ef..81236df 100644
--- a/README
+++ b/README
@@ -1,50 +1,48 @@
-Marrow - Git Server Authentication and Shell System
-
-Marrow is a pair of C programs that provide simple authentication and
-access control for Git repositories served over SSH. It allows anonymous
-read-only access to repositories while restricting write access to
-authenticated users within their own directories.
-
-Marrow reads from it's configuration directory, which should contain the 
-following files:
-
-    marrow/
-        $USER   # keys for a given user
-
-Components:
-- marrow-auth: AuthorizedKeysCommand that outputs SSH keys with forced
-  marrow-shell commands
-- marrow-shell: Restricted shell that enforces Git access permissions
-  based on GIT_USER or -a flag for anonymous
-
-How it works:
-SSH authenticates users via marrow-auth which reads /etc/marrow/authorized_keys
-and outputs keys with forced commands. Anonymous keys use marrow-shell -a,
-authenticated users get marrow-shell with GIT_USER set. Users can read from
-any repo under /srv/git/ but can only write to /srv/git/$GIT_USER/.
-
-Example setup:
-1. Configure sshd_config with AuthorizedKeysCommand /usr/local/bin/marrow-auth
-2. Create /etc/marrow/user with format:
-3. Users SSH in and git commands are filtered through marrow-shell
-
-Example usage:
-  Anonymous clone: git clone git@server:project.git
-  Authenticated push: git push git@server:myuser/project.git
-
-Building:
-  make          - Build both programs
-  make test     - Run tests
-  make install  - Install to /usr/local/bin
-  make clean    - Remove build artifacts
-
-Requirements: C compiler, crypt(3)
-Note: Built and tested on FreeBSD systems
-
-License: MIT
-
-TODO:
-- Build out initial prototype
-- Additional tooling
-    - User management
-    - Public vs Private repos
+MARROW
+
+Minimal git server for FreeBSD jails. Handles authentication, repository access
+control, and static site generation.
+
+COMPONENTS
+
+marrow-auth     SSH authorized keys provider
+marrow-shell    Restricted shell enforcing repository permissions
+marrow-static   Static site generator for repositories
+
+AUTHENTICATION
+
+marrow-auth reads SSH public keys from /usr/local/etc/marrow/$USER and outputs
+them with forced marrow-shell commands.
+
+REPOSITORY ACCESS
+
+Repositories live in /srv/git. Access rules:
+- Anonymous users: read-only access to all repositories
+- Authenticated users: read all, write to /srv/git/$USER/ only
+- Write operations auto-create bare repositories if they don't exist
+
+STATIC SITE GENERATION
+
+Generate HTML pages for repositories:
+    marrow-static <output-dir> <repo>
+
+Generates repository page with README, recent commits, and clone URL.
+
+BUILDING
+
+    make            Build all programs
+    make install    Install to /usr/local/bin
+    make clean      Clean build artifacts
+
+SETUP
+
+1. Add to /etc/ssh/sshd_config:
+    Match User git
+        AuthorizedKeysCommand /usr/local/bin/marrow-auth %u
+        AuthorizedKeysCommandUser git
+
+2. Create /usr/local/etc/marrow/$USER with SSH public keys for each user
+
+3. Create /srv/git for repositories
+
+4. Users connect via SSH and use git commands
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..0299e7c
--- /dev/null
+++ b/TODO
@@ -0,0 +1,5 @@
+- User management tooling
+- Public vs private repository support
+- File browser for marrow-static
+- Commit log pages for marrow-static
+- Blob viewer for marrow-static
diff --git a/marrow-shell.c b/marrow-shell.c
index 3fd9757..f55f045 100644
--- a/marrow-shell.c
+++ b/marrow-shell.c
@@ -103,32 +103,34 @@ contains_relative_components(const struct slice path) {
 
 static int32_t
 validate_repo_path(const struct slice repo_path, const struct slice user) {
-    char full_path[PATH_MAX];
-    char expected_prefix[PATH_MAX];
-
     /* Reject paths with relative components */
     if (contains_relative_components(repo_path)) {
         return 0;
     }
 
-    /* Construct full path: GIT_BASE_PATH/repo_path */
-    if (repo_path.size > 0 && '/' == repo_path.ptr[0]) {
-        snprintf(full_path, sizeof(full_path), "%s%.*s", GIT_BASE_PATH, (int32_t)repo_path.size, repo_path.ptr);
-    } else {
-        snprintf(full_path, sizeof(full_path), "%s/%.*s", GIT_BASE_PATH, (int32_t)repo_path.size, repo_path.ptr);
+    /* For anonymous users, any path is OK (already under GIT_BASE_PATH) */
+    if (slice_isempty(user)) {
+        return 1;
     }
 
-    /* Build expected prefix based on user */
-    if (slice_isempty(user)) {
-        /* Anonymous/read: must be under GIT_BASE_PATH/ */
-        snprintf(expected_prefix, sizeof(expected_prefix), "%s/", GIT_BASE_PATH);
-    } else {
-        /* Authenticated/write: must be under GIT_BASE_PATH/username/ */
-        snprintf(expected_prefix, sizeof(expected_prefix), "%s/%.*s/", GIT_BASE_PATH, (int32_t)user.size, user.ptr);
+    /* For authenticated users, ensure path starts with username/ */
+    const char *path_start = repo_path.ptr;
+    size_t remaining_size = repo_path.size;
+
+    /* Skip leading slash if present */
+    if (remaining_size > 0 && '/' == *path_start) {
+        path_start++;
+        remaining_size--;
+    }
+
+    /* Check if path starts with username followed by '/' */
+    if (remaining_size >= user.size + 1 &&
+        0 == strncmp(path_start, user.ptr, user.size) &&
+        '/' == path_start[user.size]) {
+        return 1;
     }
 
-    /* Check if full_path starts with expected_prefix */
-    return (0 == strncmp(full_path, expected_prefix, strlen(expected_prefix)));
+    return 0;
 }
 
 static int32_t
diff --git a/marrow-static.c b/marrow-static.c
new file mode 100644
index 0000000..e472dad
--- /dev/null
+++ b/marrow-static.c
@@ -0,0 +1,318 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <limits.h>
+#include <errno.h>
+#include <stdint.h>
+
+#define MAX_CMD_LEN 4096
+#define MAX_LINE_LEN 2048
+#define GIT_BASE_PATH "/srv/git"
+
+static int32_t
+ensure_directory(const char *path) {
+    struct stat st;
+    if (0 == stat(path, &st)) {
+        return S_ISDIR(st.st_mode) ? 1 : 0;
+    }
+
+    char tmp[PATH_MAX];
+    snprintf(tmp, sizeof(tmp), "mkdir -p '%s'", path);
+    return (0 == system(tmp));
+}
+
+static int32_t
+is_git_repository(const char *path) {
+    char git_dir[PATH_MAX];
+    struct stat st;
+
+    snprintf(git_dir, sizeof(git_dir), "%s/HEAD", path);
+    return (0 == stat(git_dir, &st));
+}
+
+static void
+html_escape(FILE *out, const char *str) {
+    while (*str) {
+        switch (*str) {
+            case '<': fprintf(out, "&lt;"); break;
+            case '>': fprintf(out, "&gt;"); break;
+            case '&': fprintf(out, "&amp;"); break;
+            case '"': fprintf(out, "&quot;"); break;
+            default: fputc(*str, out); break;
+        }
+        str++;
+    }
+}
+
+static void
+generate_html_header(FILE *out, const char *title) {
+    fprintf(out, "<!DOCTYPE html>\n");
+    fprintf(out, "<html>\n<head>\n");
+    fprintf(out, "<meta charset=\"utf-8\">\n");
+    fprintf(out, "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n");
+    fprintf(out, "<title>%s</title>\n", title);
+    fprintf(out, "<style>\n");
+    fprintf(out, "body{font-family:monospace;max-width:900px;margin:40px auto;padding:0 20px;line-height:1.6;}\n");
+    fprintf(out, "a{color:#00f;text-decoration:none;}\n");
+    fprintf(out, "a:hover{text-decoration:underline;}\n");
+    fprintf(out, "table{border-collapse:collapse;width:100%%;}\n");
+    fprintf(out, "th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #ddd;}\n");
+    fprintf(out, "th{font-weight:bold;}\n");
+    fprintf(out, "pre{background:#f5f5f5;padding:10px;overflow-x:auto;}\n");
+    fprintf(out, "code{background:#f5f5f5;padding:2px 4px;}\n");
+    fprintf(out, ".header{border-bottom:2px solid #000;margin-bottom:20px;padding-bottom:10px;}\n");
+    fprintf(out, ".footer{margin-top:40px;padding-top:20px;border-top:1px solid #ddd;font-size:0.9em;color:#666;}\n");
+    fprintf(out, "</style>\n");
+    fprintf(out, "</head>\n<body>\n");
+    fprintf(out, "<div class=\"header\"><h1>%s</h1></div>\n", title);
+}
+
+static void
+generate_html_footer(FILE *out) {
+    fprintf(out, "<div class=\"footer\">generated by marrow-static</div>\n");
+    fprintf(out, "</body>\n</html>\n");
+}
+
+static int32_t
+get_git_readme(const char *repo_path, char **readme) {
+    char cmd[MAX_CMD_LEN];
+    snprintf(cmd, sizeof(cmd),
+             "cd '%s' && git show HEAD:README.md 2>/dev/null || "
+             "git show HEAD:README 2>/dev/null || "
+             "git show HEAD:readme.md 2>/dev/null",
+             repo_path);
+
+    FILE *fp = popen(cmd, "r");
+    if (!fp) {
+        return 0;
+    }
+
+    /* Allocate buffer for README */
+    size_t capacity = 8192;
+    size_t total = 0;
+    *readme = malloc(capacity);
+    if (!*readme) {
+        pclose(fp);
+        return 0;
+    }
+
+    char buffer[1024];
+    while (fgets(buffer, sizeof(buffer), fp)) {
+        size_t len = strlen(buffer);
+        if (total + len >= capacity) {
+            capacity *= 2;
+            char *new_readme = realloc(*readme, capacity);
+            if (!new_readme) {
+                free(*readme);
+                pclose(fp);
+                return 0;
+            }
+            *readme = new_readme;
+        }
+        strcpy(*readme + total, buffer);
+        total += len;
+    }
+
+    int32_t status = pclose(fp);
+    if (0 != status || 0 == total) {
+        free(*readme);
+        *readme = NULL;
+        return 0;
+    }
+
+    return 1;
+}
+
+static void
+render_markdown_simple(FILE *out, const char *markdown) {
+    const char *line = markdown;
+    int32_t in_code = 0;
+
+    while (*line) {
+        const char *next = strchr(line, '\n');
+        if (!next) {
+            next = line + strlen(line);
+        }
+
+        size_t line_len = next - line;
+
+        /* Code blocks */
+        if (line_len >= 3 && 0 == strncmp(line, "```", 3)) {
+            if (in_code) {
+                fprintf(out, "</pre>\n");
+                in_code = 0;
+            } else {
+                fprintf(out, "<pre>");
+                in_code = 1;
+            }
+            line = (*next ? next + 1 : next);
+            continue;
+        }
+
+        if (in_code) {
+            html_escape(out, line);
+            if (*next) {
+                fprintf(out, "\n");
+            }
+            line = (*next ? next + 1 : next);
+            continue;
+        }
+
+        /* Headers */
+        if ('#' == *line && line_len > 0) {
+            int32_t level = 0;
+            const char *p = line;
+            while (p < next && '#' == *p && level < 6) {
+                level++;
+                p++;
+            }
+            if (level > 0) {
+                /* Skip space after # */
+                if (p < next && ' ' == *p) {
+                    p++;
+                }
+                fprintf(out, "<h%d>", level + 1);
+                size_t text_len = next - p;
+                for (size_t i = 0; i < text_len; i++) {
+                    html_escape(out, (char[]){p[i], '\0'});
+                }
+                fprintf(out, "</h%d>\n", level + 1);
+                line = (*next ? next + 1 : next);
+                continue;
+            }
+        }
+
+        /* Empty lines */
+        if (0 == line_len) {
+            line = (*next ? next + 1 : next);
+            continue;
+        }
+
+        /* Regular paragraph */
+        fprintf(out, "<p>");
+        for (size_t i = 0; i < line_len; i++) {
+            html_escape(out, (char[]){line[i], '\0'});
+        }
+        fprintf(out, "</p>\n");
+
+        line = (*next ? next + 1 : next);
+    }
+
+    if (in_code) {
+        fprintf(out, "</pre>\n");
+    }
+}
+
+static int32_t
+generate_repo_page(const char *output_dir, const char *repo_name) {
+    char repo_path[PATH_MAX];
+    char repo_output_dir[PATH_MAX];
+    char repo_index[PATH_MAX];
+
+    snprintf(repo_path, sizeof(repo_path), "%s/%s", GIT_BASE_PATH, repo_name);
+    snprintf(repo_output_dir, sizeof(repo_output_dir), "%s/%s", output_dir, repo_name);
+    snprintf(repo_index, sizeof(repo_index), "%s/index.html", repo_output_dir);
+
+    if (!ensure_directory(repo_output_dir)) {
+        dprintf(STDERR_FILENO, "Error: Cannot create directory %s\n", repo_output_dir);
+        return 0;
+    }
+
+    FILE *out = fopen(repo_index, "w");
+    if (!out) {
+        dprintf(STDERR_FILENO, "Error: Cannot create %s\n", repo_index);
+        return 0;
+    }
+
+    generate_html_header(out, repo_name);
+
+    /* Clone URL */
+    fprintf(out, "<h2>clone</h2>\n");
+    fprintf(out, "<pre>git clone git@host:%s</pre>\n", repo_name);
+
+    /* README */
+    char *readme = NULL;
+    if (get_git_readme(repo_path, &readme)) {
+        fprintf(out, "<h2>readme</h2>\n");
+        render_markdown_simple(out, readme);
+        free(readme);
+    }
+
+    /* Recent commits */
+    fprintf(out, "<h2>recent commits</h2>\n");
+    char cmd[MAX_CMD_LEN];
+    snprintf(cmd, sizeof(cmd),
+             "cd '%s' && git log --pretty=format:'%%h|%%an|%%ar|%%s' -n 10 HEAD 2>/dev/null",
+             repo_path);
+
+    FILE *fp = popen(cmd, "r");
+    if (fp) {
+        fprintf(out, "<table>\n");
+        fprintf(out, "<thead><tr><th>hash</th><th>author</th><th>date</th><th>message</th></tr></thead>\n");
+        fprintf(out, "<tbody>\n");
+
+        char line[MAX_LINE_LEN];
+        while (fgets(line, sizeof(line), fp)) {
+            char *hash = strtok(line, "|");
+            char *author = strtok(NULL, "|");
+            char *date = strtok(NULL, "|");
+            char *msg = strtok(NULL, "|");
+
+            if (hash && author && date && msg) {
+                /* Remove trailing newline from message */
+                size_t msg_len = strlen(msg);
+                if (msg_len > 0 && '\n' == msg[msg_len - 1]) {
+                    msg[msg_len - 1] = '\0';
+                }
+
+                fprintf(out, "<tr><td><code>%s</code></td><td>", hash);
+                html_escape(out, author);
+                fprintf(out, "</td><td>%s</td><td>", date);
+                html_escape(out, msg);
+                fprintf(out, "</td></tr>\n");
+            }
+        }
+
+        fprintf(out, "</tbody>\n</table>\n");
+        pclose(fp);
+    }
+
+    generate_html_footer(out);
+    fclose(out);
+
+    return 1;
+}
+
+int32_t
+main(int32_t argc, char *argv[]) {
+    if (argc < 3) {
+        dprintf(STDERR_FILENO, "Usage: %s <output-dir> <repo>\n", argv[0]);
+        dprintf(STDERR_FILENO, "Example: %s /var/www/git myproject.git\n", argv[0]);
+        return EXIT_FAILURE;
+    }
+
+    const char *output_dir = argv[1];
+    const char *repo_name = argv[2];
+
+    if (!ensure_directory(output_dir)) {
+        dprintf(STDERR_FILENO, "Error: Cannot create output directory %s\n", output_dir);
+        return EXIT_FAILURE;
+    }
+
+    char repo_path[PATH_MAX];
+    snprintf(repo_path, sizeof(repo_path), "%s/%s", GIT_BASE_PATH, repo_name);
+
+    if (!is_git_repository(repo_path)) {
+        dprintf(STDERR_FILENO, "Error: %s is not a git repository\n", repo_name);
+        return EXIT_FAILURE;
+    }
+
+    if (!generate_repo_page(output_dir, repo_name)) {
+        return EXIT_FAILURE;
+    }
+
+    return EXIT_SUCCESS;
+}