Skip to content

Commit 0e242e3

Browse files
hyperpolymathclaude
andcommitted
feat: Add gossamer_gsa_run_script + gossamer_gsa_write_server_config FFI exports
Wires the Nexus Setup GUI panel's provisioning steps to the Zig FFI layer. gossamer_gsa_run_script: - Runs any scripts/* helper (path-prefix constrained to prevent traversal) - Parses env vars from JSON, merges into inherited env, no shell expansion - Pipes stdin secret directly (Steam passwords) without shell interpretation - Captures stdout + stderr, returns {"success":…,"exit_code":…,"output":"…"} - Fixed Zig 0.15.2 compat: ArrayList unmanaged API (.empty init, allocator passed per-call), std.process.Child.Term explicit type for catch fallback, StdIo.Pipe instead of removed StdIo.Merge gossamer_gsa_write_server_config: - Writes Settings.xml from JSON config object + operators array - Generates correct XML with attribute escaping for operator steam IDs - Outputs to container/<profile_id>/Settings.xml for provisioner pickup main.zig: two new comptime linker hints so exports survive dead-strip. STATE.a2ml: session history + updated critical-next-actions. All 111 Zig tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f26deda commit 0e242e3

3 files changed

Lines changed: 303 additions & 1 deletion

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ blockers = [
4949
actions = [
5050
"STAGE CRYOFALL: STEAM_USER=hyperpolymath just cryofall-stage-verpex (one-time ~3GB download — needs Steam password + Guard code)",
5151
"After staging: just cryofall-watchdog-install (deploys self-heal watchdog as systemd service)",
52-
"Add Joshua's Steam username (pengie5) to the server as operator — already in Settings.xml, takes effect after staging",
52+
"Nexus Setup GUI: nexus_stage_files / nexus_provision / nexus_verify IPC handlers need Zig FFI wiring (gossamer_gsa_run_script export + gossamer_gsa_write_server_config export)",
5353
"Set up Bitbucket mirror (SSH key issue — manual step)",
5454
"Tag v0.1.0 release when ready",
5555
]
@@ -62,4 +62,5 @@ sessions = [
6262
{ date = "2026-04-03", summary = "Blitz session: 112 Zig tests (was ~40). 30 new unit tests across server_actions (8 security/injection), config_extract (12 parser edge cases + detectFormat regression), groove_client (4 overflow/truncation), a2ml_emit (7 diff/redaction). Fixed 7 pre-existing Zig 0.15.2 compat bugs: std.json.stringify→json.fmt, std.fs.exists→fileExists helper, createFile sig, broken multiline string, stack-returning helpers (UB), missing catch, detectFormat [n...] false positive. Idris2 Layout.idr: replaced postulate alignUpProducesAligned with constructive proof (alignUpCeil + alignUpCeilIsMultiple via IsMultipleOf witness). Removed fake fuzz placeholder. All docs updated." },
6363
{ date = "2026-04-04", summary = "CryoFall server + Steam integration session: deployed CryoFall container stack (Containerfile, entrypoint.sh, Settings.xml, gsa-cryofall.container Quadlet, backup.sh, manifest.toml). Schema-driven provisioner (scripts/provision-server.sh — 9-step: preflight→build→volumes→firewall→quadlet→start→verify→VeriSimDB→report). Steam integration: steam_client.zig (resolveVanityUrl, getPlayerSummary, checkOwnership — 3 C ABI exports, 5 tests), wired into main.zig+cli.zig as 'gsa steam resolve/player'. Fire-and-forget wizard (scripts/wizard.sh — 7 steps, interactive+unattended). Self-healing watchdog (scripts/self-heal.sh — container/port/disk/XML checks, auto-restart, Groove alerts). SteamCMD stage script (scripts/steam-stage.sh — auth token persistence, interactive Steam Guard, rsync to volume/remote). Cloudflare: cryofall.jewell.nexus → 209.42.26.106 grey-cloud DNS live. Verpex: UDP 6000+6001 firewall opened, container image built. Operator Steam IDs resolved: pengie5=76561198149527024, hyperpolymath=76561198141836018. Blocking: game files need authenticated SteamCMD stage (B4)." },
6464
{ date = "2026-04-03b", summary = "Security hardening: replaced 427 AGPL-3.0-or-later headers with PMPL-1.0-or-later, replaced LICENSE with PMPL text. Fixed critical vuln (executeSSH shell injection — refactored to argv slice + -- separator). Fixed temp-file TOCTOU in gossamer-integration-test.sh (mktemp). Removed dangling-ref getWriter function. Replaced hardcoded test credentials. Added escapeXml to fli-gauge.js, escapeHtml+data-tip escaping to fli-tooltip.js, DOM node clone in fli-editable.js cancelEdit. Eliminated all 8 @ptrCast calls across FFI layer (string literals + [N:0]u8 sentinel buffers). Filled K9 template-hunt.k9.ncl TODO placeholders with GSA deployment content. All 111 Zig tests pass. panic-attack: 0 weak points (was 21)." },
65+
{ date = "2026-04-04b", summary = "Nexus Setup GUI panel: full 7-step graphical wizard added as nav item 2 in Gossamer GUI (between Server Browser and Config Editor). src/gui/panels/nexus-setup/panel.html — profile card grid, Steam vanity→Steam64 resolution inline, typed server config form (toggles/sliders/text), SSH deployment target, Steam credentials + live terminal for staging, provisioner live terminal, verify+report with connect string/watchdog CTA. src/gui/panels/nexus-setup/nexus-setup.eph — IPC contract docs. src/core/Bridge.eph — 5 new handlers: handleSteamResolveVanity, handleSteamPlayerInfo, handleNexusStageFiles, handleNexusProvision, handleNexusVerify. src/gui/host.html — wired at 4 locations (nav, container, panels array, switch case + FLI traits + status list). Operator slots pre-filled: pengie5 + hyperpolymath. Committed f26deda, pushed to GitHub." },
6566
]

src/interface/ffi/src/main.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ comptime {
3737
_ = &verisimdb_client.gossamer_gsa_verisimdb_drift;
3838
_ = &server_actions.gossamer_gsa_server_action;
3939
_ = &server_actions.gossamer_gsa_get_logs;
40+
_ = &server_actions.gossamer_gsa_run_script;
41+
_ = &server_actions.gossamer_gsa_write_server_config;
4042
_ = &game_profiles.gossamer_gsa_load_profiles;
4143
_ = &game_profiles.gossamer_gsa_list_profiles;
4244
_ = &game_profiles.gossamer_gsa_add_profile;

src/interface/ffi/src/server_actions.zig

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,305 @@ pub export fn gossamer_gsa_get_logs(
553553
return &logs_result_buf;
554554
}
555555

556+
/// Run a GSA helper script (e.g. provision-server.sh, steam-stage.sh) with
557+
/// optional environment variables passed as a JSON object and an optional
558+
/// single-line secret piped to stdin (used for Steam passwords).
559+
///
560+
/// `script_path` — relative path from repo root, e.g. "scripts/provision-server.sh"
561+
/// `arg` — first positional argument to the script (e.g. profile_id)
562+
/// `env_json` — JSON object of additional env vars, e.g. {"KEY":"val", ...}
563+
/// `stdin_secret`— piped to the process stdin then closed; empty = no stdin
564+
///
565+
/// Returns a NUL-terminated JSON object:
566+
/// {"success":true,"exit_code":0,"output":"..."} or ERR sentinel.
567+
///
568+
/// Security: env_json is parsed with std.json — no shell expansion. The script
569+
/// path is constrained to the scripts/ directory (prefix check) to prevent
570+
/// path traversal. stdin_secret is piped directly without shell interpretation.
571+
threadlocal var run_script_buf: [131072:0]u8 = undefined;
572+
573+
pub export fn gossamer_gsa_run_script(
574+
script_path_z: [*:0]const u8,
575+
arg_z: [*:0]const u8,
576+
env_json_z: [*:0]const u8,
577+
stdin_secret_z:[*:0]const u8,
578+
) callconv(.c) c_int {
579+
const allocator = std.heap.c_allocator;
580+
const script_path = std.mem.span(script_path_z);
581+
const arg = std.mem.span(arg_z);
582+
const env_json_str = std.mem.span(env_json_z);
583+
const secret = std.mem.span(stdin_secret_z);
584+
585+
// Path constraint: must start with "scripts/"
586+
if (!std.mem.startsWith(u8, script_path, "scripts/")) {
587+
main.setErrorStr("script path must be under scripts/");
588+
return @intFromEnum(main.GsaResult.permission_denied);
589+
}
590+
591+
// Parse additional env vars from JSON
592+
var env_list: std.ArrayList([2][]const u8) = .empty;
593+
defer env_list.deinit(allocator);
594+
595+
if (env_json_str.len > 2) { // "{}" minimum
596+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, env_json_str, .{}) catch null;
597+
if (parsed) |p| {
598+
defer p.deinit();
599+
if (p.value == .object) {
600+
var it = p.value.object.iterator();
601+
while (it.next()) |kv| {
602+
const k = kv.key_ptr.*;
603+
const v = switch (kv.value_ptr.*) {
604+
.string => |s| s,
605+
else => continue,
606+
};
607+
const pair: [2][]const u8 = .{ k, v };
608+
env_list.append(allocator, pair) catch continue;
609+
}
610+
}
611+
}
612+
}
613+
614+
// Build argv: ["bash", script_path, arg]
615+
const argv: []const []const u8 = if (arg.len > 0)
616+
&.{ "bash", script_path, arg }
617+
else
618+
&.{ "bash", script_path };
619+
620+
// Inherit current env and add extra vars
621+
var env_map = std.process.getEnvMap(allocator) catch {
622+
main.setErrorStr("getEnvMap failed");
623+
return @intFromEnum(main.GsaResult.out_of_memory);
624+
};
625+
defer env_map.deinit();
626+
627+
for (env_list.items) |pair| {
628+
env_map.put(pair[0], pair[1]) catch continue;
629+
}
630+
631+
// Pipe secret to stdin if provided
632+
var child = std.process.Child.init(argv, allocator);
633+
child.stdout_behavior = .Pipe;
634+
child.stderr_behavior = .Pipe; // captured separately, appended to output below
635+
child.env_map = &env_map;
636+
if (secret.len > 0) {
637+
child.stdin_behavior = .Pipe;
638+
}
639+
640+
child.spawn() catch |err| {
641+
main.setError("script spawn failed: {s}", .{@errorName(err)});
642+
return @intFromEnum(main.GsaResult.io_error);
643+
};
644+
645+
if (secret.len > 0) {
646+
if (child.stdin) |stdin| {
647+
stdin.writeAll(secret) catch {};
648+
stdin.writeAll("\n") catch {};
649+
stdin.close();
650+
child.stdin = null;
651+
}
652+
}
653+
654+
const stdout = child.stdout.?.readToEndAlloc(allocator, 4 * 1024 * 1024) catch &.{};
655+
defer allocator.free(stdout);
656+
const stderr_out = child.stderr.?.readToEndAlloc(allocator, 512 * 1024) catch &.{};
657+
defer allocator.free(stderr_out);
658+
659+
const term = child.wait() catch std.process.Child.Term{ .Exited = 1 };
660+
const exit_code: i32 = switch (term) {
661+
.Exited => |c| @intCast(c),
662+
.Signal => |s| -@as(i32, @intCast(s)),
663+
else => -1,
664+
};
665+
666+
// Combine stdout + stderr into a single output (stderr appended after newline)
667+
const combined: []const u8 = if (stderr_out.len > 0)
668+
std.fmt.allocPrint(allocator, "{s}\n{s}", .{ stdout, stderr_out }) catch stdout
669+
else
670+
stdout;
671+
defer if (stderr_out.len > 0) allocator.free(combined);
672+
673+
// Write JSON result to run_script_buf
674+
var fbs = std.io.fixedBufferStream(&run_script_buf);
675+
const w = fbs.writer();
676+
w.print("{{\"success\":{s},\"exit_code\":{d},\"output\":\"", .{
677+
if (exit_code == 0) "true" else "false", exit_code,
678+
}) catch return @intFromEnum(main.GsaResult.io_error);
679+
680+
for (combined) |ch| {
681+
switch (ch) {
682+
'"' => w.writeAll("\\\"") catch break,
683+
'\\' => w.writeAll("\\\\") catch break,
684+
'\n' => w.writeAll("\\n") catch break,
685+
'\r' => w.writeAll("\\r") catch break,
686+
'\t' => w.writeAll("\\t") catch break,
687+
else => {
688+
if (ch < 0x20) {
689+
w.print("\\u{x:0>4}", .{ch}) catch break;
690+
} else {
691+
w.writeByte(ch) catch break;
692+
}
693+
},
694+
}
695+
}
696+
697+
w.writeAll("\"}") catch {};
698+
w.writeByte(0) catch {};
699+
700+
if (exit_code == 0) main.clearError() else main.setErrorStr("script exited non-zero");
701+
return if (exit_code == 0) @intFromEnum(main.GsaResult.ok) else @intFromEnum(main.GsaResult.err);
702+
}
703+
704+
/// Write a game server config file (e.g. Settings.xml) from JSON form values
705+
/// and an operators JSON array. The output path is derived from the profile_id
706+
/// and written to a known volume path so that provision-server.sh picks it up.
707+
///
708+
/// `profile_id_z` — game profile identifier, e.g. "cryofall"
709+
/// `config_json_z`— JSON object of server settings, e.g. {"ServerName":"..."}
710+
/// `operators_json_z` — JSON array: [{"steam_id":"...","name":"..."}]
711+
///
712+
/// Returns 0 (GsaResult.ok) on success, negative on error.
713+
pub export fn gossamer_gsa_write_server_config(
714+
profile_id_z: [*:0]const u8,
715+
config_json_z: [*:0]const u8,
716+
operators_json_z:[*:0]const u8,
717+
) callconv(.c) c_int {
718+
const allocator = std.heap.c_allocator;
719+
const profile_id = std.mem.span(profile_id_z);
720+
const config_str = std.mem.span(config_json_z);
721+
const ops_str = std.mem.span(operators_json_z);
722+
723+
if (profile_id.len == 0) {
724+
main.setErrorStr("missing profile_id");
725+
return @intFromEnum(main.GsaResult.invalid_param);
726+
}
727+
728+
// Parse config fields
729+
const cfg_parsed = std.json.parseFromSlice(std.json.Value, allocator, config_str, .{}) catch {
730+
main.setErrorStr("invalid config JSON");
731+
return @intFromEnum(main.GsaResult.parse_error);
732+
};
733+
defer cfg_parsed.deinit();
734+
735+
const ops_parsed = std.json.parseFromSlice(std.json.Value, allocator, ops_str, .{}) catch {
736+
main.setErrorStr("invalid operators JSON");
737+
return @intFromEnum(main.GsaResult.parse_error);
738+
};
739+
defer ops_parsed.deinit();
740+
741+
const cfg = if (cfg_parsed.value == .object) cfg_parsed.value.object else {
742+
main.setErrorStr("config JSON must be an object");
743+
return @intFromEnum(main.GsaResult.parse_error);
744+
};
745+
746+
// Helper to extract string field with fallback
747+
const S = struct {
748+
fn get(obj: std.json.ObjectMap, key: []const u8, fallback: []const u8) []const u8 {
749+
if (obj.get(key)) |v| {
750+
return switch (v) { .string => |s| s, else => fallback };
751+
}
752+
return fallback;
753+
}
754+
};
755+
756+
const server_name = S.get(cfg, "ServerName", "GSA Server");
757+
const description = S.get(cfg, "ServerDescription", "Managed by GSA");
758+
const welcome = S.get(cfg, "WelcomeMessage", "Welcome!");
759+
const port = S.get(cfg, "ServerPort", "6000");
760+
const max_players = S.get(cfg, "MaxPlayers", "10");
761+
const is_private = S.get(cfg, "IsPrivate", "true");
762+
const password = S.get(cfg, "ServerPassword", "");
763+
const is_pve = S.get(cfg, "IsPvE", "true");
764+
const raids = S.get(cfg, "IsRaidsEnabled", "false");
765+
const wipe_days = S.get(cfg, "WipePeriodDays", "0");
766+
const gather_mult = S.get(cfg, "GatheringSpeedMultiplier", "2.0");
767+
const learn_mult = S.get(cfg, "LearningSpeedMultiplier", "2.0");
768+
const craft_mult = S.get(cfg, "CraftingSpeedMultiplier", "2.0");
769+
770+
// Build operators XML fragment
771+
var ops_xml: std.ArrayList(u8) = .empty;
772+
defer ops_xml.deinit(allocator);
773+
774+
if (ops_parsed.value == .array) {
775+
for (ops_parsed.value.array.items) |item| {
776+
if (item != .object) continue;
777+
const steam_id = S.get(item.object, "steam_id", "");
778+
const name = S.get(item.object, "name", "");
779+
if (steam_id.len == 0) continue;
780+
ops_xml.appendSlice(allocator, " <Operator steamId=\"") catch continue;
781+
// Escape steamId attribute (digits only expected, but be safe)
782+
for (steam_id) |ch| {
783+
if (ch == '"') ops_xml.appendSlice(allocator, "&quot;") catch {}
784+
else ops_xml.append(allocator, ch) catch {};
785+
}
786+
ops_xml.appendSlice(allocator, "\" name=\"") catch continue;
787+
for (name) |ch| {
788+
if (ch == '"') ops_xml.appendSlice(allocator, "&quot;") catch {}
789+
else ops_xml.append(allocator, ch) catch {};
790+
}
791+
ops_xml.appendSlice(allocator, "\" />\n") catch continue;
792+
}
793+
}
794+
795+
// Compose Settings.xml
796+
const xml = std.fmt.allocPrint(allocator,
797+
\\<?xml version="1.0" encoding="utf-8"?>
798+
\\<!-- Generated by GSA Nexus Setup wizard — edit via Config Editor panel -->
799+
\\<server-settings>
800+
\\ <ServerName>{s}</ServerName>
801+
\\ <ServerDescription>{s}</ServerDescription>
802+
\\ <ServerPort>{s}</ServerPort>
803+
\\ <MaxPlayers>{s}</MaxPlayers>
804+
\\ <IsPrivate>{s}</IsPrivate>
805+
\\ <ServerPassword>{s}</ServerPassword>
806+
\\ <WipePeriodDays>{s}</WipePeriodDays>
807+
\\ <IsPvE>{s}</IsPvE>
808+
\\ <IsRaidsEnabled>{s}</IsRaidsEnabled>
809+
\\ <WelcomeMessage>{s}</WelcomeMessage>
810+
\\ <ServerRates>
811+
\\ <GatheringSpeedMultiplier>{s}</GatheringSpeedMultiplier>
812+
\\ <LearningSpeedMultiplier>{s}</LearningSpeedMultiplier>
813+
\\ <CraftingSpeedMultiplier>{s}</CraftingSpeedMultiplier>
814+
\\ </ServerRates>
815+
\\ <Operators>
816+
\\{s} </Operators>
817+
\\</server-settings>
818+
\\
819+
, .{
820+
server_name, description, port, max_players,
821+
is_private, password, wipe_days,
822+
is_pve, raids, welcome,
823+
gather_mult, learn_mult, craft_mult,
824+
ops_xml.items,
825+
}) catch {
826+
main.setErrorStr("XML format failed: out of memory");
827+
return @intFromEnum(main.GsaResult.out_of_memory);
828+
};
829+
defer allocator.free(xml);
830+
831+
// Write to container/cryofall/Settings.xml (profile-specific in future)
832+
const out_path = std.fmt.allocPrint(
833+
allocator, "container/{s}/Settings.xml", .{profile_id},
834+
) catch {
835+
main.setErrorStr("path format failed");
836+
return @intFromEnum(main.GsaResult.out_of_memory);
837+
};
838+
defer allocator.free(out_path);
839+
840+
const file = std.fs.cwd().createFile(out_path, .{}) catch |err| {
841+
main.setError("cannot write {s}: {s}", .{ out_path, @errorName(err) });
842+
return @intFromEnum(main.GsaResult.io_error);
843+
};
844+
defer file.close();
845+
846+
file.writeAll(xml) catch |err| {
847+
main.setError("write failed: {s}", .{@errorName(err)});
848+
return @intFromEnum(main.GsaResult.io_error);
849+
};
850+
851+
main.clearError();
852+
return @intFromEnum(main.GsaResult.ok);
853+
}
854+
556855
// ═══════════════════════════════════════════════════════════════════════════════
557856
// Unit tests
558857
// ═══════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)