1use std::ffi::OsString;
2use std::fs::{self, File, OpenOptions};
3use std::io::Read;
4use std::net::SocketAddr;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::time::{Duration, Instant};
8
9use chrono::{DateTime, Utc};
10use fs2::FileExt;
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::error::ProxyError;
15
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct DaemonPaths {
19 pub pidfile: PathBuf,
20 pub config: PathBuf,
21 pub log_file: PathBuf,
22 pub stderr_file: PathBuf,
23}
24
25impl DaemonPaths {
26 pub fn new(pidfile: PathBuf, config: PathBuf, log_file: PathBuf, stderr_file: PathBuf) -> Self {
27 Self {
28 pidfile,
29 config,
30 log_file,
31 stderr_file,
32 }
33 }
34
35 pub fn resolve() -> Result<Self, ProxyError> {
36 let data_dir = dirs::data_local_dir()
37 .ok_or_else(|| ProxyError::DaemonConfig {
38 detail: "could not resolve local data directory".to_string(),
39 })?
40 .join("gaze");
41 let config_dir = dirs::config_dir()
42 .ok_or_else(|| ProxyError::DaemonConfig {
43 detail: "could not resolve config directory".to_string(),
44 })?
45 .join("gaze");
46 let log_dir = if cfg!(target_os = "macos") {
47 dirs::home_dir()
48 .ok_or_else(|| ProxyError::DaemonConfig {
49 detail: "could not resolve home directory".to_string(),
50 })?
51 .join("Library/Logs/gaze")
52 } else {
53 data_dir.join("Logs")
54 };
55 Ok(Self {
56 pidfile: data_dir.join("proxy.pid"),
57 config: config_dir.join("proxy.toml"),
58 log_file: log_dir.join("proxy.log"),
59 stderr_file: log_dir.join("proxy-stderr.log"),
60 })
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[non_exhaustive]
66pub struct DaemonConfig {
67 pub bind: SocketAddr,
68 pub session_ttl: String,
69 pub policy: Option<PathBuf>,
70 pub rulepack: Option<String>,
71 pub adapters: AdapterConfig,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[non_exhaustive]
76pub struct AdapterConfig {
77 pub openai: ProviderConfig,
78 pub anthropic: ProviderConfig,
79 pub gemini: ProviderConfig,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct ProviderConfig {
85 pub upstream: Url,
86}
87
88impl ProviderConfig {
89 pub fn new(upstream: Url) -> Self {
90 Self { upstream }
91 }
92}
93
94impl AdapterConfig {
95 pub fn new(openai: Url, anthropic: Url, gemini: Url) -> Self {
96 Self {
97 openai: ProviderConfig::new(openai),
98 anthropic: ProviderConfig::new(anthropic),
99 gemini: ProviderConfig::new(gemini),
100 }
101 }
102}
103
104impl Default for AdapterConfig {
105 fn default() -> Self {
106 Self {
107 openai: ProviderConfig {
108 upstream: Url::parse("https://api.openai.com").expect("static url"),
109 },
110 anthropic: ProviderConfig {
111 upstream: Url::parse("https://api.anthropic.com").expect("static url"),
112 },
113 gemini: ProviderConfig {
114 upstream: Url::parse("https://generativelanguage.googleapis.com")
115 .expect("static url"),
116 },
117 }
118 }
119}
120
121impl Default for DaemonConfig {
122 fn default() -> Self {
123 Self {
124 bind: "127.0.0.1:8787".parse().expect("static bind addr"),
125 session_ttl: "30m".to_string(),
126 policy: None,
127 rulepack: Some("core".to_string()),
128 adapters: AdapterConfig::default(),
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
134#[non_exhaustive]
135pub struct StartOptions {
136 pub paths: DaemonPaths,
137 pub config: DaemonConfig,
138 pub extra_args: Vec<String>,
139}
140
141impl StartOptions {
142 pub fn new(paths: DaemonPaths, config: DaemonConfig) -> Self {
143 Self {
144 paths,
145 config,
146 extra_args: Vec::new(),
147 }
148 }
149
150 pub fn with_extra_args(mut self, extra_args: Vec<String>) -> Self {
151 self.extra_args = extra_args;
152 self
153 }
154}
155
156#[derive(Debug, Clone)]
157#[non_exhaustive]
158pub struct StopOptions {
159 pub paths: DaemonPaths,
160 pub timeout: Duration,
161 pub force: bool,
162}
163
164impl StopOptions {
165 pub fn new(paths: DaemonPaths, timeout: Duration, force: bool) -> Self {
166 Self {
167 paths,
168 timeout,
169 force,
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175#[non_exhaustive]
176pub struct DaemonStatus {
177 pub pid: u32,
178 pub bind: Option<String>,
179 pub started_at: Option<String>,
180 pub running: bool,
181}
182
183pub fn read_or_default_config(paths: &DaemonPaths) -> Result<DaemonConfig, ProxyError> {
184 if !paths.config.exists() {
185 return Ok(DaemonConfig::default());
186 }
187 let text = fs::read_to_string(&paths.config).map_err(|source| ProxyError::DaemonIo {
188 path: paths.config.clone(),
189 source,
190 })?;
191 toml::from_str(&text).map_err(|err| ProxyError::DaemonConfig {
192 detail: err.to_string(),
193 })
194}
195
196pub fn write_config(paths: &DaemonPaths, config: &DaemonConfig) -> Result<(), ProxyError> {
197 if let Some(parent) = paths.config.parent() {
198 fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
199 path: parent.to_path_buf(),
200 source,
201 })?;
202 }
203 let text = toml::to_string_pretty(config).map_err(|err| ProxyError::DaemonConfig {
204 detail: err.to_string(),
205 })?;
206 fs::write(&paths.config, text).map_err(|source| ProxyError::DaemonIo {
207 path: paths.config.clone(),
208 source,
209 })
210}
211
212fn serve_args(config: &DaemonConfig) -> Vec<OsString> {
222 let DaemonConfig {
223 bind,
224 session_ttl,
225 policy,
226 rulepack,
227 adapters,
228 } = config;
229 let AdapterConfig {
230 openai,
231 anthropic,
232 gemini,
233 } = adapters;
234
235 let mut args: Vec<OsString> = vec![
236 "--bind".into(),
237 bind.to_string().into(),
238 "--session-ttl".into(),
239 session_ttl.into(),
240 ];
241 if let Some(policy) = policy {
247 args.push("--policy".into());
248 args.push(policy.into());
249 }
250 if let Some(rulepack) = rulepack {
251 args.push("--rulepack".into());
252 args.push(rulepack.into());
253 }
254 for (flag, ProviderConfig { upstream }) in [
255 ("--upstream-openai", openai),
256 ("--upstream-anthropic", anthropic),
257 ("--upstream-gemini", gemini),
258 ] {
259 args.push(flag.into());
260 args.push(upstream.to_string().into());
261 }
262 args
263}
264
265pub fn start(options: StartOptions) -> Result<u32, ProxyError> {
266 cleanup_stale(&options.paths)?;
267 if let Some(status) = status(&options.paths)? {
268 if status.running {
269 return Err(ProxyError::DaemonAlreadyRunning {
270 pid: status.pid,
271 pidfile: options.paths.pidfile,
272 });
273 }
274 }
275 write_config(&options.paths, &options.config)?;
276 create_parent(&options.paths.pidfile)?;
277 create_parent(&options.paths.log_file)?;
278 let lock = lock_pidfile(&options.paths)?;
279
280 let stdout = OpenOptions::new()
281 .create(true)
282 .append(true)
283 .open(&options.paths.log_file)
284 .map_err(|source| ProxyError::DaemonIo {
285 path: options.paths.log_file.clone(),
286 source,
287 })?;
288 let stderr = OpenOptions::new()
289 .create(true)
290 .append(true)
291 .open(&options.paths.stderr_file)
292 .map_err(|source| ProxyError::DaemonIo {
293 path: options.paths.stderr_file.clone(),
294 source,
295 })?;
296 let mut command =
297 Command::new(
298 std::env::current_exe().map_err(|source| ProxyError::DaemonIo {
299 path: PathBuf::from("current_exe"),
300 source,
301 })?,
302 );
303 command
304 .args(["proxy", "serve", "--_foreground-daemon"])
305 .args(serve_args(&options.config))
306 .args(options.extra_args)
307 .stdin(Stdio::null())
308 .stdout(Stdio::from(stdout))
309 .stderr(Stdio::from(stderr));
310 drop(lock);
311 let mut child = command.spawn().map_err(|source| ProxyError::DaemonIo {
312 path: PathBuf::from("gaze proxy serve"),
313 source,
314 })?;
315 confirm_started(&mut child, &options.paths)
316}
317
318fn confirm_started(child: &mut Child, paths: &DaemonPaths) -> Result<u32, ProxyError> {
330 let deadline = Instant::now() + Duration::from_millis(250);
331 loop {
332 match child.try_wait() {
333 Ok(Some(status)) => {
334 let _ = fs::remove_file(&paths.pidfile);
338 return Err(ProxyError::DaemonExitedEarly {
339 code: status.code(),
340 stderr_file: paths.stderr_file.clone(),
341 });
342 }
343 Ok(None) if Instant::now() >= deadline => return Ok(child.id()),
344 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
345 Err(source) => {
346 return Err(ProxyError::DaemonIo {
347 path: PathBuf::from("gaze proxy serve"),
348 source,
349 })
350 }
351 }
352 }
353}
354
355pub fn init_foreground_daemon(paths: &DaemonPaths, bind: SocketAddr) -> Result<(), ProxyError> {
356 create_parent(&paths.pidfile)?;
357 create_parent(&paths.log_file)?;
358 let _lock = lock_pidfile(paths)?;
359 write_pidfile(&paths.pidfile, std::process::id(), bind, Utc::now())
360}
361
362pub fn stop(options: StopOptions) -> Result<(), ProxyError> {
363 cleanup_stale(&options.paths)?;
364 let Some(status) = status(&options.paths)? else {
365 return Err(ProxyError::DaemonNotRunning);
366 };
367 terminate(status.pid, false)?;
368 let started = Instant::now();
369 while started.elapsed() < options.timeout {
370 if !process_exists(status.pid) {
371 let _ = fs::remove_file(&options.paths.pidfile);
372 return Ok(());
373 }
374 std::thread::sleep(Duration::from_millis(100));
375 }
376 if options.force {
377 terminate(status.pid, true)?;
378 let _ = fs::remove_file(&options.paths.pidfile);
379 Ok(())
380 } else {
381 Err(ProxyError::DaemonAlreadyRunning {
382 pid: status.pid,
383 pidfile: options.paths.pidfile,
384 })
385 }
386}
387
388pub fn restart(options: StartOptions, timeout: Duration, force: bool) -> Result<u32, ProxyError> {
389 let stop_result = stop(StopOptions {
390 paths: options.paths.clone(),
391 timeout,
392 force,
393 });
394 if !matches!(stop_result, Ok(()) | Err(ProxyError::DaemonNotRunning)) {
395 cleanup_stale(&options.paths)?;
396 }
397 start(options)
398}
399
400pub fn status(paths: &DaemonPaths) -> Result<Option<DaemonStatus>, ProxyError> {
401 if !paths.pidfile.exists() {
402 return Ok(None);
403 }
404 let record = read_pidfile(&paths.pidfile)?;
405 Ok(Some(DaemonStatus {
406 running: process_exists(record.pid),
407 pid: record.pid,
408 bind: record.bind,
409 started_at: record.started_at,
410 }))
411}
412
413pub fn cleanup_stale(paths: &DaemonPaths) -> Result<(), ProxyError> {
414 if let Some(status) = status(paths)? {
415 if !status.running {
416 fs::remove_file(&paths.pidfile).map_err(|source| ProxyError::DaemonIo {
417 path: paths.pidfile.clone(),
418 source,
419 })?;
420 }
421 }
422 Ok(())
423}
424
425pub fn logs(paths: &DaemonPaths, follow: bool) -> Result<(), ProxyError> {
426 if follow {
427 Command::new("tail")
428 .arg("-F")
429 .arg(&paths.log_file)
430 .status()
431 .map_err(|source| ProxyError::DaemonIo {
432 path: paths.log_file.clone(),
433 source,
434 })?;
435 } else if paths.log_file.exists() {
436 let text = fs::read_to_string(&paths.log_file).map_err(|source| ProxyError::DaemonIo {
437 path: paths.log_file.clone(),
438 source,
439 })?;
440 print!("{text}");
441 }
442 Ok(())
443}
444
445fn create_parent(path: &Path) -> Result<(), ProxyError> {
446 if let Some(parent) = path.parent() {
447 fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
448 path: parent.to_path_buf(),
449 source,
450 })?;
451 }
452 Ok(())
453}
454
455fn lock_pidfile(paths: &DaemonPaths) -> Result<File, ProxyError> {
456 let file = OpenOptions::new()
457 .create(true)
458 .truncate(false)
459 .read(true)
460 .write(true)
461 .open(&paths.pidfile)
462 .map_err(|source| ProxyError::DaemonIo {
463 path: paths.pidfile.clone(),
464 source,
465 })?;
466 file.try_lock_exclusive()
467 .map_err(|source| ProxyError::DaemonIo {
468 path: paths.pidfile.clone(),
469 source,
470 })?;
471 Ok(file)
472}
473
474fn write_pidfile(
475 path: &Path,
476 pid: u32,
477 bind: SocketAddr,
478 started_at: DateTime<Utc>,
479) -> Result<(), ProxyError> {
480 let contents = format!(
481 "{pid}\nbind={bind}\nstarted_at={}\n",
482 started_at.to_rfc3339()
483 );
484 if contents.len() > 200 {
485 return Err(ProxyError::DaemonConfig {
486 detail: "pidfile content exceeds 200 bytes".to_string(),
487 });
488 }
489 fs::write(path, contents).map_err(|source| ProxyError::DaemonIo {
490 path: path.to_path_buf(),
491 source,
492 })
493}
494
495struct PidRecord {
496 pid: u32,
497 bind: Option<String>,
498 started_at: Option<String>,
499}
500
501fn read_pidfile(path: &Path) -> Result<PidRecord, ProxyError> {
502 let mut text = String::new();
503 File::open(path)
504 .and_then(|mut file| file.read_to_string(&mut text))
505 .map_err(|source| ProxyError::DaemonIo {
506 path: path.to_path_buf(),
507 source,
508 })?;
509 let mut lines = text.lines();
510 let pid = lines
511 .next()
512 .ok_or_else(|| ProxyError::DaemonPidfileStale {
513 pidfile: path.to_path_buf(),
514 })?
515 .parse::<u32>()
516 .map_err(|_| ProxyError::DaemonPidfileStale {
517 pidfile: path.to_path_buf(),
518 })?;
519 let mut bind = None;
520 let mut started_at = None;
521 for line in lines {
522 if let Some(rest) = line.strip_prefix("bind=") {
523 bind = Some(rest.to_string());
524 } else if let Some(rest) = line.strip_prefix("started_at=") {
525 started_at = Some(rest.to_string());
526 }
527 }
528 Ok(PidRecord {
529 pid,
530 bind,
531 started_at,
532 })
533}
534
535#[cfg(unix)]
536fn process_exists(pid: u32) -> bool {
537 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
538}
539
540#[cfg(not(unix))]
541fn process_exists(_pid: u32) -> bool {
542 false
543}
544
545#[cfg(unix)]
546fn terminate(pid: u32, force: bool) -> Result<(), ProxyError> {
547 let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
548 let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
549 if rc == 0 {
550 Ok(())
551 } else {
552 Err(ProxyError::DaemonNotRunning)
553 }
554}
555
556#[cfg(not(unix))]
557fn terminate(_pid: u32, _force: bool) -> Result<(), ProxyError> {
558 Err(ProxyError::DaemonConfig {
559 detail: "daemon stop is not implemented on this platform".to_string(),
560 })
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 fn temp_paths(dir: &tempfile::TempDir) -> DaemonPaths {
568 DaemonPaths {
569 pidfile: dir.path().join("proxy.pid"),
570 config: dir.path().join("proxy.toml"),
571 log_file: dir.path().join("proxy.log"),
572 stderr_file: dir.path().join("proxy-stderr.log"),
573 }
574 }
575
576 #[test]
577 fn pidfile_round_trip_includes_bind_and_started_at() {
578 let dir = tempfile::tempdir().unwrap();
579 let paths = temp_paths(&dir);
580 write_pidfile(
581 &paths.pidfile,
582 123,
583 "127.0.0.1:8787".parse().unwrap(),
584 DateTime::parse_from_rfc3339("2026-05-14T00:00:00Z")
585 .unwrap()
586 .with_timezone(&Utc),
587 )
588 .unwrap();
589 let status = status(&paths).unwrap().unwrap();
590 assert_eq!(status.pid, 123);
591 assert_eq!(status.bind.as_deref(), Some("127.0.0.1:8787"));
592 assert!(!status.running);
593 }
594
595 #[test]
596 fn cleanup_stale_unlinks_dead_pidfile() {
597 let dir = tempfile::tempdir().unwrap();
598 let paths = temp_paths(&dir);
599 write_pidfile(
600 &paths.pidfile,
601 999_999,
602 "127.0.0.1:8787".parse().unwrap(),
603 Utc::now(),
604 )
605 .unwrap();
606 cleanup_stale(&paths).unwrap();
607 assert!(!paths.pidfile.exists());
608 }
609
610 #[test]
613 fn serve_args_forward_every_configured_field_to_the_daemon_child() {
614 let config = DaemonConfig {
615 policy: Some(PathBuf::from("/etc/gaze/prod.toml")),
616 rulepack: Some("core-extended".to_string()),
617 adapters: AdapterConfig::new(
618 Url::parse("http://127.0.0.1:4001").unwrap(),
619 Url::parse("http://127.0.0.1:4002").unwrap(),
620 Url::parse("http://127.0.0.1:4003").unwrap(),
621 ),
622 ..Default::default()
623 };
624
625 assert_eq!(
626 serve_args(&config),
627 [
628 "--bind",
629 "127.0.0.1:8787",
630 "--session-ttl",
631 "30m",
632 "--policy",
633 "/etc/gaze/prod.toml",
634 "--rulepack",
635 "core-extended",
636 "--upstream-openai",
637 "http://127.0.0.1:4001/",
638 "--upstream-anthropic",
639 "http://127.0.0.1:4002/",
640 "--upstream-gemini",
641 "http://127.0.0.1:4003/",
642 ]
643 .map(OsString::from)
644 );
645 }
646
647 #[test]
648 fn serve_args_omit_policy_when_the_daemon_has_none() {
649 let args = serve_args(&DaemonConfig::default());
650
651 assert!(!args.contains(&OsString::from("--policy")));
652 assert!(args
653 .windows(2)
654 .any(|pair| pair == ["--rulepack", "core"].map(OsString::from)));
655 }
656
657 #[test]
658 fn config_round_trip() {
659 let dir = tempfile::tempdir().unwrap();
660 let paths = temp_paths(&dir);
661 let config = DaemonConfig::default();
662 write_config(&paths, &config).unwrap();
663 let loaded = read_or_default_config(&paths).unwrap();
664 assert_eq!(loaded.bind, config.bind);
665 assert_eq!(
666 loaded.adapters.openai.upstream,
667 config.adapters.openai.upstream
668 );
669 }
670}