--- /dev/null
+Copyright (c) 2026 Andrew Gundersen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--- /dev/null
+# Crimata OS
+
+## Install Paths
+
+| Component | Path |
+|-------------------|---------------------------------------------|
+| Web desktop UI | `/opt/crimata/ui/` |
+| Auth daemon | `/opt/crimata/bin/crimata-auth` |
+| Dock daemon | `/opt/crimata/bin/crimata-dock` |
+| Contacts app | `/usr/lib/crimata-contacts/` |
+| Nginx config | `/etc/nginx/sites-available/crimata` |
+| systemd units | `/etc/systemd/system/crimata-*.service` |
+| Postgres DBs | `crimata_contacts`, `crimata_blog`, … |
+
+## Ports
+
+| Service | Port |
+|--------------------|------|
+| nginx | 80 |
+| crimata-auth | 7700 |
+| crimata-dock | 7701 |
+| crimata-contacts | 3001 |
--- /dev/null
+CC = gcc
+CFLAGS = -Wall -Wextra -O2
+LIBS = -lmicrohttpd -lsystemd
+SRC = src/main.c src/apps.c src/systemd.c
+OUT = crimata-dock
+
+.PHONY: all clean
+
+all:
+ $(CC) $(CFLAGS) $(SRC) $(LIBS) -o $(OUT)
+
+clean:
+ rm -f $(OUT)
--- /dev/null
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <glob.h>
+#include "apps.h"
+
+#define MANIFEST_GLOB "/usr/lib/crimata-*/crimata.json"
+#define MAX_FILE_SIZE 4096
+
+/* Pull a string value out of a flat JSON object (same approach as auth) */
+static int json_str(const char *json, const char *key, char *out, size_t out_len)
+{
+ char search[128];
+ snprintf(search, sizeof(search), "\"%s\"", key);
+ const char *pos = strstr(json, search);
+ if (!pos) return 0;
+
+ pos += strlen(search);
+ while (*pos == ' ' || *pos == ':') pos++;
+ if (*pos != '"') return 0;
+ pos++;
+
+ size_t i = 0;
+ while (*pos && *pos != '"' && i < out_len - 1)
+ out[i++] = *pos++;
+ out[i] = '\0';
+ return 1;
+}
+
+static int json_int(const char *json, const char *key, int *out)
+{
+ char search[128];
+ snprintf(search, sizeof(search), "\"%s\"", key);
+ const char *pos = strstr(json, search);
+ if (!pos) return 0;
+
+ pos += strlen(search);
+ while (*pos == ' ' || *pos == ':') pos++;
+ if (*pos < '0' || *pos > '9') return 0;
+
+ *out = atoi(pos);
+ return 1;
+}
+
+static int parse_manifest(const char *path, app_t *app)
+{
+ FILE *f = fopen(path, "r");
+ if (!f) return 0;
+
+ char buf[MAX_FILE_SIZE];
+ size_t n = fread(buf, 1, sizeof(buf) - 1, f);
+ fclose(f);
+ buf[n] = '\0';
+
+ if (!json_str(buf, "id", app->id, sizeof(app->id))) return 0;
+ if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0;
+ json_str(buf, "icon", app->icon, sizeof(app->icon));
+ json_int(buf, "port", &app->port);
+ app->running = 0;
+
+ return 1;
+}
+
+int apps_scan(app_t *apps)
+{
+ glob_t g;
+ int count = 0;
+
+ if (glob(MANIFEST_GLOB, 0, NULL, &g) != 0)
+ return 0;
+
+ for (size_t i = 0; i < g.gl_pathc && count < MAX_APPS; i++) {
+ if (parse_manifest(g.gl_pathv[i], &apps[count]))
+ count++;
+ }
+
+ globfree(&g);
+ return count;
+}
--- /dev/null
+#ifndef APPS_H
+#define APPS_H
+
+#define MAX_APPS 32
+#define MAX_STR 128
+
+typedef struct {
+ char id[MAX_STR];
+ char name[MAX_STR];
+ char icon[MAX_STR];
+ int port;
+ int running; /* populated at request time via systemd check */
+} app_t;
+
+/* Scan /usr/lib/crimata-*/crimata.json — returns app count */
+int apps_scan(app_t *apps);
+
+#endif
--- /dev/null
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <microhttpd.h>
+#include "apps.h"
+#include "systemd.h"
+
+#define PORT 7701
+#define BUF_SIZE 16384
+
+/* ── JSON response helper ─────────────────────────────────────────────────── */
+
+static enum MHD_Result send_json(struct MHD_Connection *conn,
+ unsigned int status, const char *body)
+{
+ struct MHD_Response *resp = MHD_create_response_from_buffer(
+ strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header(resp, "Content-Type", "application/json");
+ MHD_add_response_header(resp, "Access-Control-Allow-Origin", "*");
+ enum MHD_Result r = MHD_queue_response(conn, status, resp);
+ MHD_destroy_response(resp);
+ return r;
+}
+
+/* ── GET /apps ────────────────────────────────────────────────────────────── */
+
+static enum MHD_Result handle_list(struct MHD_Connection *conn)
+{
+ app_t apps[MAX_APPS];
+ int count = apps_scan(apps);
+
+ /* Check running status for each app */
+ for (int i = 0; i < count; i++) {
+ char unit[MAX_STR + 16];
+ snprintf(unit, sizeof(unit), "crimata-%s.service", apps[i].id);
+ apps[i].running = (systemd_is_active(unit) == 1) ? 1 : 0;
+ }
+
+ /* Build JSON array */
+ char buf[BUF_SIZE];
+ int pos = 0;
+ pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
+
+ for (int i = 0; i < count; i++) {
+ pos += snprintf(buf + pos, sizeof(buf) - pos,
+ "%s{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\","
+ "\"port\":%d,\"running\":%s}",
+ i > 0 ? "," : "",
+ apps[i].id, apps[i].name, apps[i].icon,
+ apps[i].port, apps[i].running ? "true" : "false");
+ }
+
+ pos += snprintf(buf + pos, sizeof(buf) - pos, "]");
+
+ return send_json(conn, MHD_HTTP_OK, buf);
+}
+
+/* ── POST /apps/{id}/start|stop ───────────────────────────────────────────── */
+
+static enum MHD_Result handle_action(struct MHD_Connection *conn,
+ const char *app_id, int start)
+{
+ /* Verify app exists */
+ app_t apps[MAX_APPS];
+ int count = apps_scan(apps);
+ int found = 0;
+
+ for (int i = 0; i < count; i++) {
+ if (strcmp(apps[i].id, app_id) == 0) { found = 1; break; }
+ }
+
+ if (!found)
+ return send_json(conn, MHD_HTTP_NOT_FOUND,
+ "{\"error\":\"app not found\"}");
+
+ char unit[MAX_STR + 16];
+ snprintf(unit, sizeof(unit), "crimata-%s.service", app_id);
+
+ int r = start ? systemd_start(unit) : systemd_stop(unit);
+
+ if (r < 0)
+ return send_json(conn, MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "{\"error\":\"systemd call failed\"}");
+
+ return send_json(conn, MHD_HTTP_OK, "{\"ok\":true}");
+}
+
+/* ── Main request handler ─────────────────────────────────────────────────── */
+
+static enum MHD_Result handler(void *cls,
+ struct MHD_Connection *conn,
+ const char *url,
+ const char *method,
+ const char *version,
+ const char *upload_data,
+ size_t *upload_data_size,
+ void **con_cls)
+{
+ (void)cls; (void)version; (void)upload_data; (void)upload_data_size;
+ (void)con_cls;
+
+ if (strcmp(url, "/health") == 0 && strcmp(method, "GET") == 0)
+ return send_json(conn, MHD_HTTP_OK, "{\"status\":\"ok\"}");
+
+ if (strcmp(url, "/apps") == 0 && strcmp(method, "GET") == 0)
+ return handle_list(conn);
+
+ char app_id[MAX_STR];
+
+ if (strcmp(method, "POST") == 0) {
+ if (sscanf(url, "/apps/%127[^/]/start", app_id) == 1)
+ return handle_action(conn, app_id, 1);
+
+ if (sscanf(url, "/apps/%127[^/]/stop", app_id) == 1)
+ return handle_action(conn, app_id, 0);
+ }
+
+ return send_json(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"not found\"}");
+}
+
+/* ── Entry point ──────────────────────────────────────────────────────────── */
+
+int main(void)
+{
+ struct MHD_Daemon *daemon = MHD_start_daemon(
+ MHD_USE_INTERNAL_POLLING_THREAD,
+ PORT, NULL, NULL,
+ &handler, NULL,
+ MHD_OPTION_END
+ );
+
+ if (!daemon) {
+ fprintf(stderr, "failed to start daemon on port %d\n", PORT);
+ return 1;
+ }
+
+ printf("crimata-dock listening on :%d\n", PORT);
+ getchar();
+
+ MHD_stop_daemon(daemon);
+ return 0;
+}
--- /dev/null
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <systemd/sd-bus.h>
+#include "systemd.h"
+
+#define SYSTEMD_DEST "org.freedesktop.systemd1"
+#define SYSTEMD_PATH "/org/freedesktop/systemd1"
+#define MANAGER_IFACE "org.freedesktop.systemd1.Manager"
+#define UNIT_IFACE "org.freedesktop.systemd1.Unit"
+
+static sd_bus *open_bus(void)
+{
+ sd_bus *bus = NULL;
+ if (sd_bus_open_system(&bus) < 0) return NULL;
+ return bus;
+}
+
+int systemd_is_active(const char *unit)
+{
+ sd_bus *bus = open_bus();
+ sd_bus_error error = SD_BUS_ERROR_NULL;
+ sd_bus_message *reply = NULL;
+ const char *path;
+ char *state = NULL;
+ int result = 0;
+
+ if (!bus) return -1;
+
+ /* GetUnit → object path */
+ if (sd_bus_call_method(bus, SYSTEMD_DEST, SYSTEMD_PATH, MANAGER_IFACE,
+ "GetUnit", &error, &reply, "s", unit) < 0)
+ goto done; /* unit doesn't exist = not running */
+
+ if (sd_bus_message_read(reply, "o", &path) < 0) goto done;
+
+ /* Read ActiveState property */
+ if (sd_bus_get_property_string(bus, SYSTEMD_DEST, path, UNIT_IFACE,
+ "ActiveState", &error, &state) < 0)
+ goto done;
+
+ result = (strcmp(state, "active") == 0) ? 1 : 0;
+
+done:
+ free(state);
+ sd_bus_message_unref(reply);
+ sd_bus_error_free(&error);
+ sd_bus_unref(bus);
+ return result;
+}
+
+int systemd_start(const char *unit)
+{
+ sd_bus *bus = open_bus();
+ sd_bus_error error = SD_BUS_ERROR_NULL;
+ sd_bus_message *reply = NULL;
+ int result = -1;
+
+ if (!bus) return -1;
+
+ if (sd_bus_call_method(bus, SYSTEMD_DEST, SYSTEMD_PATH, MANAGER_IFACE,
+ "StartUnit", &error, &reply,
+ "ss", unit, "replace") >= 0)
+ result = 0;
+
+ sd_bus_message_unref(reply);
+ sd_bus_error_free(&error);
+ sd_bus_unref(bus);
+ return result;
+}
+
+int systemd_stop(const char *unit)
+{
+ sd_bus *bus = open_bus();
+ sd_bus_error error = SD_BUS_ERROR_NULL;
+ sd_bus_message *reply = NULL;
+ int result = -1;
+
+ if (!bus) return -1;
+
+ if (sd_bus_call_method(bus, SYSTEMD_DEST, SYSTEMD_PATH, MANAGER_IFACE,
+ "StopUnit", &error, &reply,
+ "ss", unit, "replace") >= 0)
+ result = 0;
+
+ sd_bus_message_unref(reply);
+ sd_bus_error_free(&error);
+ sd_bus_unref(bus);
+ return result;
+}
--- /dev/null
+#ifndef SYSTEMD_H
+#define SYSTEMD_H
+
+/* Returns 1 if active, 0 if inactive/not found, -1 on error */
+int systemd_is_active(const char *unit);
+
+/* Returns 0 on success, -1 on error */
+int systemd_start(const char *unit);
+int systemd_stop(const char *unit);
+
+#endif