#ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif #include "cst_parliament.h" #include "cJSON.h" #include "cst_agent.h" #include "cst_discover.h" #include "cst_util.h" #include "monocypher.h" #include "os_compat.h" #include "cst_hermit_glyph.h" #include #include #include #include #include #ifdef _WIN32 #include #else #include #endif #define PARL_CONTEXT_MAX (256 * 1024) static int sync_want_path(char *out, size_t n) { char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) return -1; return os_path_join(out, n, dir, "sync.want"); } void cst_parliament_sync_request(void) { char path[OS_PATH_MAX]; if (sync_want_path(path, sizeof(path)) != 0) return; FILE *f = fopen(path, "wb"); if (!f) return; fputs("1\n", f); fclose(f); } int cst_parliament_sync_pending(void) { char path[OS_PATH_MAX]; if (sync_want_path(path, sizeof(path)) != 0) return 0; FILE *f = fopen(path, "rb"); if (!f) return 0; fclose(f); return 1; } void cst_parliament_sync_ack(void) { char path[OS_PATH_MAX]; if (sync_want_path(path, sizeof(path)) == 0) remove(path); } int cst_parliament_default_instance(char *out, size_t n) { if (!out || n < 2) return -1; const char *env = hermit_getenv("INSTANCE_ID"); if (env && env[0]) { snprintf(out, n, "%s", env); return 0; } char host[128] = {0}; #ifdef _WIN32 DWORD hn = (DWORD)sizeof(host); if (!GetComputerNameA(host, &hn) || !host[0]) snprintf(host, sizeof(host), "%s", "seat"); #else if (gethostname(host, sizeof(host)) != 0 || !host[0]) snprintf(host, sizeof(host), "%s", "seat"); host[sizeof(host) - 1] = 0; #endif size_t j = 0; for (size_t i = 0; host[i] && j + 1 < n; i++) { unsigned char c = (unsigned char)host[i]; if (isalnum(c) || c == '-' || c == '_') out[j++] = (char)c; else if (c == '.' || c == ' ') out[j++] = '-'; } out[j] = 0; if (!out[0]) snprintf(out, n, "%s", "seat"); return 0; } int cst_parliament_path(char *out, size_t n) { char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) return -1; if (os_mkdir_p(dir, 0700) != 0) return -1; return os_path_join(out, n, dir, "parliament.json"); } static int context_path(char *out, size_t n) { char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) return -1; return os_path_join(out, n, dir, "parliament.context"); } int cst_parliament_load(cst_parliament_meta_t *m) { if (!m) return -1; memset(m, 0, sizeof(*m)); char path[OS_PATH_MAX]; if (cst_parliament_path(path, sizeof(path)) != 0) return -1; FILE *f = fopen(path, "rb"); if (!f) { snprintf(m->status, sizeof(m->status), "%s", "idle"); return 0; } cst_buf_t b = {0}; char chunk[4096]; size_t n; while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 256 * 1024) cst_buf_append(&b, chunk, n); fclose(f); cJSON *o = cJSON_Parse(b.buf ? b.buf : "{}"); cst_buf_free(&b); if (!o) return -1; const cJSON *v; v = cJSON_GetObjectItemCaseSensitive(o, "parliament_id"); if (cJSON_IsString(v)) snprintf(m->parliament_id, sizeof(m->parliament_id), "%s", v->valuestring); v = cJSON_GetObjectItemCaseSensitive(o, "project_id"); if (cJSON_IsString(v)) snprintf(m->project_id, sizeof(m->project_id), "%s", v->valuestring); v = cJSON_GetObjectItemCaseSensitive(o, "chair_instance"); if (cJSON_IsString(v)) snprintf(m->chair_instance, sizeof(m->chair_instance), "%s", v->valuestring); v = cJSON_GetObjectItemCaseSensitive(o, "chair_device"); if (cJSON_IsString(v)) snprintf(m->chair_device, sizeof(m->chair_device), "%s", v->valuestring); v = cJSON_GetObjectItemCaseSensitive(o, "revision"); if (cJSON_IsNumber(v)) m->revision = (unsigned long)v->valuedouble; v = cJSON_GetObjectItemCaseSensitive(o, "status"); if (cJSON_IsString(v)) snprintf(m->status, sizeof(m->status), "%s", v->valuestring); else snprintf(m->status, sizeof(m->status), "%s", "idle"); cJSON_Delete(o); return 0; } int cst_parliament_save(const cst_parliament_meta_t *m) { if (!m) return -1; char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8]; if (cst_parliament_path(path, sizeof(path)) != 0) return -1; if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) return -1; cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "parliament_id", m->parliament_id); cJSON_AddStringToObject(o, "project_id", m->project_id); cJSON_AddStringToObject(o, "chair_instance", m->chair_instance); cJSON_AddStringToObject(o, "chair_device", m->chair_device); cJSON_AddNumberToObject(o, "revision", (double)m->revision); cJSON_AddStringToObject(o, "status", m->status); char *txt = cJSON_PrintUnformatted(o); cJSON_Delete(o); if (!txt) return -1; FILE *f = fopen(tmp, "wb"); int ok = 0; if (f) { ok = fputs(txt, f) >= 0 && fputc('\n', f) >= 0; if (fclose(f) != 0) ok = 0; } free(txt); if (!ok) { remove(tmp); return -1; } if (os_rename_replace(tmp, path) != 0) { remove(tmp); return -1; } return 0; } char *cst_parliament_device_context(void) { cst_buf_t b = {0}; char host[128] = "unknown", cwd[OS_PATH_MAX] = "."; #ifdef _WIN32 DWORD n = sizeof(host); GetComputerNameA(host, &n); _getcwd(cwd, (int)sizeof(cwd)); cst_buf_printf(&b, "os=windows\nhost=%s\ncwd=%s\npid=%d\n", host, cwd, (int)GetCurrentProcessId()); #else gethostname(host, sizeof(host)); if (!getcwd(cwd, sizeof(cwd))) snprintf(cwd, sizeof(cwd), "."); cst_buf_printf(&b, "os=unix\nhost=%s\ncwd=%s\npid=%d\n", host, cwd, (int)getpid()); #ifdef __linux__ cst_buf_puts(&b, "platform=linux\n"); #elif defined(__APPLE__) cst_buf_puts(&b, "platform=macos\n"); #endif #endif return cst_buf_steal(&b); } int cst_parliament_claim_chair(const char *instance_id, const char *device_label) { if (!instance_id || !instance_id[0]) return -1; cst_parliament_meta_t m; if (cst_parliament_load(&m) != 0) return -1; if (!m.parliament_id[0]) { snprintf(m.parliament_id, sizeof(m.parliament_id), "parl-%ld", (long)time(NULL)); snprintf(m.status, sizeof(m.status), "%s", "open"); } /* Single chair: logging-in device always takes the chair (user will). */ snprintf(m.chair_instance, sizeof(m.chair_instance), "%s", instance_id); snprintf(m.chair_device, sizeof(m.chair_device), "%s", device_label ? device_label : ""); m.revision++; snprintf(m.status, sizeof(m.status), "%s", "open"); return cst_parliament_save(&m); } static int context_write_file(const char *text) { char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8]; if (context_path(path, sizeof(path)) != 0) return -1; if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) return -1; FILE *f = fopen(tmp, "wb"); if (!f) return -1; int ok = fputs(text ? text : "", f) >= 0; if (fclose(f) != 0) ok = 0; if (!ok) { remove(tmp); return -1; } if (os_rename_replace(tmp, path) != 0) { remove(tmp); return -1; } return 0; } int cst_parliament_context_put(const char *text) { if (context_write_file(text) != 0) return -1; cst_parliament_meta_t m; if (cst_parliament_load(&m) == 0) { m.revision++; cst_parliament_save(&m); } cst_parliament_sync_request(); return 0; } char *cst_parliament_context_get(void) { char path[OS_PATH_MAX]; if (context_path(path, sizeof(path)) != 0) return strdup(""); FILE *f = fopen(path, "rb"); if (!f) return strdup(""); cst_buf_t b = {0}; char chunk[4096]; size_t n; while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 2 * 1024 * 1024) cst_buf_append(&b, chunk, n); fclose(f); return b.buf ? cst_buf_steal(&b) : strdup(""); } int cst_parliament_context_append(const char *seat_id, const char *kind, const char *body) { if (!body || !body[0]) return -1; const char *seat = seat_id && seat_id[0] ? seat_id : "unknown"; const char *k = kind && kind[0] ? kind : "note"; char *prev = cst_parliament_context_get(); cst_buf_t b = {0}; if (prev && prev[0]) { cst_buf_puts(&b, prev); if (prev[strlen(prev) - 1] != '\n') cst_buf_puts(&b, "\n"); } free(prev); time_t now = time(NULL); struct tm tm; #ifdef _WIN32 gmtime_s(&tm, &now); #else gmtime_r(&now, &tm); #endif char ts[40]; strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%MZ", &tm); cst_buf_printf(&b, "---\nseat=%s kind=%s at=%s\n", seat, k, ts); cst_buf_puts(&b, body); if (body[strlen(body) - 1] != '\n') cst_buf_puts(&b, "\n"); /* Keep the newest tail when the shared blob grows too large. */ if (b.len > PARL_CONTEXT_MAX) { size_t keep = PARL_CONTEXT_MAX - 64; const char *start = b.buf + (b.len - keep); while (start > b.buf && *start != '\n') start++; if (*start == '\n') start++; cst_buf_t slim = {0}; cst_buf_puts(&slim, "…(earlier parliament notes truncated)\n"); cst_buf_append(&slim, start, (size_t)(b.buf + b.len - start)); cst_buf_free(&b); b = slim; } int rc = cst_parliament_context_put(b.buf); cst_buf_free(&b); return rc; } int cst_parliament_new_project(const char *project_id) { cst_parliament_meta_t m; memset(&m, 0, sizeof(m)); snprintf(m.parliament_id, sizeof(m.parliament_id), "parl-%ld", (long)time(NULL)); snprintf(m.project_id, sizeof(m.project_id), "%s", project_id && project_id[0] ? project_id : "default"); snprintf(m.status, sizeof(m.status), "%s", "open"); m.revision = 1; /* Keep chair empty until next login claims it. */ cst_parliament_session_clear(); (void)cst_parliament_daemon_stop(); if (cst_parliament_save(&m) != 0) return -1; return context_write_file(""); } char *cst_parliament_login(const char *password, const char *instance_id, const char *device_label, char *err, size_t err_sz) { if (err && err_sz) err[0] = 0; if (!password || !password[0] || !instance_id || !instance_id[0]) { if (err && err_sz) snprintf(err, err_sz, "password and instance id required"); return NULL; } char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) { if (err && err_sz) snprintf(err, err_sz, "realm dir missing"); return NULL; } if (!cst_realm_is_password_mode(dir)) { if (err && err_sz) snprintf(err, err_sz, "realm is not password mode; run --realm-password-init"); return NULL; } cst_realm_keys_t keys; if (cst_realm_unlock_password(dir, password, &keys) != 0) { if (err && err_sz) snprintf(err, err_sz, "login failed (bad password)"); return NULL; } /* Ensure this device has an enrolled instance cert. */ cst_realm_cert_t cert; uint8_t sk[64]; if (cst_realm_instance_load(instance_id, &cert, sk) != 0) { uint8_t seed[32]; if (cst_realm_enroll(&keys, instance_id, device_label, 0, &cert, seed, sk) != 0 || cst_realm_instance_save(instance_id, &cert, seed) != 0) { if (err && err_sz) snprintf(err, err_sz, "enroll instance failed"); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); return NULL; } } cst_parliament_meta_t before; cst_parliament_load(&before); char prev_chair[64]; snprintf(prev_chair, sizeof(prev_chair), "%s", before.chair_instance); if (cst_parliament_claim_chair(instance_id, device_label) != 0) { if (err && err_sz) snprintf(err, err_sz, "claim chair failed"); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); return NULL; } char *ctx = cst_parliament_context_get(); char *dev = cst_parliament_device_context(); cst_buf_t hand = {0}; cst_buf_puts(&hand, "LOGIN_OK\n"); cst_buf_printf(&hand, "chair=%s\n", instance_id); if (prev_chair[0] && strcmp(prev_chair, instance_id) != 0) cst_buf_printf(&hand, "handover_from=%s\n", prev_chair); else cst_buf_puts(&hand, "handover_from=\n"); cst_buf_printf(&hand, "device_context=\n%s", dev ? dev : ""); cst_buf_puts(&hand, "--- parliament_context ---\n"); cst_buf_puts(&hand, ctx ? ctx : ""); free(ctx); free(dev); if (cst_parliament_session_write(instance_id) != 0) { if (err && err_sz) snprintf(err, err_sz, "session ticket write failed"); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); crypto_wipe(sk, sizeof(sk)); cst_buf_free(&hand); return NULL; } crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); crypto_wipe(sk, sizeof(sk)); cst_parliament_sync_request(); return cst_buf_steal(&hand); } char *cst_parliament_seat_join(const char *password, const char *instance_id, const char *device_label, char *err, size_t err_sz) { if (err && err_sz) err[0] = 0; if (!password || !password[0] || !instance_id || !instance_id[0]) { if (err && err_sz) snprintf(err, err_sz, "password and instance id required"); return NULL; } char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) { if (err && err_sz) snprintf(err, err_sz, "realm dir missing"); return NULL; } if (!cst_realm_is_password_mode(dir)) { if (err && err_sz) snprintf(err, err_sz, "realm is not password mode"); return NULL; } cst_realm_keys_t keys; if (cst_realm_unlock_password(dir, password, &keys) != 0) { if (err && err_sz) snprintf(err, err_sz, "join failed (bad password)"); return NULL; } cst_realm_cert_t cert; uint8_t sk[64]; if (cst_realm_instance_load(instance_id, &cert, sk) != 0) { uint8_t seed[32]; if (cst_realm_enroll(&keys, instance_id, device_label, 0, &cert, seed, sk) != 0 || cst_realm_instance_save(instance_id, &cert, seed) != 0) { if (err && err_sz) snprintf(err, err_sz, "enroll instance failed"); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); return NULL; } } cst_parliament_meta_t m; cst_parliament_load(&m); int as_chair = 0; if (!m.chair_instance[0]) { if (cst_parliament_claim_chair(instance_id, device_label) != 0) { if (err && err_sz) snprintf(err, err_sz, "claim vacant chair failed"); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); crypto_wipe(sk, sizeof(sk)); return NULL; } as_chair = 1; } else if (strcmp(m.chair_instance, instance_id) == 0) as_chair = 1; cst_parliament_load(&m); if (cst_parliament_session_write(instance_id) != 0) { if (err && err_sz) snprintf(err, err_sz, "session ticket write failed"); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); crypto_wipe(sk, sizeof(sk)); return NULL; } char *ctx = cst_parliament_context_get(); char *dev = cst_parliament_device_context(); cst_buf_t hand = {0}; cst_buf_puts(&hand, "JOIN_OK\n"); cst_buf_printf(&hand, "seat=%s\n", instance_id); cst_buf_printf(&hand, "role=%s\n", as_chair ? "chair" : "member"); cst_buf_printf(&hand, "chair=%s\n", m.chair_instance[0] ? m.chair_instance : ""); cst_buf_printf(&hand, "device_context=\n%s", dev ? dev : ""); cst_buf_puts(&hand, "--- parliament_context ---\n"); cst_buf_puts(&hand, ctx ? ctx : ""); free(ctx); free(dev); crypto_wipe(keys.seed, sizeof(keys.seed)); crypto_wipe(keys.sk, sizeof(keys.sk)); crypto_wipe(sk, sizeof(sk)); return cst_buf_steal(&hand); } static int session_path(char *out, size_t n) { char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) return -1; return os_path_join(out, n, dir, "chair.session"); } int cst_parliament_session_write(const char *instance_id) { if (!instance_id || !instance_id[0]) return -1; char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8]; if (session_path(path, sizeof(path)) != 0) return -1; if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) return -1; FILE *f = fopen(tmp, "wb"); if (!f) return -1; int ok = fprintf(f, "instance=%s\nissued=%ld\n", instance_id, (long)time(NULL)) > 0; if (fclose(f) != 0) ok = 0; if (!ok) { remove(tmp); return -1; } if (os_rename_replace(tmp, path) != 0) { remove(tmp); return -1; } return 0; } int cst_parliament_session_ok(char *instance_id_out, size_t n) { char path[OS_PATH_MAX]; if (session_path(path, sizeof(path)) != 0) return 0; FILE *f = fopen(path, "rb"); if (!f) return 0; char line[256], iid[64] = {0}; long issued = 0; while (fgets(line, sizeof(line), f)) { if (!strncmp(line, "instance=", 9)) { const char *p = line + 9; size_t i = 0; while (p[i] && p[i] != '\n' && p[i] != '\r' && i + 1 < sizeof(iid)) { iid[i] = p[i]; i++; } iid[i] = 0; } else if (!strncmp(line, "issued=", 7)) issued = atol(line + 7); } fclose(f); if (!iid[0] || issued <= 0) return 0; /* Ticket valid for 24h of wall clock. */ long now = (long)time(NULL); if (now < issued || now - issued > 86400) return 0; cst_parliament_meta_t m; if (cst_parliament_load(&m) != 0) return 0; /* Any enrolled seat may hold a ticket while the parliament is open. * Chair exclusivity is tracked in meta, not by invalidating member seats. */ if (!m.status[0] || !strcmp(m.status, "idle") || !strcmp(m.status, "dissolved")) return 0; cst_realm_cert_t cert; uint8_t sk[64]; if (cst_realm_instance_load(iid, &cert, sk) != 0) return 0; crypto_wipe(sk, sizeof(sk)); if (instance_id_out && n) snprintf(instance_id_out, n, "%s", iid); return 1; } int cst_parliament_is_chair(void) { char sid[64] = {0}; if (!cst_parliament_session_ok(sid, sizeof(sid))) return 0; cst_parliament_meta_t m; if (cst_parliament_load(&m) != 0 || !m.chair_instance[0]) return 0; return strcmp(m.chair_instance, sid) == 0; } void cst_parliament_session_clear(void) { char path[OS_PATH_MAX]; if (session_path(path, sizeof(path)) == 0) remove(path); } int cst_parliament_apply_sync(unsigned long revision, const char *parliament_id, const char *project_id, const char *chair, const char *context) { cst_parliament_meta_t m; if (cst_parliament_load(&m) != 0) return -1; if (revision <= m.revision) return 0; if (parliament_id && parliament_id[0]) snprintf(m.parliament_id, sizeof(m.parliament_id), "%s", parliament_id); if (project_id) snprintf(m.project_id, sizeof(m.project_id), "%s", project_id); if (chair) { char local_sid[64] = {0}; int had = cst_parliament_session_ok(local_sid, sizeof(local_sid)); /* Only demote/clear when this seat previously held the chair. */ if (chair[0] && had && m.chair_instance[0] && !strcmp(local_sid, m.chair_instance) && strcmp(local_sid, chair) != 0) cst_parliament_session_clear(); if (!chair[0] && had && m.chair_instance[0] && !strcmp(local_sid, m.chair_instance)) cst_parliament_session_clear(); snprintf(m.chair_instance, sizeof(m.chair_instance), "%s", chair); } m.revision = revision; snprintf(m.status, sizeof(m.status), "%s", "open"); if (context_write_file(context ? context : "") != 0) return -1; return cst_parliament_save(&m); } int cst_parliament_is_active(void) { /* Solo (legacy_workers) is the default for single-machine execution. * Password Realm alone no longer forces parliament — that starved task / * worker_* / runtime tools. Opt in via CST_COLLABORATION / settings, or * when an open parliament meta already exists. */ const char *mode = hermit_getenv("COLLABORATION"); if (mode && mode[0]) return strcmp(mode, "parliament") == 0; cst_settings_t s; if (cst_settings_load(&s) == 0 && s.collaboration[0]) return strcmp(s.collaboration, "parliament") == 0; cst_parliament_meta_t m; if (cst_parliament_load(&m) == 0 && m.status[0] && strcmp(m.status, "idle") != 0 && strcmp(m.status, "dissolved") != 0) return 1; return 0; } char *cst_parliament_status_text(void) { cst_buf_t b = {0}; char dir[OS_PATH_MAX]; if (cst_realm_dir(dir, sizeof(dir)) != 0) { cst_buf_puts(&b, "parliament (no realm dir)\n"); return cst_buf_steal(&b); } int pw = cst_realm_is_password_mode(dir); cst_parliament_meta_t m; memset(&m, 0, sizeof(m)); cst_parliament_load(&m); char sid[64] = {0}; int sess = cst_parliament_session_ok(sid, sizeof(sid)); int svc_pid = 0, svc_port = 0; char svc_id[64] = {0}; int svc = cst_parliament_daemon_info(&svc_pid, svc_id, sizeof(svc_id), &svc_port) == 0; cst_buf_printf(&b, "realm %s\n", pw ? "password" : "file-keys/legacy"); cst_buf_printf(&b, "parliament %s project=%s rev=%lu\n", m.status[0] ? m.status : "idle", m.project_id[0] ? m.project_id : "-", m.revision); cst_buf_printf(&b, "chair %s%s%s\n", m.chair_instance[0] ? m.chair_instance : "(none)", m.chair_device[0] ? " @ " : "", m.chair_device); cst_buf_printf(&b, "login %s%s%s\n", sess ? "ok" : "required", sess && sid[0] ? " as " : "", sess ? sid : ""); if (svc) cst_buf_printf(&b, "service running pid=%d id=%s port=%d\n", svc_pid, svc_id[0] ? svc_id : "-", svc_port); else cst_buf_puts(&b, "service stopped\n"); cst_buf_printf(&b, "role %s\n", sess ? (cst_parliament_is_chair() ? "chair" : "member") : "logged-out"); cst_buf_puts(&b, "peer_sync sealed (X25519 + XChaCha20-Poly1305)\n"); { cst_peer_info_t peers[16]; int np = cst_discover_local_list(NULL, peers, 16); if (np > 0) { cst_buf_puts(&b, "peers\n"); for (int i = 0; i < np; i++) { int is_chair = m.chair_instance[0] && !strcmp(peers[i].instance_id, m.chair_instance); cst_buf_printf(&b, " - %s%s port=%d\n", peers[i].instance_id, is_chair ? " (chair)" : "", peers[i].port); } } else { cst_buf_puts(&b, "peers (none discovered yet)\n"); } } return cst_buf_steal(&b); }