use crate::docker::volume::{
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_SSH, MOUNT_STATE,
};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub version: u32,
#[serde(default = "default_opencode_web_port")]
pub opencode_web_port: u16,
#[serde(default = "default_bind")]
pub bind: String,
#[serde(default = "default_auto_restart")]
pub auto_restart: bool,
#[serde(default = "default_boot_mode")]
pub boot_mode: String,
#[serde(default = "default_restart_retries")]
pub restart_retries: u32,
#[serde(default = "default_restart_delay")]
pub restart_delay: u32,
#[serde(default)]
pub auth_username: Option<String>,
#[serde(default)]
pub auth_password: Option<String>,
#[serde(default)]
pub container_env: Vec<String>,
#[serde(default = "default_bind_address")]
pub bind_address: String,
#[serde(default)]
pub trust_proxy: bool,
#[serde(default)]
pub allow_unauthenticated_network: bool,
#[serde(default = "default_rate_limit_attempts")]
pub rate_limit_attempts: u32,
#[serde(default = "default_rate_limit_window")]
pub rate_limit_window_seconds: u32,
#[serde(default)]
pub users: Vec<String>,
#[serde(default = "default_cockpit_port")]
pub cockpit_port: u16,
#[serde(default = "default_cockpit_enabled")]
pub cockpit_enabled: bool,
#[serde(default = "default_image_source")]
pub image_source: String,
#[serde(default = "default_update_check")]
pub update_check: String,
#[serde(default = "default_mounts")]
pub mounts: Vec<String>,
}
fn default_opencode_web_port() -> u16 {
3000
}
fn default_bind() -> String {
"localhost".to_string()
}
fn default_auto_restart() -> bool {
true
}
fn default_boot_mode() -> String {
"user".to_string()
}
fn default_restart_retries() -> u32 {
3
}
fn default_restart_delay() -> u32 {
5
}
fn default_bind_address() -> String {
"127.0.0.1".to_string()
}
fn default_rate_limit_attempts() -> u32 {
5
}
fn default_rate_limit_window() -> u32 {
60
}
fn default_cockpit_port() -> u16 {
9090
}
fn default_cockpit_enabled() -> bool {
false
}
fn default_image_source() -> String {
"prebuilt".to_string()
}
fn default_update_check() -> String {
"always".to_string()
}
pub fn default_mounts() -> Vec<String> {
let Some(base_dirs) = BaseDirs::new() else {
return Vec::new();
};
let home_dir = base_dirs.home_dir();
let data_dir = home_dir.join(".local").join("share").join("opencode");
let state_dir = home_dir.join(".local").join("state").join("opencode");
let cache_dir = home_dir.join(".cache").join("opencode");
let config_dir = home_dir
.join(".config")
.join("opencode-cloud")
.join("opencode");
let workspace_dir = home_dir.join("opencode");
let occ_data_dir = home_dir.join(".local").join("share").join("opencode-cloud");
let ssh_dir = occ_data_dir.join("ssh");
vec![
format!("{}:{MOUNT_SESSION}", data_dir.display()),
format!("{}:{MOUNT_STATE}", state_dir.display()),
format!("{}:{MOUNT_CACHE}", cache_dir.display()),
format!("{}:{MOUNT_PROJECTS}", workspace_dir.display()),
format!("{}:{MOUNT_CONFIG}", config_dir.display()),
format!("{}:{MOUNT_SSH}", ssh_dir.display()),
]
}
pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
let trimmed = addr.trim();
if trimmed.eq_ignore_ascii_case("localhost") {
return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
}
let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
};
stripped.parse::<IpAddr>().map_err(|_| {
format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
})
}
impl Default for Config {
fn default() -> Self {
Self {
version: 1,
opencode_web_port: default_opencode_web_port(),
bind: default_bind(),
auto_restart: default_auto_restart(),
boot_mode: default_boot_mode(),
restart_retries: default_restart_retries(),
restart_delay: default_restart_delay(),
auth_username: None,
auth_password: None,
container_env: Vec::new(),
bind_address: default_bind_address(),
trust_proxy: false,
allow_unauthenticated_network: false,
rate_limit_attempts: default_rate_limit_attempts(),
rate_limit_window_seconds: default_rate_limit_window(),
users: Vec::new(),
cockpit_port: default_cockpit_port(),
cockpit_enabled: default_cockpit_enabled(),
image_source: default_image_source(),
update_check: default_update_check(),
mounts: default_mounts(),
}
}
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn has_required_auth(&self) -> bool {
if !self.users.is_empty() {
return true;
}
match (&self.auth_username, &self.auth_password) {
(Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
_ => false,
}
}
pub fn is_network_exposed(&self) -> bool {
match validate_bind_address(&self.bind_address) {
Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
Err(_) => false, }
}
pub fn is_localhost(&self) -> bool {
match validate_bind_address(&self.bind_address) {
Ok(ip) => ip.is_loopback(),
Err(_) => {
self.bind_address.eq_ignore_ascii_case("localhost")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.version, 1);
assert_eq!(config.opencode_web_port, 3000);
assert_eq!(config.bind, "localhost");
assert!(config.auto_restart);
assert_eq!(config.boot_mode, "user");
assert_eq!(config.restart_retries, 3);
assert_eq!(config.restart_delay, 5);
assert!(config.auth_username.is_none());
assert!(config.auth_password.is_none());
assert!(config.container_env.is_empty());
assert_eq!(config.bind_address, "127.0.0.1");
assert!(!config.trust_proxy);
assert!(!config.allow_unauthenticated_network);
assert_eq!(config.rate_limit_attempts, 5);
assert_eq!(config.rate_limit_window_seconds, 60);
assert!(config.users.is_empty());
assert_eq!(config.mounts, default_mounts());
}
#[test]
fn test_serialize_deserialize_roundtrip() {
let config = Config::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_deserialize_with_missing_optional_fields() {
let json = r#"{"version": 1}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.opencode_web_port, 3000);
assert_eq!(config.bind, "localhost");
assert!(config.auto_restart);
assert_eq!(config.boot_mode, "user");
assert_eq!(config.restart_retries, 3);
assert_eq!(config.restart_delay, 5);
assert!(config.auth_username.is_none());
assert!(config.auth_password.is_none());
assert!(config.container_env.is_empty());
assert_eq!(config.bind_address, "127.0.0.1");
assert!(!config.trust_proxy);
assert!(!config.allow_unauthenticated_network);
assert_eq!(config.rate_limit_attempts, 5);
assert_eq!(config.rate_limit_window_seconds, 60);
assert!(config.users.is_empty());
}
#[test]
fn test_serialize_deserialize_roundtrip_with_service_fields() {
let config = Config {
version: 1,
opencode_web_port: 9000,
bind: "0.0.0.0".to_string(),
auto_restart: false,
boot_mode: "system".to_string(),
restart_retries: 5,
restart_delay: 10,
auth_username: None,
auth_password: None,
container_env: Vec::new(),
bind_address: "0.0.0.0".to_string(),
trust_proxy: true,
allow_unauthenticated_network: false,
rate_limit_attempts: 10,
rate_limit_window_seconds: 120,
users: vec!["admin".to_string()],
cockpit_port: 9090,
cockpit_enabled: true,
image_source: default_image_source(),
update_check: default_update_check(),
mounts: Vec::new(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
assert_eq!(parsed.boot_mode, "system");
assert_eq!(parsed.restart_retries, 5);
assert_eq!(parsed.restart_delay, 10);
assert_eq!(parsed.bind_address, "0.0.0.0");
assert!(parsed.trust_proxy);
assert_eq!(parsed.rate_limit_attempts, 10);
assert_eq!(parsed.users, vec!["admin"]);
}
#[test]
fn test_reject_unknown_fields() {
let json = r#"{"version": 1, "unknown_field": "value"}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_serialize_deserialize_roundtrip_with_auth_fields() {
let config = Config {
auth_username: Some("admin".to_string()),
auth_password: Some("secret123".to_string()),
container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
..Config::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
assert_eq!(parsed.auth_username, Some("admin".to_string()));
assert_eq!(parsed.auth_password, Some("secret123".to_string()));
assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
}
#[test]
fn test_has_required_auth_returns_false_when_both_none() {
let config = Config::default();
assert!(!config.has_required_auth());
}
#[test]
fn test_has_required_auth_returns_false_when_username_none() {
let config = Config {
auth_username: None,
auth_password: Some("secret".to_string()),
..Config::default()
};
assert!(!config.has_required_auth());
}
#[test]
fn test_has_required_auth_returns_false_when_password_none() {
let config = Config {
auth_username: Some("admin".to_string()),
auth_password: None,
..Config::default()
};
assert!(!config.has_required_auth());
}
#[test]
fn test_has_required_auth_returns_false_when_username_empty() {
let config = Config {
auth_username: Some(String::new()),
auth_password: Some("secret".to_string()),
..Config::default()
};
assert!(!config.has_required_auth());
}
#[test]
fn test_has_required_auth_returns_false_when_password_empty() {
let config = Config {
auth_username: Some("admin".to_string()),
auth_password: Some(String::new()),
..Config::default()
};
assert!(!config.has_required_auth());
}
#[test]
fn test_has_required_auth_returns_true_when_both_set() {
let config = Config {
auth_username: Some("admin".to_string()),
auth_password: Some("secret123".to_string()),
..Config::default()
};
assert!(config.has_required_auth());
}
#[test]
fn test_validate_bind_address_ipv4_localhost() {
let result = validate_bind_address("127.0.0.1");
assert!(result.is_ok());
let ip = result.unwrap();
assert!(ip.is_loopback());
}
#[test]
fn test_validate_bind_address_ipv4_all_interfaces() {
let result = validate_bind_address("0.0.0.0");
assert!(result.is_ok());
let ip = result.unwrap();
assert!(ip.is_unspecified());
}
#[test]
fn test_validate_bind_address_ipv6_localhost() {
let result = validate_bind_address("::1");
assert!(result.is_ok());
let ip = result.unwrap();
assert!(ip.is_loopback());
}
#[test]
fn test_validate_bind_address_ipv6_all_interfaces() {
let result = validate_bind_address("::");
assert!(result.is_ok());
let ip = result.unwrap();
assert!(ip.is_unspecified());
}
#[test]
fn test_validate_bind_address_localhost_string() {
let result = validate_bind_address("localhost");
assert!(result.is_ok());
assert_eq!(result.unwrap().to_string(), "127.0.0.1");
}
#[test]
fn test_validate_bind_address_localhost_case_insensitive() {
let result = validate_bind_address("LOCALHOST");
assert!(result.is_ok());
assert_eq!(result.unwrap().to_string(), "127.0.0.1");
}
#[test]
fn test_validate_bind_address_bracketed_ipv6() {
let result = validate_bind_address("[::1]");
assert!(result.is_ok());
assert!(result.unwrap().is_loopback());
}
#[test]
fn test_validate_bind_address_invalid() {
let result = validate_bind_address("not-an-ip");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid IP address"));
}
#[test]
fn test_validate_bind_address_whitespace() {
let result = validate_bind_address(" 127.0.0.1 ");
assert!(result.is_ok());
}
#[test]
fn test_is_network_exposed_ipv4_all() {
let config = Config {
bind_address: "0.0.0.0".to_string(),
..Config::default()
};
assert!(config.is_network_exposed());
}
#[test]
fn test_is_network_exposed_ipv6_all() {
let config = Config {
bind_address: "::".to_string(),
..Config::default()
};
assert!(config.is_network_exposed());
}
#[test]
fn test_is_network_exposed_localhost_false() {
let config = Config::default();
assert!(!config.is_network_exposed());
}
#[test]
fn test_is_network_exposed_ipv6_localhost_false() {
let config = Config {
bind_address: "::1".to_string(),
..Config::default()
};
assert!(!config.is_network_exposed());
}
#[test]
fn test_is_localhost_ipv4() {
let config = Config {
bind_address: "127.0.0.1".to_string(),
..Config::default()
};
assert!(config.is_localhost());
}
#[test]
fn test_is_localhost_ipv6() {
let config = Config {
bind_address: "::1".to_string(),
..Config::default()
};
assert!(config.is_localhost());
}
#[test]
fn test_is_localhost_string() {
let config = Config {
bind_address: "localhost".to_string(),
..Config::default()
};
assert!(config.is_localhost());
}
#[test]
fn test_is_localhost_all_interfaces_false() {
let config = Config {
bind_address: "0.0.0.0".to_string(),
..Config::default()
};
assert!(!config.is_localhost());
}
#[test]
fn test_serialize_deserialize_with_security_fields() {
let config = Config {
bind_address: "0.0.0.0".to_string(),
trust_proxy: true,
allow_unauthenticated_network: true,
rate_limit_attempts: 10,
rate_limit_window_seconds: 120,
users: vec!["admin".to_string(), "developer".to_string()],
..Config::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
assert_eq!(parsed.bind_address, "0.0.0.0");
assert!(parsed.trust_proxy);
assert!(parsed.allow_unauthenticated_network);
assert_eq!(parsed.rate_limit_attempts, 10);
assert_eq!(parsed.rate_limit_window_seconds, 120);
assert_eq!(parsed.users, vec!["admin", "developer"]);
}
#[test]
fn test_default_config_cockpit_fields() {
let config = Config::default();
assert_eq!(config.cockpit_port, 9090);
assert!(!config.cockpit_enabled);
}
#[test]
fn test_serialize_deserialize_with_cockpit_fields() {
let config = Config {
cockpit_port: 9091,
cockpit_enabled: false,
..Config::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.cockpit_port, 9091);
assert!(!parsed.cockpit_enabled);
}
#[test]
fn test_cockpit_fields_default_on_missing() {
let json = r#"{"version": 1}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.cockpit_port, 9090);
assert!(!config.cockpit_enabled);
}
#[test]
fn test_default_config_image_fields() {
let config = Config::default();
assert_eq!(config.image_source, "prebuilt");
assert_eq!(config.update_check, "always");
}
#[test]
fn test_serialize_deserialize_with_image_fields() {
let config = Config {
image_source: "build".to_string(),
update_check: "never".to_string(),
..Config::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.image_source, "build");
assert_eq!(parsed.update_check, "never");
}
#[test]
fn test_image_fields_default_on_missing() {
let json = r#"{"version": 1}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.image_source, "prebuilt");
assert_eq!(config.update_check, "always");
}
#[test]
fn test_default_config_mounts_field() {
let config = Config::default();
assert_eq!(config.mounts, default_mounts());
}
#[test]
fn test_serialize_deserialize_with_mounts() {
let config = Config {
mounts: vec![
"/home/user/data:/home/opencoder/workspace/data".to_string(),
"/home/user/config:/etc/app:ro".to_string(),
],
..Config::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.mounts.len(), 2);
assert_eq!(
parsed.mounts[0],
"/home/user/data:/home/opencoder/workspace/data"
);
assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
}
#[test]
fn test_mounts_field_default_on_missing() {
let json = r#"{"version": 1}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.mounts, default_mounts());
}
}