Skip to content

Commit cdda91e

Browse files
authored
Merge pull request #85 from vsilent/feature/config-generator
feat: add 'status init' command for first-run config generation
2 parents 4b53c85 + 51b93c6 commit cdda91e

3 files changed

Lines changed: 365 additions & 0 deletions

File tree

src/agent/init.rs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
use std::path::Path;
2+
3+
use anyhow::{Context, Result};
4+
use tracing::info;
5+
6+
/// Default config.json content with sensible defaults.
7+
const DEFAULT_CONFIG: &str = r#"{
8+
"ssl": "letsencrypt",
9+
"domain": "example.com",
10+
"reqdata": {
11+
"email": "admin@example.com"
12+
},
13+
"apps_info": null,
14+
"subdomains": {},
15+
"compose_agent_enabled": false,
16+
"control_plane": "status_panel"
17+
}
18+
"#;
19+
20+
/// Default .env content with documented variables.
21+
const DEFAULT_ENV: &str = r#"# Status Panel Agent – Environment Configuration
22+
# Docs: https://github.com/trydirect/status
23+
24+
# ── Agent Identity (set after running `status register`) ─────────────
25+
AGENT_ID=
26+
AGENT_TOKEN=
27+
28+
# ── Dashboard / Stacker Server ───────────────────────────────────────
29+
DASHBOARD_URL=https://stacker.try.direct
30+
31+
# ── Polling (pull-based command loop) ────────────────────────────────
32+
POLLING_TIMEOUT_SECS=30
33+
POLLING_BACKOFF_SECS=5
34+
COMMAND_TIMEOUT_SECS=300
35+
36+
# ── Metrics ──────────────────────────────────────────────────────────
37+
METRICS_INTERVAL_SECS=15
38+
# METRICS_WEBHOOK=https://example.com/metrics
39+
40+
# ── UI / API credentials (must be set explicitly before use) ─────────
41+
STATUS_PANEL_USERNAME=
42+
STATUS_PANEL_PASSWORD=
43+
44+
# ── Docker ───────────────────────────────────────────────────────────
45+
# DOCKER_SOCK=unix:///var/run/docker.sock
46+
# NGINX_CONTAINER=nginx
47+
48+
# ── Compose Agent ────────────────────────────────────────────────────
49+
# COMPOSE_AGENT_ENABLED=false
50+
51+
# ── Backup / Security ───────────────────────────────────────────────
52+
# DEPLOYMENT_HASH=
53+
# BACKUP_PATH=/data/encrypted/backup.tar.gz.cpt
54+
# TRYDIRECT_IP=
55+
56+
# ── Vault (optional) ────────────────────────────────────────────────
57+
# VAULT_ADDRESS=http://127.0.0.1:8200
58+
# VAULT_TOKEN=
59+
60+
# ── Self-update (optional) ──────────────────────────────────────────
61+
# UPDATE_SERVER_URL=
62+
# UPDATE_BINARY_URL=
63+
# UPDATE_EXPECTED_SHA256=
64+
# UPDATE_STORAGE_PATH=/var/lib/status-panel
65+
"#;
66+
67+
/// Result of the init operation, indicating which files were created.
68+
pub struct InitResult {
69+
pub config_created: bool,
70+
pub env_created: bool,
71+
pub config_path: String,
72+
pub env_path: String,
73+
}
74+
75+
/// Generate default config.json and .env files in the given directory.
76+
///
77+
/// Existing files are never overwritten unless `force` is true.
78+
/// The .env file is created with restricted permissions (0600) on Unix.
79+
pub fn generate_default_config(dir: &Path, force: bool) -> Result<InitResult> {
80+
let config_path = dir.join("config.json");
81+
let env_path = dir.join(".env");
82+
83+
let config_created =
84+
write_if_absent(&config_path, DEFAULT_CONFIG, force).context("writing config.json")?;
85+
let env_created =
86+
write_if_absent_secure(&env_path, DEFAULT_ENV, force).context("writing .env")?;
87+
88+
if config_created {
89+
info!(path = %config_path.display(), "created default config.json");
90+
}
91+
if env_created {
92+
info!(path = %env_path.display(), "created default .env");
93+
}
94+
95+
Ok(InitResult {
96+
config_created,
97+
env_created,
98+
config_path: config_path.display().to_string(),
99+
env_path: env_path.display().to_string(),
100+
})
101+
}
102+
103+
/// Write `content` to `path` atomically if the file does not exist.
104+
/// When `force` is true, truncates and overwrites.
105+
/// Returns `true` when the file was actually written.
106+
fn write_if_absent(path: &Path, content: &str, force: bool) -> Result<bool> {
107+
use std::fs::OpenOptions;
108+
use std::io::Write;
109+
110+
if force {
111+
std::fs::write(path, content)?;
112+
return Ok(true);
113+
}
114+
115+
// Atomic create: fails with AlreadyExists if file is present (no TOCTOU)
116+
match OpenOptions::new().write(true).create_new(true).open(path) {
117+
Ok(mut f) => {
118+
f.write_all(content.as_bytes())?;
119+
Ok(true)
120+
}
121+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
122+
Err(e) => Err(e.into()),
123+
}
124+
}
125+
126+
/// Like `write_if_absent` but sets 0600 permissions on Unix (for secret-bearing files).
127+
fn write_if_absent_secure(path: &Path, content: &str, force: bool) -> Result<bool> {
128+
use std::fs::OpenOptions;
129+
use std::io::Write;
130+
131+
if force {
132+
write_with_restricted_perms(path, content)?;
133+
return Ok(true);
134+
}
135+
136+
match OpenOptions::new().write(true).create_new(true).open(path) {
137+
Ok(mut f) => {
138+
f.write_all(content.as_bytes())?;
139+
drop(f);
140+
set_restricted_perms(path);
141+
Ok(true)
142+
}
143+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
144+
Err(e) => Err(e.into()),
145+
}
146+
}
147+
148+
fn write_with_restricted_perms(path: &Path, content: &str) -> Result<()> {
149+
std::fs::write(path, content)?;
150+
set_restricted_perms(path);
151+
Ok(())
152+
}
153+
154+
#[cfg(unix)]
155+
fn set_restricted_perms(path: &Path) {
156+
use std::os::unix::fs::PermissionsExt;
157+
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
158+
}
159+
160+
#[cfg(not(unix))]
161+
fn set_restricted_perms(_path: &Path) {
162+
// No-op on non-Unix platforms
163+
}
164+
165+
#[cfg(test)]
166+
mod tests {
167+
use super::*;
168+
169+
#[test]
170+
fn test_generate_creates_both_files() {
171+
let dir = tempfile::tempdir().unwrap();
172+
let result = generate_default_config(dir.path(), false).unwrap();
173+
174+
assert!(result.config_created);
175+
assert!(result.env_created);
176+
177+
let config_content = std::fs::read_to_string(dir.path().join("config.json")).unwrap();
178+
assert!(config_content.contains("\"domain\""));
179+
assert!(config_content.contains("\"reqdata\""));
180+
181+
let env_content = std::fs::read_to_string(dir.path().join(".env")).unwrap();
182+
assert!(env_content.contains("AGENT_ID="));
183+
assert!(env_content.contains("DASHBOARD_URL="));
184+
}
185+
186+
#[test]
187+
fn test_generate_skips_existing_files() {
188+
let dir = tempfile::tempdir().unwrap();
189+
std::fs::write(dir.path().join("config.json"), "existing").unwrap();
190+
191+
let result = generate_default_config(dir.path(), false).unwrap();
192+
193+
assert!(!result.config_created);
194+
assert!(result.env_created);
195+
196+
// Original file untouched
197+
let content = std::fs::read_to_string(dir.path().join("config.json")).unwrap();
198+
assert_eq!(content, "existing");
199+
}
200+
201+
#[test]
202+
fn test_generate_force_overwrites() {
203+
let dir = tempfile::tempdir().unwrap();
204+
std::fs::write(dir.path().join("config.json"), "old").unwrap();
205+
206+
let result = generate_default_config(dir.path(), true).unwrap();
207+
208+
assert!(result.config_created);
209+
let content = std::fs::read_to_string(dir.path().join("config.json")).unwrap();
210+
assert!(content.contains("\"domain\""));
211+
}
212+
213+
#[test]
214+
fn test_default_config_is_valid_json() {
215+
let parsed: serde_json::Value = serde_json::from_str(DEFAULT_CONFIG).unwrap();
216+
assert_eq!(parsed["domain"], "example.com");
217+
assert_eq!(parsed["reqdata"]["email"], "admin@example.com");
218+
}
219+
220+
#[test]
221+
fn test_default_config_deserializes_to_config() {
222+
let cfg: super::super::config::Config = serde_json::from_str(DEFAULT_CONFIG).unwrap();
223+
assert_eq!(cfg.domain.as_deref(), Some("example.com"));
224+
assert_eq!(cfg.reqdata.email, "admin@example.com");
225+
assert!(!cfg.compose_agent_enabled);
226+
}
227+
228+
#[test]
229+
fn test_env_contains_all_documented_vars() {
230+
assert!(DEFAULT_ENV.contains("AGENT_ID="));
231+
assert!(DEFAULT_ENV.contains("AGENT_TOKEN="));
232+
assert!(DEFAULT_ENV.contains("DASHBOARD_URL="));
233+
assert!(DEFAULT_ENV.contains("STATUS_PANEL_USERNAME="));
234+
assert!(DEFAULT_ENV.contains("STATUS_PANEL_PASSWORD="));
235+
assert!(DEFAULT_ENV.contains("COMPOSE_AGENT_ENABLED"));
236+
assert!(DEFAULT_ENV.contains("VAULT_ADDRESS"));
237+
assert!(DEFAULT_ENV.contains("VAULT_TOKEN"));
238+
assert!(DEFAULT_ENV.contains("DEPLOYMENT_HASH"));
239+
assert!(DEFAULT_ENV.contains("UPDATE_SERVER_URL"));
240+
assert!(DEFAULT_ENV.contains("UPDATE_STORAGE_PATH"));
241+
// Credentials must not have default values baked in
242+
assert!(DEFAULT_ENV.contains("STATUS_PANEL_USERNAME=\n"));
243+
assert!(DEFAULT_ENV.contains("STATUS_PANEL_PASSWORD=\n"));
244+
}
245+
246+
#[cfg(unix)]
247+
#[test]
248+
fn test_env_file_has_restricted_permissions() {
249+
use std::os::unix::fs::PermissionsExt;
250+
let dir = tempfile::tempdir().unwrap();
251+
generate_default_config(dir.path(), false).unwrap();
252+
253+
let meta = std::fs::metadata(dir.path().join(".env")).unwrap();
254+
let mode = meta.permissions().mode() & 0o777;
255+
assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
256+
}
257+
}

src/agent/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ pub mod backup;
22
pub mod config;
33
pub mod daemon;
44
pub mod docker;
5+
pub mod init;
56
pub mod registration;
67
pub mod watchdog;

0 commit comments

Comments
 (0)