(2 months ago)commit b8cdced: README / cleanup
| author | Tanner Stenson <tanner@brickware.sh> |
| date | Tue Oct 28 23:56:45 2025 -0400 |
README / cleanup
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, "<"); break;
+ case '>': fprintf(out, ">"); break;
+ case '&': fprintf(out, "&"); break;
+ case '"': fprintf(out, """); 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;
+}