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 drop(lock);
260 let child = command.spawn().map_err(|source| ProxyError::DaemonIo {
261 path: PathBuf::from("gaze proxy serve"),
262 source,
263 })?;
264 std::thread::sleep(Duration::from_millis(250));
265 if process_exists(child.id()) {
266 Ok(child.id())
267 } else {
268 Err(ProxyError::DaemonNotRunning)
269 }
270}
271
272pub fn init_foreground_daemon(paths: &DaemonPaths, bind: SocketAddr) -> Result<(), ProxyError> {
273 create_parent(&paths.pidfile)?;
274 create_parent(&paths.log_file)?;
275 let _lock = lock_pidfile(paths)?;
276 write_pidfile(&paths.pidfile, std::process::id(), bind, Utc::now())
277}
278
279pub fn stop(options: StopOptions) -> Result<(), ProxyError> {
280 cleanup_stale(&options.paths)?;
281 let Some(status) = status(&options.paths)? else {
282 return Err(ProxyError::DaemonNotRunning);
283 };
284 terminate(status.pid, false)?;
285 let started = Instant::now();
286 while started.elapsed() < options.timeout {
287 if !process_exists(status.pid) {
288 let _ = fs::remove_file(&options.paths.pidfile);
289 return Ok(());
290 }
291 std::thread::sleep(Duration::from_millis(100));
292 }
293 if options.force {
294 terminate(status.pid, true)?;
295 let _ = fs::remove_file(&options.paths.pidfile);
296 Ok(())
297 } else {
298 Err(ProxyError::DaemonAlreadyRunning {
299 pid: status.pid,
300 pidfile: options.paths.pidfile,
301 })
302 }
303}
304
305pub fn restart(options: StartOptions, timeout: Duration, force: bool) -> Result<u32, ProxyError> {
306 let stop_result = stop(StopOptions {
307 paths: options.paths.clone(),
308 timeout,
309 force,
310 });
311 if !matches!(stop_result, Ok(()) | Err(ProxyError::DaemonNotRunning)) {
312 cleanup_stale(&options.paths)?;
313 }
314 start(options)
315}
316
317pub fn status(paths: &DaemonPaths) -> Result<Option<DaemonStatus>, ProxyError> {
318 if !paths.pidfile.exists() {
319 return Ok(None);
320 }
321 let record = read_pidfile(&paths.pidfile)?;
322 Ok(Some(DaemonStatus {
323 running: process_exists(record.pid),
324 pid: record.pid,
325 bind: record.bind,
326 started_at: record.started_at,
327 }))
328}
329
330pub fn cleanup_stale(paths: &DaemonPaths) -> Result<(), ProxyError> {
331 if let Some(status) = status(paths)? {
332 if !status.running {
333 fs::remove_file(&paths.pidfile).map_err(|source| ProxyError::DaemonIo {
334 path: paths.pidfile.clone(),
335 source,
336 })?;
337 }
338 }
339 Ok(())
340}
341
342pub fn logs(paths: &DaemonPaths, follow: bool) -> Result<(), ProxyError> {
343 if follow {
344 Command::new("tail")
345 .arg("-F")
346 .arg(&paths.log_file)
347 .status()
348 .map_err(|source| ProxyError::DaemonIo {
349 path: paths.log_file.clone(),
350 source,
351 })?;
352 } else if paths.log_file.exists() {
353 let text = fs::read_to_string(&paths.log_file).map_err(|source| ProxyError::DaemonIo {
354 path: paths.log_file.clone(),
355 source,
356 })?;
357 print!("{text}");
358 }
359 Ok(())
360}
361
362fn create_parent(path: &Path) -> Result<(), ProxyError> {
363 if let Some(parent) = path.parent() {
364 fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
365 path: parent.to_path_buf(),
366 source,
367 })?;
368 }
369 Ok(())
370}
371
372fn lock_pidfile(paths: &DaemonPaths) -> Result<File, ProxyError> {
373 let file = OpenOptions::new()
374 .create(true)
375 .truncate(false)
376 .read(true)
377 .write(true)
378 .open(&paths.pidfile)
379 .map_err(|source| ProxyError::DaemonIo {
380 path: paths.pidfile.clone(),
381 source,
382 })?;
383 file.try_lock_exclusive()
384 .map_err(|source| ProxyError::DaemonIo {
385 path: paths.pidfile.clone(),
386 source,
387 })?;
388 Ok(file)
389}
390
391fn write_pidfile(
392 path: &Path,
393 pid: u32,
394 bind: SocketAddr,
395 started_at: DateTime<Utc>,
396) -> Result<(), ProxyError> {
397 let contents = format!(
398 "{pid}\nbind={bind}\nstarted_at={}\n",
399 started_at.to_rfc3339()
400 );
401 if contents.len() > 200 {
402 return Err(ProxyError::DaemonConfig {
403 detail: "pidfile content exceeds 200 bytes".to_string(),
404 });
405 }
406 fs::write(path, contents).map_err(|source| ProxyError::DaemonIo {
407 path: path.to_path_buf(),
408 source,
409 })
410}
411
412struct PidRecord {
413 pid: u32,
414 bind: Option<String>,
415 started_at: Option<String>,
416}
417
418fn read_pidfile(path: &Path) -> Result<PidRecord, ProxyError> {
419 let mut text = String::new();
420 File::open(path)
421 .and_then(|mut file| file.read_to_string(&mut text))
422 .map_err(|source| ProxyError::DaemonIo {
423 path: path.to_path_buf(),
424 source,
425 })?;
426 let mut lines = text.lines();
427 let pid = lines
428 .next()
429 .ok_or_else(|| ProxyError::DaemonPidfileStale {
430 pidfile: path.to_path_buf(),
431 })?
432 .parse::<u32>()
433 .map_err(|_| ProxyError::DaemonPidfileStale {
434 pidfile: path.to_path_buf(),
435 })?;
436 let mut bind = None;
437 let mut started_at = None;
438 for line in lines {
439 if let Some(rest) = line.strip_prefix("bind=") {
440 bind = Some(rest.to_string());
441 } else if let Some(rest) = line.strip_prefix("started_at=") {
442 started_at = Some(rest.to_string());
443 }
444 }
445 Ok(PidRecord {
446 pid,
447 bind,
448 started_at,
449 })
450}
451
452#[cfg(unix)]
453fn process_exists(pid: u32) -> bool {
454 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
455}
456
457#[cfg(not(unix))]
458fn process_exists(_pid: u32) -> bool {
459 false
460}
461
462#[cfg(unix)]
463fn terminate(pid: u32, force: bool) -> Result<(), ProxyError> {
464 let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
465 let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
466 if rc == 0 {
467 Ok(())
468 } else {
469 Err(ProxyError::DaemonNotRunning)
470 }
471}
472
473#[cfg(not(unix))]
474fn terminate(_pid: u32, _force: bool) -> Result<(), ProxyError> {
475 Err(ProxyError::DaemonConfig {
476 detail: "daemon stop is not implemented on this platform".to_string(),
477 })
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn temp_paths(dir: &tempfile::TempDir) -> DaemonPaths {
485 DaemonPaths {
486 pidfile: dir.path().join("proxy.pid"),
487 config: dir.path().join("proxy.toml"),
488 log_file: dir.path().join("proxy.log"),
489 stderr_file: dir.path().join("proxy-stderr.log"),
490 }
491 }
492
493 #[test]
494 fn pidfile_round_trip_includes_bind_and_started_at() {
495 let dir = tempfile::tempdir().unwrap();
496 let paths = temp_paths(&dir);
497 write_pidfile(
498 &paths.pidfile,
499 123,
500 "127.0.0.1:8787".parse().unwrap(),
501 DateTime::parse_from_rfc3339("2026-05-14T00:00:00Z")
502 .unwrap()
503 .with_timezone(&Utc),
504 )
505 .unwrap();
506 let status = status(&paths).unwrap().unwrap();
507 assert_eq!(status.pid, 123);
508 assert_eq!(status.bind.as_deref(), Some("127.0.0.1:8787"));
509 assert!(!status.running);
510 }
511
512 #[test]
513 fn cleanup_stale_unlinks_dead_pidfile() {
514 let dir = tempfile::tempdir().unwrap();
515 let paths = temp_paths(&dir);
516 write_pidfile(
517 &paths.pidfile,
518 999_999,
519 "127.0.0.1:8787".parse().unwrap(),
520 Utc::now(),
521 )
522 .unwrap();
523 cleanup_stale(&paths).unwrap();
524 assert!(!paths.pidfile.exists());
525 }
526
527 #[test]
528 fn config_round_trip() {
529 let dir = tempfile::tempdir().unwrap();
530 let paths = temp_paths(&dir);
531 let config = DaemonConfig::default();
532 write_config(&paths, &config).unwrap();
533 let loaded = read_or_default_config(&paths).unwrap();
534 assert_eq!(loaded.bind, config.bind);
535 assert_eq!(
536 loaded.adapters.openai.upstream,
537 config.adapters.openai.upstream
538 );
539 }
540}