Files
zerogravity/src/bin/zg.rs
Louie 7455f76351 feat: match Go TLS fingerprint for MITM upstream (#11)
* feat: match Go TLS fingerprint for MITM upstream connections

Replace rustls with boring2 (BoringSSL) for all MITM→Google upstream
connections, configured with Go crypto/tls exact defaults:

- Cipher suites: TLS_AES_128_GCM_SHA256 + 14 others in Go order
- Curves: X25519, P-256, P-384
- Signature algorithms: ECDSA+SHA256, RSA-PSS+SHA256, etc.
- HTTP/2 SETTINGS: 4MB stream window, 1GB connection window, 10MB
  header list, no adaptive windowing

Local TLS (LS→MITM) still uses rustls for CA cert presentation.
boring2/tokio-boring2 were already compiled as transitive deps from
wreq — no new build time added.

* chore: fmt + update README TLS description
2026-02-18 16:15:08 -06:00

541 lines
14 KiB
Rust

//! `zg` — ZeroGravity daemon manager.
//!
//! All commands exit immediately (safe for agent use via fast-bash MCP).
//! Platform-aware: uses systemd on Linux, launchctl on macOS, and direct
//! process management on Windows.
use std::process::{Command, Stdio};
const SERVICE: &str = "zerogravity";
const PORT: u16 = 8741;
// macOS plist identifier
#[cfg(target_os = "macos")]
const PLIST_LABEL: &str = "com.zerogravity.proxy";
// ANSI colors
const RED: &str = "\x1b[0;31m";
const GREEN: &str = "\x1b[0;32m";
const YELLOW: &str = "\x1b[1;33m";
const CYAN: &str = "\x1b[0;36m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const NC: &str = "\x1b[0m";
fn main() {
let args: Vec<String> = std::env::args().collect();
let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("");
let arg = args.get(2).map(|s| s.as_str());
match cmd {
"start" => do_start(),
"stop" => do_stop(),
"restart" => do_restart(),
"rebuild" => do_build(),
"status" => do_status(),
"logs" => do_logs(arg.unwrap_or("30"), false),
"logs-follow" => do_logs(arg.unwrap_or("30"), true),
"logs-all" => do_logs_all(),
"test" => do_test(arg.unwrap_or("Say hello in exactly 3 words")),
"health" => do_health(),
_ => usage(),
}
}
fn usage() {
println!("{BOLD}zg{NC} — ZeroGravity daemon manager\n");
println!(" {CYAN}start{NC} Start the proxy daemon");
println!(" {CYAN}stop{NC} Stop the proxy daemon");
println!(" {CYAN}restart{NC} Rebuild + restart");
println!(" {CYAN}rebuild{NC} Build release binary only");
println!(" {CYAN}status{NC} Service status + quota + usage");
println!(" {CYAN}logs{NC} [N] Show last N lines (default 30)");
println!(" {CYAN}logs-follow{NC} [N] Tail last N lines + follow");
println!(" {CYAN}logs-all{NC} Full log dump");
println!(" {CYAN}test{NC} [msg] Quick test request (gemini-3-flash)");
println!(" {CYAN}health{NC} Health check");
}
fn project_dir() -> String {
// Resolve project dir from binary location:
// binary is at <project>/target/release/zg or <project>/target/debug/zg
let exe = std::env::current_exe().expect("cannot resolve exe path");
let exe_dir = exe.parent().expect("no parent dir");
// Walk up from target/release/ or target/debug/
if let Some(target_dir) = exe_dir.parent() {
if let Some(project) = target_dir.parent() {
return project.to_string_lossy().to_string();
}
}
// Fallback: use current dir
std::env::current_dir()
.expect("no cwd")
.to_string_lossy()
.to_string()
}
#[allow(dead_code)]
fn binary_path() -> String {
let dir = project_dir();
#[cfg(windows)]
{
format!("{dir}\\target\\release\\zerogravity.exe")
}
#[cfg(not(windows))]
{
format!("{dir}/target/release/zerogravity")
}
}
fn base_url() -> String {
let port = std::env::var("PROXY_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(PORT);
format!("http://localhost:{port}")
}
// ── Platform service management ──
#[cfg(target_os = "linux")]
fn svc_start() -> bool {
systemctl(&["daemon-reload"]);
systemctl(&["start", SERVICE])
}
#[cfg(target_os = "linux")]
fn svc_stop() -> bool {
systemctl(&["stop", SERVICE])
}
#[cfg(target_os = "linux")]
fn svc_status() {
let output = Command::new("systemctl")
.args(["--user", "status", SERVICE, "--no-pager"])
.output();
match output {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout);
for (i, line) in text.lines().enumerate() {
if i >= 6 {
break;
}
println!("{line}");
}
}
Err(_) => println!("{RED}Not running{NC}"),
}
}
#[cfg(target_os = "linux")]
fn svc_logs(n: &str, follow: bool) {
let mut args = vec!["--user", "-u", SERVICE, "--no-pager", "-n", n];
if follow {
args.push("-f");
}
let _ = Command::new("journalctl")
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
}
#[cfg(target_os = "linux")]
fn svc_logs_all() {
let _ = Command::new("journalctl")
.args(["--user", "-u", SERVICE, "--no-pager"])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
}
#[cfg(target_os = "linux")]
fn systemctl(args: &[&str]) -> bool {
Command::new("systemctl")
.arg("--user")
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn svc_show_fail_logs() {
let _ = Command::new("journalctl")
.args(["--user", "-u", SERVICE, "--no-pager", "-n", "20"])
.status();
}
// ── macOS: launchctl ──
#[cfg(target_os = "macos")]
fn plist_path() -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
format!("{home}/Library/LaunchAgents/{PLIST_LABEL}.plist")
}
#[cfg(target_os = "macos")]
fn svc_start() -> bool {
let plist = plist_path();
if !std::path::Path::new(&plist).exists() {
eprintln!("{RED}Plist not found at {plist}{NC}");
eprintln!(" Run ./scripts/setup-macos.sh first");
return false;
}
// bootout first to ensure clean state (ignore errors)
let uid = Command::new("id")
.arg("-u")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "501".into());
let _ = Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}"), &plist])
.status();
Command::new("launchctl")
.args(["load", &plist])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn svc_stop() -> bool {
let plist = plist_path();
Command::new("launchctl")
.args(["unload", &plist])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn svc_status() {
let output = Command::new("launchctl")
.args(["list", PLIST_LABEL])
.output();
match output {
Ok(o) if o.status.success() => {
let text = String::from_utf8_lossy(&o.stdout);
println!("{text}");
}
_ => println!("{RED}Not loaded{NC}"),
}
}
#[cfg(target_os = "macos")]
fn svc_logs(n: &str, follow: bool) {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
let log = format!("{home}/Library/Logs/zerogravity.log");
if !std::path::Path::new(&log).exists() {
println!("{DIM}(no log file yet){NC}");
return;
}
let mut args = vec!["-n", n];
if follow {
args.push("-f");
}
args.push(&log);
let _ = Command::new("tail")
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
}
#[cfg(target_os = "macos")]
fn svc_logs_all() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
let log = format!("{home}/Library/Logs/zerogravity.log");
if std::path::Path::new(&log).exists() {
let _ = Command::new("cat")
.arg(&log)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
} else {
println!("{DIM}(no log file yet){NC}");
}
}
#[cfg(target_os = "macos")]
fn svc_show_fail_logs() {
svc_logs("20", false);
}
// ── Windows / other: direct process management ──
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_start() -> bool {
let bin = binary_path();
if !std::path::Path::new(&bin).exists() {
eprintln!("{RED}Binary not found: {bin}{NC}");
eprintln!(" Run `cargo build --release` first");
return false;
}
// Try starting via scheduled task on Windows
#[cfg(windows)]
{
let result = Command::new("schtasks")
.args(["/run", "/tn", "ZeroGravity Proxy"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if result {
return true;
}
// Fallback: start directly (detached)
eprintln!("{YELLOW}Scheduled task not found, starting directly...{NC}");
}
// Fallback for all non-linux/macos: spawn detached
match Command::new(&bin)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => true,
Err(e) => {
eprintln!("{RED}Failed to start: {e}{NC}");
false
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_stop() -> bool {
#[cfg(windows)]
{
let _ = Command::new("schtasks")
.args(["/end", "/tn", "ZeroGravity Proxy"])
.status();
}
// Also try killing by process name
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/F", "/IM", "zerogravity.exe"])
.status();
}
#[cfg(not(windows))]
{
let _ = Command::new("pkill").args(["-f", "zerogravity"]).status();
}
true
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_status() {
if health_ok() {
println!("{GREEN}Running{NC} (responding on port {PORT})");
} else {
println!("{RED}Not responding on port {PORT}{NC}");
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_logs(_n: &str, _follow: bool) {
println!("{DIM}Logs not available via zg on this platform.{NC}");
println!(" Check the console output where zerogravity was started.");
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_logs_all() {
svc_logs("0", false);
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_show_fail_logs() {
println!("{DIM}Check the console output where zerogravity was started.{NC}");
}
// ── Shared helpers ──
fn curl_get(path: &str) -> Option<String> {
let url = format!("{}{}", base_url(), path);
Command::new("curl")
.args(["-sf", &url])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
}
fn curl_post(path: &str, body: &str) -> Option<String> {
let url = format!("{}{}", base_url(), path);
Command::new("curl")
.args([
"-sf",
&url,
"-H",
"Content-Type: application/json",
"-d",
body,
])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
}
fn health_ok() -> bool {
curl_get("/health").is_some()
}
fn jq_print(json: &str) {
// Try jq first, fall back to raw JSON
match Command::new("jq")
.arg(".")
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
{
Ok(mut child) => {
// Drop stdin before wait so jq sees EOF and doesn't hang
{
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(json.as_bytes());
}
}
let _ = child.wait();
}
Err(_) => {
// jq not installed — print raw
println!("{json}");
}
}
}
// ── Commands ──
fn do_build() {
let dir = project_dir();
println!("{YELLOW}Building release binary...{NC}");
let status = Command::new("cargo")
.args(["build", "--release"])
.current_dir(&dir)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.expect("failed to run cargo");
if status.success() {
println!("{GREEN}Build complete.{NC}");
} else {
eprintln!("{RED}Build failed.{NC}");
std::process::exit(1);
}
}
fn do_start() {
if !svc_start() {
eprintln!("{RED}Failed to start service.{NC}");
std::process::exit(1);
}
println!("{GREEN}Started.{NC} Waiting for ready...");
for _ in 0..20 {
if health_ok() {
println!("{GREEN}ZeroGravity is up on port {PORT}.{NC}");
return;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
eprintln!("{RED}Proxy didn't become healthy in 10s. Check logs:{NC}");
svc_show_fail_logs();
std::process::exit(1);
}
fn do_stop() {
let _ = svc_stop();
println!("{YELLOW}Stopped.{NC}");
}
fn do_restart() {
println!("{YELLOW}Stopping...{NC}");
do_stop();
do_build();
do_start();
}
fn do_status() {
println!("{BOLD}── Service ──{NC}");
svc_status();
println!();
if !health_ok() {
println!("{RED}Proxy is not responding on port {PORT}.{NC}");
std::process::exit(1);
}
println!("{BOLD}── Quota ──{NC}");
match curl_get("/v1/quota") {
Some(json) => jq_print(&json),
None => println!("{DIM}(no quota data){NC}"),
}
println!();
println!("{BOLD}── Usage ──{NC}");
match curl_get("/v1/usage") {
Some(json) => jq_print(&json),
None => println!("{DIM}(no usage data){NC}"),
}
println!();
println!("{BOLD}── Sessions ──{NC}");
match curl_get("/v1/sessions") {
Some(json) => jq_print(&json),
None => println!("{DIM}(no sessions){NC}"),
}
}
fn do_logs(n: &str, follow: bool) {
svc_logs(n, follow);
}
fn do_logs_all() {
svc_logs_all();
}
fn do_test(msg: &str) {
println!("{CYAN}Testing:{NC} {msg}");
let escaped = msg
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
let body =
format!(r#"{{"model":"gemini-3-flash","input":"{escaped}","stream":false,"timeout":30}}"#);
match curl_post("/v1/responses", &body) {
Some(json) => jq_print(&json),
None => {
eprintln!("{RED}Request failed. Is the proxy running?{NC}");
std::process::exit(1);
}
}
}
fn do_health() {
match curl_get("/health") {
Some(json) => {
jq_print(&json);
println!("{GREEN}Healthy{NC}");
}
None => {
println!("{RED}Not responding{NC}");
std::process::exit(1);
}
}
}