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