1//! Daemon state: its identity, the control plane it trusts, and the resources it serves.2//!3//! The resource map is the security boundary that keeps the daemon from being an open4//! proxy. A capability names a *resource id*; only this map turns that into a TCP target, and5//! it is written exclusively by the local `devsite` CLI.67use std::collections::HashMap;8use std::fs::{File, OpenOptions, TryLockError};9use std::net::SocketAddr;10use std::path::{Path, PathBuf};1112use anyhow::{Context, Result};13use devsite_proto::ResourceId;14use ed25519_dalek::VerifyingKey;15use iroh::SecretKey;16use serde::{Deserialize, Serialize};17181920pub enum Visibility {21 Public,22 Private,23 Shared,24}252627pub struct HostedService {28 pub resource_id: ResourceId,29 pub name: String,30 /// Fixed local TCP target. The peer chooses a resource, never an address.31 pub target: SocketAddr,32 pub visibility: Visibility,33}343536pub struct DaemonConfig {37 /// Base URL of the control plane, e.g. `https://dev.site`.38 pub server_url: Option<String>,39 /// Revocable machine credential from `devsite login`, used for CLI calls and40 /// for registering this daemon's endpoint id when it starts.41 42 pub machine_credential: Option<String>,43 /// The control plane's capability-signing public key, pinned at login.44 ///45 /// Hex-encoded. A daemon that has not pinned a key cannot verify anything and refuses46 /// every request, which is the correct failure direction.47 pub control_plane_key: Option<String>,48 49 pub resources: Vec<HostedService>,50}5152impl DaemonConfig {53 pub fn verifying_key(&self) -> Result<VerifyingKey> {54 let hex = self55 .control_plane_key56 .as_deref()57 .context("no control plane key pinned — run `devsite login` first")?;58 let raw = data_encoding::HEXLOWER_PERMISSIVE59 .decode(hex.as_bytes())60 .context("control plane key is not valid hex")?;61 let bytes: [u8; 32] = raw62 .try_into()63 .map_err(|_| anyhow::anyhow!("control plane key is not 32 bytes"))?;64 VerifyingKey::from_bytes(&bytes).context("control plane key is not a valid ed25519 key")65 }6667 /// Resource id to local target. Built fresh on each use so a config reload takes effect.68 pub fn targets(&self) -> HashMap<ResourceId, SocketAddr> {69 self.resources70 .iter()71 .map(|r| (r.resource_id, r.target))72 .collect()73 }74}7576/// Where the daemon keeps its identity and configuration.77pub struct Paths {78 pub root: PathBuf,79}8081/// An exclusive claim that this config directory has one daemon serving it.82///83/// The operating system releases the lock when the process exits, including84/// crashes, so there is no stale PID file to clean up or trust.85pub struct DaemonLock {86 _file: File,87}8889impl Paths {90 pub fn discover() -> Result<Self> {91 // DEVSITE_HOME keeps integration tests (and multiple daemons on one machine) from92 // colliding on a single shared identity.93 if let Ok(custom) = std::env::var("DEVSITE_HOME") {94 return Ok(Self {95 root: PathBuf::from(custom),96 });97 }98 let dirs = directories::ProjectDirs::from("dev", "devsite", "devsite")99 .context("could not determine a config directory")?;100 Ok(Self {101 root: dirs.config_dir().to_path_buf(),102 })103 }104105 pub fn identity(&self) -> PathBuf {106 self.root.join("devsite-endpoint.key")107 }108109 /// Public half of [`Self::identity`]. Public Ed25519 material follows the110 /// conventional `.pub` naming used by the rest of the CLI handoff files.111 pub fn identity_public(&self) -> PathBuf {112 self.root.join("devsite-endpoint.pub")113 }114115 pub fn config(&self) -> PathBuf {116 self.root.join("config.json")117 }118119 pub fn daemon_lock(&self) -> PathBuf {120 self.root.join("daemon.lock")121 }122123 /// Try to become the daemon for this config directory.124 ///125 /// `None` means another process currently owns the lock. Keeping the126 /// returned guard alive keeps the claim; dropping it releases the claim.127 pub fn try_daemon_lock(&self) -> Result<Option<DaemonLock>> {128 std::fs::create_dir_all(&self.root)?;129 let path = self.daemon_lock();130 let file = open_lock_file(&path)?;131 match file.try_lock() {132 Ok(()) => Ok(Some(DaemonLock { _file: file })),133 Err(TryLockError::WouldBlock) => Ok(None),134 Err(TryLockError::Error(err)) => {135 Err(err).with_context(|| format!("locking {}", path.display()))136 }137 }138 }139140 /// Whether a daemon currently owns this config directory.141 pub fn daemon_running(&self) -> Result<bool> {142 if !self.daemon_lock().exists() {143 return Ok(false);144 }145 Ok(self.try_daemon_lock()?.is_none())146 }147148 pub fn load_config(&self) -> Result<DaemonConfig> {149 let path = self.config();150 if !path.exists() {151 return Ok(DaemonConfig::default());152 }153 let text = std::fs::read_to_string(&path)154 .with_context(|| format!("reading {}", path.display()))?;155 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))156 }157158 pub fn save_config(&self, config: &DaemonConfig) -> Result<()> {159 std::fs::create_dir_all(&self.root)?;160 let text = serde_json::to_string_pretty(config)?;161 write_private(&self.config(), text.as_bytes())162 }163164 /// Load the persistent endpoint key, creating it on first run.165 ///166 /// This key *is* the daemon's identity — every capability ever issued names it as the167 /// audience — so it must survive restarts and must not be world-readable.168 pub fn load_or_create_identity(&self) -> Result<SecretKey> {169 let path = self.identity();170 let key = if path.exists() {171 let raw =172 std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;173 let bytes: [u8; 32] = raw174 .as_slice()175 .try_into()176 .map_err(|_| anyhow::anyhow!("{} is not a 32 byte key", path.display()))?;177 SecretKey::from_bytes(&bytes)178 } else {179 std::fs::create_dir_all(&self.root)?;180 let key = SecretKey::generate();181 write_private(&path, &key.to_bytes())?;182 key183 };184185 let public = format!("{}\n", key.public());186 let public_path = self.identity_public();187 if std::fs::read_to_string(&public_path).ok().as_deref() != Some(public.as_str()) {188 std::fs::write(&public_path, public.as_bytes())189 .with_context(|| format!("writing {}", public_path.display()))?;190 }191 Ok(key)192 }193}194195196fn open_lock_file(path: &Path) -> Result<File> {197 use std::os::unix::fs::OpenOptionsExt;198199 OpenOptions::new()200 .read(true)201 .write(true)202 .create(true)203 .truncate(false)204 .mode(0o600)205 .open(path)206 .with_context(|| format!("opening {}", path.display()))207}208209210fn open_lock_file(path: &Path) -> Result<File> {211 OpenOptions::new()212 .read(true)213 .write(true)214 .create(true)215 .truncate(false)216 .open(path)217 .with_context(|| format!("opening {}", path.display()))218}219220/// Write a file only the owner can read.221///222/// The mode is set before any bytes land on disk, so the secret is never briefly exposed223/// with default permissions.224fn write_private(path: &Path, contents: &[u8]) -> Result<()> {225 226 {227 use std::io::Write;228 use std::os::unix::fs::OpenOptionsExt;229230 let mut file = std::fs::OpenOptions::new()231 .write(true)232 .create(true)233 .truncate(true)234 .mode(0o600)235 .open(path)236 .with_context(|| format!("opening {}", path.display()))?;237 file.write_all(contents)?;238 Ok(())239 }240 241 {242 std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;243 Ok(())244 }245}246247248mod tests {249 use super::*;250 use std::sync::atomic::{AtomicUsize, Ordering};251252 static NEXT_TEST_DIR: AtomicUsize = AtomicUsize::new(0);253254 fn test_paths() -> Paths {255 let suffix = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed);256 Paths {257 root: std::env::temp_dir().join(format!(258 "devsite-daemon-lock-{}-{suffix}",259 std::process::id()260 )),261 }262 }263264 265 fn daemon_lock_reports_only_a_live_holder() {266 let paths = test_paths();267 assert!(!paths.daemon_running().unwrap());268269 let lock = paths.try_daemon_lock().unwrap().unwrap();270 assert!(paths.daemon_running().unwrap());271 assert!(paths.try_daemon_lock().unwrap().is_none());272273 drop(lock);274 assert!(!paths.daemon_running().unwrap());275 std::fs::remove_dir_all(&paths.root).unwrap();276 }277278 279 fn identity_writes_its_public_half_as_a_pub_file() {280 let paths = test_paths();281 let key = paths.load_or_create_identity().unwrap();282283 assert_eq!(284 std::fs::read_to_string(paths.identity_public()).unwrap(),285 format!("{}\n", key.public())286 );287288 std::fs::remove_dir_all(&paths.root).unwrap();289 }290291 292 fn legacy_identity_files_are_not_read_or_moved() {293 let paths = test_paths();294 std::fs::create_dir_all(&paths.root).unwrap();295 let original = SecretKey::generate();296 std::fs::write(paths.root.join("identity.key"), original.to_bytes()).unwrap();297 std::fs::write(298 paths.root.join("identity.pub"),299 format!("{}\n", original.public()),300 )301 .unwrap();302303 let loaded = paths.load_or_create_identity().unwrap();304305 assert_ne!(loaded.to_bytes(), original.to_bytes());306 assert!(paths.identity().exists());307 assert!(paths.identity_public().exists());308 assert!(paths.root.join("identity.key").exists());309 assert!(paths.root.join("identity.pub").exists());310 std::fs::remove_dir_all(&paths.root).unwrap();311 }312}