clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

main
devsite / crates / devsite-cli / src / main.rs
main.rs 96.8K
1//! `devsite` — configure a profile and host local services.23mod client;45use anyhow::{bail, Context, Result};6use clap::{error::ErrorKind, Parser, Subcommand};7use devsite_client::{ClientEndpoint, ServiceStream};8use devsite_daemon::config::{DaemonConfig, HostedService, Paths, Visibility};9use devsite_daemon::Daemon;10use devsite_proto::{AccountId, ResourceId, SignedCapability};11use serde::{Deserialize, Serialize};12use std::collections::HashSet;13use std::fs::OpenOptions;14use std::io::IsTerminal;15use std::net::{IpAddr, Ipv4Addr, SocketAddr};16use std::sync::Arc;17use std::time::{Duration, Instant};18use tokio::io::{copy, AsyncWriteExt};19use tokio::net::{TcpListener, TcpStream};2021use crate::client::ControlPlane;2223#[derive(Parser)]24#[command(25 name = "devsite",26 version,27 about = "Share public work and reach your local services",28 after_help = "Typical workflows:\n devsite login dmt_...\n devsite link set --name docs --url https://example.com --public\n devsite service host 3000 --name app\n devsite connect dst_...\n\nUse `devsite <command> --help` for command-specific arguments."29)]30struct Cli {31 /// Control plane base URL.32 #[arg(33 long,34 env = "DEVSITE_SERVER",35 default_value = "https://dev.site",36 global = true37 )]38 server: String,3940 /// Emit stable JSON on stdout. Resident commands emit newline-delimited events.41 #[arg(long, global = true)]42 json: bool,4344 #[command(subcommand)]45 command: Command,46}4748#[derive(Subcommand)]49enum Command {50 /// Store a revocable machine credential and pin the control plane's signing key.51 Login {52 /// Single-use machine enrollment ticket created on the signed-in dashboard.53 token: Option<String>,54 },55 /// Manage ordinary external links.56 #[command(subcommand)]57 Link(LinkCommand),58 /// Inspect resources stored on the control plane.59 #[command(subcommand)]60 Resources(ResourcesCommand),61 /// Host local TCP services.62 #[command(subcommand)]63 Service(ServiceCommand),64 /// Request and grant short-lived endpoint-bound service access.65 #[command(subcommand)]66 Access(AccessCommand),67 /// Forward a local TCP port to a service you may access.68 Connect {69 /// Short-lived connection ticket minted on dev.site.70 ticket: String,71 /// Local address to listen on. Port 0 chooses a free port.72 #[arg(long, default_value = "127.0.0.1:0")]73 listen: SocketAddr,74 },75 /// Profile appearance.76 #[command(subcommand)]77 Theme(ThemeCommand),78 /// Show what this machine is configured to serve.79 Status,80 /// Diagnose local configuration, control-plane compatibility, and resource drift.81 Doctor,82 /// Daemon control.83 #[command(subcommand)]84 Daemon(DaemonCommand),85}8687#[derive(Subcommand)]88enum LinkCommand {89 /// Create or replace a named link.90 Set {91 /// Stable presentation name used to identify later updates and removal.92 #[arg(long)]93 name: String,94 /// HTTP(S) destination without embedded credentials.95 #[arg(long)]96 url: String,97 /// Make the link visible to everyone. Without this or --share it is private.98 #[arg(long, conflicts_with = "share")]99 public: bool,100 /// Invite a specific user to accept the link. Repeatable.101 #[arg(long, value_name = "@handle")]102 share: Vec<String>,103 /// File it under a folder on your profile. Leaving it off takes it out of104 /// whatever folder it was in.105 #[arg(long, value_name = "NAME")]106 folder: Option<String>,107 /// Validate and show the exact change without applying it.108 #[arg(long, visible_alias = "dry-run")]109 plan: bool,110 },111 /// Take a link off your profile.112 Remove {113 /// Name previously passed to `link set`.114 name: String,115 /// Show what would be removed without applying it.116 #[arg(long, visible_alias = "dry-run")]117 plan: bool,118 },119}120121#[derive(Subcommand)]122enum ResourcesCommand {123 /// List owned links and services with share and local-hosting state.124 List,125}126127#[derive(Subcommand)]128enum ServiceCommand {129 /// Host a loopback TCP port as a named service.130 Host {131 /// Local TCP port on 127.0.0.1.132 port: u16,133 /// Presentation name. Defaults to `port PORT`.134 #[arg(long)]135 name: Option<String>,136 /// Invite a specific user to accept the share, e.g. --share @bob. Repeatable.137 #[arg(long, value_name = "@handle")]138 share: Vec<String>,139 /// Presentation folder. TCP services default to `Services`.140 #[arg(long, value_name = "NAME")]141 folder: Option<String>,142 /// Validate and show the exact change without applying it.143 #[arg(long, visible_alias = "dry-run")]144 plan: bool,145 },146 /// Stop hosting a service and take it off your profile.147 Remove {148 /// Name previously passed to `service host`.149 name: String,150 /// Show what would be removed without applying it.151 #[arg(long, visible_alias = "dry-run")]152 plan: bool,153 },154}155156#[derive(Subcommand)]157enum AccessCommand {158 /// Create a signed request without contacting the control plane.159 Request {160 /// Human service keyword for a trusted granting party to resolve.161 service: String,162 /// Public JSON request to give to the granting party.163 #[arg(long, value_name = "FILE")]164 request: std::path::PathBuf,165 /// Private endpoint key retained by the requesting sandbox.166 #[arg(long, value_name = "FILE")]167 key: std::path::PathBuf,168 /// Seconds before the request itself expires (maximum 600).169 #[arg(long, default_value_t = 300)]170 ttl: u64,171 },172 /// Find services that this granting party may delegate.173 Resolve { keyword: String },174 /// Validate or issue a grant for a signed request.175 Grant {176 /// Signed request JSON received from the sandboxed agent.177 #[arg(long, value_name = "FILE")]178 request: std::path::PathBuf,179 /// Canonical resource id. Omit only when the keyword has one unambiguous match.180 #[arg(long)]181 resource: Option<String>,182 /// Grant lifetime in seconds (maximum 900).183 #[arg(long, default_value_t = 900)]184 ttl: u64,185 /// Validate and show the exact endpoint-bound grant without issuing it.186 #[arg(long, visible_alias = "dry-run")]187 plan: bool,188 /// Server-signed token returned by an approved plan.189 #[arg(long, requires = "request")]190 approved_plan: Option<String>,191 },192 /// Forward a local TCP port with an endpoint-bound delegated access grant.193 Connect {194 /// Session grant returned by the granting party.195 grant: String,196 /// Private endpoint key created with `access request`.197 #[arg(long, value_name = "FILE")]198 key: std::path::PathBuf,199 #[arg(long, default_value = "127.0.0.1:0")]200 listen: SocketAddr,201 },202}203204#[derive(Subcommand)]205enum DaemonCommand {206 /// Run the daemon in the foreground.207 Run,208 /// Report whether a daemon is serving this machine's config.209 Status,210}211212/// Profile presentation is a list of `--pico-*` theme declarations and bounded213/// `--devsite-*` layout settings checked by the control plane. There is no theme214/// dropdown and no arbitrary CSS.215#[derive(Subcommand)]216enum ThemeCommand {217 /// Print the theme currently applied to your profile.218 Show,219 /// Replace your theme with declarations read from a file, or `-` for stdin.220 Set {221 /// CSS declaration file, or `-` to read stdin.222 file: String,223 },224 /// Remove your theme and go back to the defaults.225 Clear,226 /// List every profile presentation property and what it accepts.227 Properties,228}229230impl Command {231 fn name(&self) -> &'static str {232 match self {233 Self::Login { .. } => "login",234 Self::Link(LinkCommand::Set { .. }) => "link.set",235 Self::Link(LinkCommand::Remove { .. }) => "link.remove",236 Self::Resources(ResourcesCommand::List) => "resources.list",237 Self::Service(ServiceCommand::Host { .. }) => "service.host",238 Self::Service(ServiceCommand::Remove { .. }) => "service.remove",239 Self::Access(AccessCommand::Request { .. }) => "access.request",240 Self::Access(AccessCommand::Resolve { .. }) => "access.resolve",241 Self::Access(AccessCommand::Grant { plan: true, .. }) => "access.grant.plan",242 Self::Access(AccessCommand::Grant { plan: false, .. }) => "access.grant",243 Self::Access(AccessCommand::Connect { .. }) => "access.connect",244 Self::Connect { .. } => "connect",245 Self::Theme(ThemeCommand::Show) => "theme.show",246 Self::Theme(ThemeCommand::Set { .. }) => "theme.set",247 Self::Theme(ThemeCommand::Clear) => "theme.clear",248 Self::Theme(ThemeCommand::Properties) => "theme.properties",249 Self::Status => "status",250 Self::Doctor => "doctor",251 Self::Daemon(DaemonCommand::Run) => "daemon.run",252 Self::Daemon(DaemonCommand::Status) => "daemon.status",253 }254 }255}256257fn help_target(args: &[std::ffi::OsString]) -> Option<String> {258 let mut words = Vec::new();259 let mut skip_value = false;260 for arg in args.iter().skip(1) {261 let arg = arg.to_string_lossy();262 if skip_value {263 skip_value = false;264 continue;265 }266 if arg == "--server" {267 skip_value = true;268 continue;269 }270 if arg.starts_with('-') || arg.starts_with("http://") || arg.starts_with("https://") {271 continue;272 }273 words.push(arg.into_owned());274 }275276 let top = words.first()?.as_str();277 if !matches!(278 top,279 "login"280 | "link"281 | "resources"282 | "service"283 | "access"284 | "connect"285 | "theme"286 | "status"287 | "doctor"288 | "daemon"289 ) {290 return None;291 }292 let child = words.get(1).map(String::as_str);293 let child_is_command = matches!(294 (top, child),295 ("link", Some("set" | "remove"))296 | ("resources", Some("list"))297 | ("service", Some("host" | "remove"))298 | ("access", Some("request" | "resolve" | "grant" | "connect"))299 | ("theme", Some("show" | "set" | "clear" | "properties"))300 | ("daemon", Some("run" | "status"))301 );302 Some(if child_is_command {303 format!("{top}.{}", child.unwrap())304 } else {305 top.to_string()306 })307}308309fn help_suggestion(command: Option<&str>) -> String {310 let path = command.map(|value| value.replace('.', " "));311 match path {312 Some(path) => format!("Run `devsite {path} --help` for valid arguments."),313 None => "Run `devsite --help` to list available commands.".to_string(),314 }315}316317fn runtime_suggestions(command: &str, err: &anyhow::Error) -> Vec<String> {318 let mut suggestions = Vec::new();319 let message = format!("{err:#}");320 if message.contains("not signed in") || message.contains("run `devsite login` first") {321 suggestions.push(322 "Create a machine ticket on dev.site, then run `devsite login TICKET`.".to_string(),323 );324 }325 suggestions.push(help_suggestion(Some(command)));326 suggestions327}328329#[derive(Deserialize)]330struct ThemeResponse {331 css: String,332}333334#[derive(Deserialize)]335struct ThemeProperties {336 properties: Vec<ThemeProperty>,337}338339#[derive(Deserialize, Serialize)]340struct ThemeProperty {341 name: String,342 accepts: String,343}344345#[derive(Deserialize)]346struct PubKeyResponse {347 public_key: String,348}349350#[derive(Deserialize)]351struct PublicConfig {352 api_version: u32,353 minimum_cli_version: String,354 server_version: String,355 daemon_protocol: String,356}357358#[derive(Deserialize)]359struct CreateResourceResponse {360 resource_id: String,361 #[serde(default)]362 plan: Option<serde_json::Value>,363}364365#[derive(Deserialize)]366struct ResourcePlanResponse {367 plan: serde_json::Value,368}369370#[derive(Deserialize)]371struct MeResponse {372 handle: Option<String>,373}374375#[derive(Deserialize)]376struct DaemonInfo {377 credential_endpoint_id: String,378 registered_endpoint_id: Option<String>,379 registration_matches_credential: bool,380}381382#[derive(Deserialize)]383struct CapabilityResponse {384 capability: String,385 daemon_endpoint_id: String,386}387388#[derive(Deserialize)]389struct RedeemTicketResponse {390 session_token: String,391 #[serde(flatten)]392 relay_access: devsite_iroh::RelayAccess,393 resource_id: String,394 name: String,395 expires_at: u64,396}397398#[derive(Deserialize)]399struct EnrollMachineResponse {400 machine_credential: String,401 endpoint_id: String,402}403404#[derive(Debug, Deserialize, Serialize)]405struct ServiceGrantRequest {406 schema_version: u32,407 request_id: String,408 service: String,409 requester_endpoint_id: String,410 expires_at: u64,411 proof: String,412}413414#[derive(Deserialize, Serialize)]415struct AccessibleService {416 resource_id: String,417 name: String,418 owner_handle: Option<String>,419 visibility: String,420 exact_name_match: bool,421}422423#[derive(Deserialize)]424struct AccessibleServicesResponse {425 keyword: String,426 services: Vec<AccessibleService>,427}428429#[derive(Deserialize, Serialize)]430struct ServiceGrantResponse {431 grant: String,432 request_id: String,433 resource_id: String,434 name: String,435 owner_id: String,436 owner_handle: Option<String>,437 requester_endpoint_id: String,438 expires_at: u64,439}440441#[derive(Deserialize)]442struct TunnelSessionInfo {443 resource_id: String,444 name: String,445 requester_endpoint_id: String,446 #[serde(flatten)]447 relay_access: devsite_iroh::RelayAccess,448 expires_at: u64,449 brokered: bool,450}451452#[derive(Deserialize)]453struct AuthorizationListing {454 authorizations: Vec<ServiceAuthorization>,455}456457#[derive(Deserialize)]458struct ServiceAuthorization {459 viewer_id: String,460 resource_id: String,461}462463#[derive(Deserialize)]464struct ResourceListing {465 resources: Vec<Resource>,466}467468#[derive(Deserialize, Serialize)]469struct Resource {470 resource_id: String,471 name: String,472 kind: String,473 visibility: String,474 /// Set for links only; a service's target never leaves the machine serving it.475 url: Option<String>,476 folder: Option<String>,477 #[serde(default)]478 shares: Vec<ResourceShare>,479}480481#[derive(Deserialize, Serialize)]482struct ResourceShare {483 handle: String,484 status: String,485}486487#[tokio::main]488async fn main() {489 let args = std::env::args_os().collect::<Vec<_>>();490 let json_requested = args.iter().any(|arg| arg == "--json");491 let cli = match Cli::try_parse_from(&args) {492 Ok(cli) => cli,493 Err(err) if err.kind() == ErrorKind::DisplayHelp && json_requested => {494 Output::json().success(495 "help",496 serde_json::json!({497 "command": help_target(&args),498 "text": err.to_string(),499 }),500 );501 return;502 }503 Err(err) if err.kind() == ErrorKind::DisplayVersion && json_requested => {504 Output::json().success(505 "version",506 serde_json::json!({ "text": err.to_string().trim() }),507 );508 return;509 }510 Err(err)511 if matches!(512 err.kind(),513 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion514 ) =>515 {516 err.print().ok();517 return;518 }519 Err(err) if json_requested => {520 let command = help_target(&args);521 Output::json().error(522 "usage",523 command.as_deref(),524 &anyhow::anyhow!(err.to_string()),525 &[help_suggestion(command.as_deref())],526 );527 std::process::exit(2);528 }529 Err(err) => err.exit(),530 };531532 tracing_subscriber::fmt()533 .with_env_filter(534 tracing_subscriber::EnvFilter::try_from_default_env()535 .unwrap_or_else(|_| "devsite=info,devsite_daemon=info,warn".into()),536 )537 .with_target(false)538 .init();539540 let output = Output { json: cli.json };541 let command = cli.command.name();542 if let Err(err) = run(cli, output).await {543 let suggestions = runtime_suggestions(command, &err);544 output.error("runtime", Some(command), &err, &suggestions);545 std::process::exit(1);546 }547}548549#[derive(Clone, Copy)]550struct Output {551 json: bool,552}553554impl Output {555 const fn json() -> Self {556 Self { json: true }557 }558559 fn success(&self, command: &str, result: serde_json::Value) {560 if self.json {561 self.write(&serde_json::json!({562 "schema_version": 1,563 "ok": true,564 "command": command,565 "result": result,566 }));567 }568 }569570 fn event(&self, command: &str, event: &str, data: serde_json::Value) {571 if self.json {572 self.write(&serde_json::json!({573 "schema_version": 1,574 "ok": true,575 "command": command,576 "event": event,577 "data": data,578 }));579 }580 }581582 fn error(583 &self,584 kind: &str,585 command: Option<&str>,586 err: &anyhow::Error,587 suggestions: &[String],588 ) {589 if self.json {590 let causes = err591 .chain()592 .skip(1)593 .map(ToString::to_string)594 .collect::<Vec<_>>();595 self.write(&serde_json::json!({596 "schema_version": 1,597 "ok": false,598 "command": command,599 "error": {600 "kind": kind,601 "message": err.to_string(),602 "causes": causes,603 "suggestions": suggestions,604 },605 }));606 } else {607 eprintln!("error: {err:#}");608 for suggestion in suggestions {609 eprintln!("suggestion: {suggestion}");610 }611 }612 }613614 fn write(&self, value: &serde_json::Value) {615 use std::io::Write;616617 let stdout = std::io::stdout();618 let mut stdout = stdout.lock();619 serde_json::to_writer(&mut stdout, value).expect("JSON values serialize");620 writeln!(stdout).expect("JSON output writes");621 stdout.flush().expect("JSON output flushes");622 }623}624625async fn run(cli: Cli, output: Output) -> Result<()> {626 let paths = Paths::discover()?;627628 match cli.command {629 Command::Login { token } => login(&paths, &cli.server, token, output).await,630 Command::Link(LinkCommand::Set {631 name,632 url,633 public,634 share,635 folder,636 plan,637 }) => set_link(&paths, &name, &url, public, share, folder, plan, output).await,638 Command::Link(LinkCommand::Remove { name, plan }) => {639 remove_link(&paths, &name, plan, output).await640 }641 Command::Resources(ResourcesCommand::List) => resources_list(&paths, output).await,642 Command::Service(ServiceCommand::Host {643 port,644 name,645 share,646 folder,647 plan,648 }) => host_service(&paths, port, name, share, folder, plan, output).await,649 Command::Service(ServiceCommand::Remove { name, plan }) => {650 remove_service(&paths, &name, plan, output).await651 }652 Command::Access(AccessCommand::Request {653 service,654 request,655 key,656 ttl,657 }) => create_access_request(&service, &request, &key, ttl, output),658 Command::Access(AccessCommand::Resolve { keyword }) => {659 resolve_access(&paths, &keyword, output).await660 }661 Command::Access(AccessCommand::Grant {662 request,663 resource,664 ttl,665 plan,666 approved_plan,667 }) => {668 grant_access(669 &paths,670 &request,671 resource.as_deref(),672 ttl,673 plan,674 approved_plan.as_deref(),675 output,676 )677 .await678 }679 Command::Access(AccessCommand::Connect { grant, key, listen }) => {680 connect_grant(&cli.server, &grant, &key, listen, output).await681 }682 Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen, output).await,683 Command::Theme(command) => theme(&paths, &cli.server, command, output).await,684 Command::Status => status(&paths, output),685 Command::Doctor => doctor(&paths, output).await,686 Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server, output).await,687 Command::Daemon(DaemonCommand::Status) => daemon_status(&paths, output),688 }689}690691fn load_authenticated(paths: &Paths) -> Result<DaemonConfig> {692 let config = paths.load_config()?;693 if config.machine_credential.is_none() {694 bail!("not signed in — run `devsite login` first");695 }696 Ok(config)697}698699fn enrolled_server(config: &DaemonConfig) -> Result<&str> {700 config701 .server_url702 .as_deref()703 .context("this credential is not bound to a control-plane origin — log in again")704}705706fn validate_cli_version(minimum: &str, current: &str) -> Result<()> {707 let minimum = semver::Version::parse(minimum)708 .context("server reported an invalid minimum CLI version")?;709 let current =710 semver::Version::parse(current).context("this CLI has invalid version metadata")?;711 if current < minimum {712 bail!("server requires CLI {minimum} or newer, but this is CLI {current}");713 }714 Ok(())715}716717fn green_check() -> &'static str {718 if std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() {719 "\x1b[32m✓\x1b[0m"720 } else {721 "✓"722 }723}724725async fn login(paths: &Paths, server: &str, token: Option<String>, output: Output) -> Result<()> {726 let ticket = match token {727 Some(ticket) => ticket,728 None if output.json => bail!("TOKEN is required with --json"),729 None => {730 println!("Open {server}, create a machine ticket, and paste it here.");731 print!("ticket: ");732 use std::io::Write;733 std::io::stdout().flush()?;734 let mut line = String::new();735 std::io::stdin().read_line(&mut line)?;736 line.trim().to_string()737 }738 };739 if ticket.is_empty() {740 bail!("no ticket provided");741 }742743 let bootstrap = ControlPlane::new(server, None);744 let server_config: PublicConfig = bootstrap.get("/api/config").await?;745 if server_config.api_version != 3 {746 bail!(747 "this CLI speaks API version 3, but the server reports version {}",748 server_config.api_version749 );750 }751 let cli_version = env!("CARGO_PKG_VERSION");752 validate_cli_version(&server_config.minimum_cli_version, cli_version)?;753 let pubkey: PubKeyResponse = bootstrap.get("/api/pubkey").await?;754 let identity = paths.load_or_create_identity()?;755 let endpoint_id = identity.public().to_string();756 let enrollment = ControlPlane::new(server, Some(ticket));757 let enrolled: EnrollMachineResponse = enrollment758 .put(759 "/api/machine/enroll",760 &serde_json::json!({761 "endpoint_id": endpoint_id,762 "proof": endpoint_proof(&identity),763 }),764 )765 .await766 .context("enrolling this machine ticket")?;767 if enrolled.endpoint_id != endpoint_id {768 bail!("the server enrolled a different endpoint identity");769 }770771 // Confirm the rotated credential works before storing it.772 let api = ControlPlane::new(server, Some(enrolled.machine_credential.clone()));773 let me: serde_json::Value = api774 .get("/api/me")775 .await776 .context("the enrolled machine credential was not accepted")?;777778 let mut config = paths.load_config()?;779 config.server_url = Some(server.trim_end_matches('/').to_string());780 config.machine_credential = Some(enrolled.machine_credential);781 // Pinned now, and every capability is checked against it from here on.782 config.control_plane_key = Some(pubkey.public_key);783 paths.save_config(&config)?;784785 let handle = me786 .get("handle")787 .and_then(|h| h.as_str())788 .unwrap_or("(no handle yet)");789 let control_plane_key = config.control_plane_key.unwrap();790 if output.json {791 output.success(792 "login",793 serde_json::json!({794 "handle": handle,795 "server": server.trim_end_matches('/'),796 "cli_version": cli_version,797 "minimum_cli_version": server_config.minimum_cli_version,798 "cli_compatible": true,799 "control_plane_key": control_plane_key,800 }),801 );802 } else {803 println!("signed in as {handle}");804 println!(805 "{} CLI {} meets server requirement ({} or newer)",806 green_check(),807 cli_version,808 server_config.minimum_cli_version,809 );810 println!("pinned control plane key {control_plane_key}");811 }812 Ok(())813}814815fn endpoint_proof(secret: &iroh::SecretKey) -> String {816 let endpoint = *secret.public().as_bytes();817 let signature = secret.sign(&devsite_proto::machine_endpoint_proof_message(&endpoint));818 data_encoding::BASE64URL_NOPAD.encode(&signature.to_bytes())819}820821#[allow(clippy::too_many_arguments)]822async fn set_link(823 paths: &Paths,824 name: &str,825 url: &str,826 public: bool,827 share: Vec<String>,828 folder: Option<String>,829 plan: bool,830 output: Output,831) -> Result<()> {832 let visibility = if public {833 Visibility::Public834 } else if share.is_empty() {835 Visibility::Private836 } else {837 Visibility::Shared838 };839 let share = normalized_handles(&share);840 let config = load_authenticated(paths)?;841 let server = enrolled_server(&config)?;842 let api = ControlPlane::new(server, config.machine_credential.clone());843 let payload = serde_json::json!({844 "name": name,845 "kind": "link",846 "visibility": visibility_str(visibility),847 "url": url,848 "share_with": share,849 "folder": folder,850 });851 if plan {852 let planned: ResourcePlanResponse = api.post("/api/resources/plan", &payload).await?;853 if output.json {854 output.success(855 "link.set",856 serde_json::json!({ "applied": false, "plan": planned.plan }),857 );858 } else {859 print_resource_plan(&planned.plan);860 }861 return Ok(());862 }863 let update = warn_on_upsert(864 &api,865 "link",866 name,867 visibility,868 Some(url),869 folder.as_deref(),870 &share,871 )872 .await?;873 if !output.json {874 if let Some(update) = &update {875 update.print_human();876 }877 }878 let response: CreateResourceResponse = api.post("/api/resources", &payload).await?;879 if output.json {880 output.success(881 "link.set",882 serde_json::json!({883 "resource_id": response.resource_id,884 "name": name,885 "url": url,886 "visibility": visibility_str(visibility),887 "folder": folder,888 "recipients": share,889 "update": update,890 "applied": true,891 "plan": response.plan,892 }),893 );894 } else {895 println!("set link {name} → {url} ({})", visibility_str(visibility));896 if let Some(folder) = &folder {897 println!(" in {folder}");898 }899 if !share.is_empty() {900 println!(" invited {}", display_handles(&share));901 }902 }903 Ok(())904}905906/// Delete one of your resources, found by the name you gave it.907///908/// Names are resolved here rather than server-side because the server keys909/// everything by id, and an endpoint that deleted by name would have to invent a910/// rule for a link and a service sharing one. Listing first also means the "no911/// such thing" case is answered with the names that do exist.912async fn remove_resource(913 paths: &Paths,914 name: &str,915 kind: &str,916 apply: bool,917) -> Result<Option<Resource>> {918 let config = load_authenticated(paths)?;919 let server = enrolled_server(&config)?;920 let api = ControlPlane::new(server, config.machine_credential.clone());921922 let listing: ResourceListing = api.get("/api/resources").await?;923 let found = listing924 .resources925 .into_iter()926 .find(|r| r.name == name && r.kind == kind);927928 if apply {929 if let Some(resource) = &found {930 api.delete(&format!("/api/resources/{}", resource.resource_id))931 .await?;932 }933 }934 Ok(found)935}936937async fn remove_link(paths: &Paths, name: &str, plan: bool, output: Output) -> Result<()> {938 let removed = remove_resource(paths, name, "link", !plan)939 .await?940 .with_context(|| format!("you have no link called `{name}`"))?;941 let removal_plan = resource_removal_plan(&removed, None);942 if plan {943 if output.json {944 output.success(945 "link.remove",946 serde_json::json!({ "applied": false, "plan": removal_plan }),947 );948 } else {949 print_resource_plan(&removal_plan);950 }951 return Ok(());952 }953 let url = removed.url.unwrap_or_default();954 if output.json {955 output.success(956 "link.remove",957 serde_json::json!({958 "resource_id": removed.resource_id,959 "name": name,960 "url": url,961 "applied": true,962 "plan": removal_plan,963 }),964 );965 } else {966 println!("removed link {name} → {url}");967 }968 Ok(())969}970971/// Stop serving a local service and take it off the profile.972///973/// Both halves matter and in this order: the control plane forgets it, then this974/// machine does. A daemon that kept serving a resource the control plane has975/// deleted is harmless — no capability can be issued for it any more — but a976/// config that still lists it would re-register the name the next time it is hosted.977async fn remove_service(paths: &Paths, name: &str, plan: bool, output: Output) -> Result<()> {978 let mut config = paths.load_config()?;979 let local = config980 .resources981 .iter()982 .find(|resource| resource.name == name);983 let removed = remove_resource(paths, name, "service", !plan).await?;984 if removed.is_none() && local.is_none() {985 bail!("you have no service called `{name}`");986 }987 let removal_plan = match &removed {988 Some(resource) => resource_removal_plan(resource, local.map(|service| service.target)),989 None => serde_json::json!({990 "operation": "delete",991 "target": { "resource_id": null, "kind": "service", "name": name },992 "changes": [],993 "recipient_changes": [],994 "effects": [{ "code": "local_service_mapping_removed", "handles": [] }],995 "local_only": true,996 }),997 };998 if plan {999 if output.json {1000 output.success(1001 "service.remove",1002 serde_json::json!({ "applied": false, "plan": removal_plan }),1003 );1004 } else {1005 print_resource_plan(&removal_plan);1006 }1007 return Ok(());1008 }10091010 let before = config.resources.len();1011 config.resources.retain(|r| r.name != name);1012 paths.save_config(&config)?;10131014 let was_hosted_here = config.resources.len() != before;1015 let remote_already_absent = removed.is_none();1016 if output.json {1017 output.success(1018 "service.remove",1019 serde_json::json!({1020 "resource_id": removed.as_ref().map(|resource| resource.resource_id.as_str()),1021 "name": name,1022 "was_hosted_here": was_hosted_here,1023 "remote_already_absent": remote_already_absent,1024 "applied": true,1025 "plan": removal_plan,1026 }),1027 );1028 } else {1029 println!("removed service {name}");1030 if was_hosted_here {1031 println!(" a running daemon will stop serving it automatically");1032 } else {1033 println!(" (this machine was not serving it; removed from your profile)");1034 }1035 }1036 Ok(())1037}10381039async fn host_service(1040 paths: &Paths,1041 port: u16,1042 name: Option<String>,1043 share: Vec<String>,1044 folder: Option<String>,1045 plan: bool,1046 output: Output,1047) -> Result<()> {1048 if port == 0 {1049 bail!("port 0 cannot be hosted");1050 }1051 let name = name.unwrap_or_else(|| format!("port {port}"));1052 let folder = Some(folder.unwrap_or_else(|| "Services".to_string()));1053 let target = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);1054 let visibility = if share.is_empty() {1055 Visibility::Private1056 } else {1057 Visibility::Shared1058 };1059 let share = normalized_handles(&share);10601061 let config = load_authenticated(paths)?;1062 let server = enrolled_server(&config)?;1063 let api = ControlPlane::new(server, config.machine_credential.clone());1064 let payload = serde_json::json!({1065 "name": &name,1066 "kind": "service",1067 "visibility": visibility_str(visibility),1068 // Deliberately absent: the local target never leaves this machine.1069 "share_with": share,1070 "folder": folder,1071 });1072 if plan {1073 let planned: ResourcePlanResponse = api.post("/api/resources/plan", &payload).await?;1074 let planned = plan_with_local_changes(1075 planned.plan,1076 serde_json::json!([{1077 "operation": "upsert_hosted_service",1078 "target": target.to_string(),1079 }]),1080 );1081 if output.json {1082 output.success(1083 "service.host",1084 serde_json::json!({ "applied": false, "plan": planned }),1085 );1086 } else {1087 print_resource_plan(&planned);1088 }1089 return Ok(());1090 }1091 let update = warn_on_upsert(1092 &api,1093 "service",1094 &name,1095 visibility,1096 None,1097 folder.as_deref(),1098 &share,1099 )1100 .await?;1101 if !output.json {1102 if let Some(update) = &update {1103 update.print_human();1104 }1105 }11061107 let response: CreateResourceResponse = api.post("/api/resources", &payload).await?;1108 let applied_plan = response.plan.map(|plan| {1109 plan_with_local_changes(1110 plan,1111 serde_json::json!([{1112 "operation": "upsert_hosted_service",1113 "target": target.to_string(),1114 }]),1115 )1116 });11171118 let resource_id = response1119 .resource_id1120 .parse()1121 .context("the server returned an unusable resource id")?;11221123 let mut config = paths.load_config()?;1124 config.resources.retain(|r| r.name != name);1125 config.resources.push(HostedService {1126 resource_id,1127 name: name.clone(),1128 target,1129 visibility,1130 });1131 paths.save_config(&config)?;11321133 let daemon_running = paths.daemon_running()?;1134 if output.json {1135 output.success(1136 "service.host",1137 serde_json::json!({1138 "resource_id": resource_id.to_string(),1139 "name": name,1140 "target": target.to_string(),1141 "visibility": visibility_str(visibility),1142 "folder": folder,1143 "recipients": share,1144 "url": format!("{server}/s/{resource_id}"),1145 "daemon": daemon_state(daemon_running),1146 "update": update,1147 "applied": true,1148 "plan": applied_plan,1149 }),1150 );1151 } else {1152 println!("hosting {name} → {target} ({})", visibility_str(visibility));1153 if let Some(folder) = &folder {1154 println!(" in {folder}");1155 }1156 if !share.is_empty() {1157 println!(" invited {}", display_handles(&share));1158 }1159 println!(" {server}/s/{resource_id}");1160 println!();1161 print_daemon_status(paths, output)?;1162 }1163 Ok(())1164}11651166async fn connect(server: &str, ticket: &str, listen: SocketAddr, output: Output) -> Result<()> {1167 if !listen.ip().is_loopback() {1168 bail!("--listen must be a loopback address; refusing to publish the tunnel on the LAN");1169 }1170 let secret_key = iroh::SecretKey::generate();1171 let bootstrap = ControlPlane::new(server, None);1172 let redeemed: RedeemTicketResponse = bootstrap1173 .post(1174 "/api/tickets/redeem",1175 &serde_json::json!({1176 "ticket": ticket,1177 "client_endpoint_id": secret_key.public().to_string(),1178 }),1179 )1180 .await1181 .context("redeeming connection ticket")?;1182 let client =1183 Arc::new(ClientEndpoint::create_with_relay(secret_key, redeemed.relay_access).await?);1184 let api = Arc::new(ControlPlane::new(server, Some(redeemed.session_token)));1185 run_tunnel(1186 "connect",1187 server,1188 client,1189 api,1190 &redeemed.name,1191 &redeemed.resource_id,1192 redeemed.expires_at,1193 listen,1194 output,1195 )1196 .await1197}11981199fn create_access_request(1200 service: &str,1201 request_path: &std::path::Path,1202 key_path: &std::path::Path,1203 ttl: u64,1204 output: Output,1205) -> Result<()> {1206 let service = service.trim();1207 if service.is_empty() || service.chars().count() > 80 {1208 bail!("service keyword must be 1 to 80 characters");1209 }1210 if ttl == 0 || ttl > 10 * 60 {1211 bail!("request TTL must be between 1 and 600 seconds");1212 }1213 if request_path == key_path {1214 bail!("--request and --key must name different files");1215 }12161217 let secret = iroh::SecretKey::generate();1218 let endpoint = secret.public();1219 let mut nonce = [0u8; 24];1220 getrandom::fill(&mut nonce)1221 .map_err(|err| anyhow::anyhow!("generating service grant request id: {err}"))?;1222 let request_id = format!("agr_{}", data_encoding::BASE64URL_NOPAD.encode(&nonce));1223 let expires_at = unix_now()?.saturating_add(ttl);1224 let message = devsite_proto::service_grant_request_message(1225 &request_id,1226 service,1227 endpoint.as_bytes(),1228 expires_at,1229 );1230 let proof = data_encoding::BASE64URL_NOPAD.encode(&secret.sign(&message).to_bytes());1231 let request = ServiceGrantRequest {1232 schema_version: 1,1233 request_id,1234 service: service.to_string(),1235 requester_endpoint_id: endpoint.to_string(),1236 expires_at,1237 proof,1238 };12391240 write_new_private(key_path, &secret.to_bytes())?;1241 let request_json = serde_json::to_vec_pretty(&request)?;1242 if let Err(err) = write_new_private(request_path, &request_json) {1243 std::fs::remove_file(key_path).ok();1244 return Err(err);1245 }12461247 if output.json {1248 output.success(1249 "access.request",1250 serde_json::json!({1251 "request": request,1252 "request_path": request_path,1253 "key_path": key_path,1254 "handoff": "share_request_only",1255 }),1256 );1257 } else {1258 println!("created signed request for {service}");1259 println!(1260 " request {} (give to the granting party)",1261 request_path.display()1262 );1263 println!(1264 " key {} (keep inside the requester)",1265 key_path.display()1266 );1267 println!(" endpoint {endpoint}");1268 println!(" expires {expires_at}");1269 }1270 Ok(())1271}12721273async fn resolve_access(paths: &Paths, keyword: &str, output: Output) -> Result<()> {1274 let listing = resolve_access_listing(paths, keyword).await?;1275 if output.json {1276 output.success("access.resolve", serde_json::to_value(&listing.services)?);1277 } else if listing.services.is_empty() {1278 println!("no accessible services match {:?}", listing.keyword);1279 } else {1280 for service in listing.services {1281 let owner = service1282 .owner_handle1283 .map(|handle| format!(" from @{handle}"))1284 .unwrap_or_default();1285 println!("{} {}{}", service.resource_id, service.name, owner);1286 }1287 }1288 Ok(())1289}12901291async fn resolve_access_listing(1292 paths: &Paths,1293 keyword: &str,1294) -> Result<AccessibleServicesResponse> {1295 let config = load_authenticated(paths)?;1296 let server = enrolled_server(&config)?;1297 let api = ControlPlane::new(server, config.machine_credential.clone());1298 api.get_query("/api/access/services", &[("keyword", keyword)])1299 .await1300 .context("resolving accessible services")1301}13021303async fn grant_access(1304 paths: &Paths,1305 request_path: &std::path::Path,1306 resource: Option<&str>,1307 ttl: u64,1308 plan: bool,1309 approved_plan: Option<&str>,1310 output: Output,1311) -> Result<()> {1312 if ttl == 0 || ttl > 15 * 60 {1313 bail!("grant TTL must be between 1 and 900 seconds");1314 }1315 if plan && approved_plan.is_some() {1316 bail!("--approved-plan cannot be combined with --plan");1317 }1318 if !plan && approved_plan.is_none() {1319 bail!("apply requires --approved-plan from the exact reviewed plan");1320 }1321 let approved_claims = approved_plan1322 .map(|token| {1323 devsite_proto::access_plan::SignedServiceGrantPlan::from_token(token)1324 .and_then(|plan| plan.unverified_claims())1325 .map_err(|_| anyhow::anyhow!("--approved-plan is malformed"))1326 })1327 .transpose()?;1328 let request: ServiceGrantRequest = serde_json::from_slice(1329 &std::fs::read(request_path)1330 .with_context(|| format!("reading {}", request_path.display()))?,1331 )1332 .with_context(|| format!("parsing {}", request_path.display()))?;1333 if approved_claims.as_ref().is_some_and(|claims| {1334 claims.schema_version != request.schema_version1335 || claims.request_id != request.request_id1336 || claims.service != request.service1337 || claims.requester_endpoint_id != request.requester_endpoint_id1338 || claims.request_expires_at != request.expires_at1339 }) {1340 bail!("--approved-plan does not match the signed request file");1341 }1342 let listing = resolve_access_listing(paths, &request.service).await?;1343 let resource_id = match (resource, &approved_claims) {1344 (Some(resource), Some(claims)) if resource != claims.resource_id => {1345 bail!("--resource does not match --approved-plan")1346 }1347 (_, Some(claims)) => {1348 if !listing1349 .services1350 .iter()1351 .any(|item| item.resource_id == claims.resource_id)1352 {1353 bail!("the approved service is no longer accessible to this granting party");1354 }1355 claims.resource_id.clone()1356 }1357 (Some(resource), None) => {1358 if !listing1359 .services1360 .iter()1361 .any(|item| item.resource_id == resource)1362 {1363 bail!(1364 "resource {resource} is not among the services matching {:?}",1365 request.service1366 );1367 }1368 resource.to_string()1369 }1370 (None, None) => select_access_service(&request.service, &listing.services)?1371 .resource_id1372 .clone(),1373 };1374 let expires_at = match &approved_claims {1375 Some(claims) => claims.grant_expires_at,1376 None => unix_now()?.saturating_add(ttl),1377 };1378 let requester: iroh::EndpointId = request1379 .requester_endpoint_id1380 .parse()1381 .context("request contains an invalid endpoint id")?;1382 let broker = paths.load_or_create_identity()?;1383 let message = devsite_proto::service_grant_issue_message(1384 &request.request_id,1385 &resource_id,1386 requester.as_bytes(),1387 expires_at,1388 );1389 let broker_proof = data_encoding::BASE64URL_NOPAD.encode(&broker.sign(&message).to_bytes());1390 let body = serde_json::json!({1391 "request": request,1392 "resource_id": resource_id,1393 "expires_at": expires_at,1394 "broker_proof": broker_proof,1395 "approved_plan": approved_plan,1396 });1397 let config = load_authenticated(paths)?;1398 let server = enrolled_server(&config)?;1399 let api = ControlPlane::new(server, config.machine_credential.clone());1400 if plan {1401 let result: serde_json::Value = api1402 .post("/api/access/grants/plan", &body)1403 .await1404 .context("planning endpoint-bound service grant")?;1405 if output.json {1406 output.success("access.grant.plan", result);1407 } else {1408 println!("{}", serde_json::to_string_pretty(&result)?);1409 }1410 } else {1411 let grant: ServiceGrantResponse = api1412 .post("/api/access/grants", &body)1413 .await1414 .context("issuing endpoint-bound service grant")?;1415 if output.json {1416 let mut result = serde_json::to_value(&grant)?;1417 if let serde_json::Value::Object(fields) = &mut result {1418 fields.insert("server".to_string(), serde_json::json!(server));1419 }1420 output.success("access.grant", result);1421 } else {1422 println!("issued {} access to {}", grant.request_id, grant.name);1423 if let Some(owner) = &grant.owner_handle {1424 println!(" owner @{owner}");1425 } else {1426 println!(" owner {}", grant.owner_id);1427 }1428 println!(" endpoint {}", grant.requester_endpoint_id);1429 println!(" expires {}", grant.expires_at);1430 println!(" grant {}", grant.grant);1431 }1432 }1433 Ok(())1434}14351436fn select_access_service<'a>(1437 keyword: &str,1438 services: &'a [AccessibleService],1439) -> Result<&'a AccessibleService> {1440 let exact = services1441 .iter()1442 .filter(|service| service.exact_name_match)1443 .collect::<Vec<_>>();1444 match (exact.as_slice(), services) {1445 ([service], _) => Ok(*service),1446 ([], [service]) => Ok(service),1447 ([], []) => bail!("no accessible service matches {keyword:?}"),1448 _ => bail!(1449 "service keyword {keyword:?} is ambiguous; run `devsite access resolve {keyword}` and pass --resource"1450 ),1451 }1452}14531454async fn connect_grant(1455 server: &str,1456 grant: &str,1457 key_path: &std::path::Path,1458 listen: SocketAddr,1459 output: Output,1460) -> Result<()> {1461 if !grant.starts_with("dss_") {1462 bail!("delegated access grant must start with dss_");1463 }1464 let raw = std::fs::read(key_path).with_context(|| format!("reading {}", key_path.display()))?;1465 let bytes: [u8; 32] = raw1466 .as_slice()1467 .try_into()1468 .map_err(|_| anyhow::anyhow!("{} is not a 32 byte endpoint key", key_path.display()))?;1469 let secret_key = iroh::SecretKey::from_bytes(&bytes);1470 let api = Arc::new(ControlPlane::new(server, Some(grant.to_string())));1471 let session: TunnelSessionInfo = api1472 .get("/api/tunnel/session")1473 .await1474 .context("validating delegated access grant")?;1475 if session.requester_endpoint_id != secret_key.public().to_string() || !session.brokered {1476 bail!("the grant is not bound to the supplied requester endpoint key");1477 }1478 let client =1479 Arc::new(ClientEndpoint::create_with_relay(secret_key, session.relay_access).await?);1480 run_tunnel(1481 "access.connect",1482 server,1483 client,1484 api,1485 &session.name,1486 &session.resource_id,1487 session.expires_at,1488 listen,1489 output,1490 )1491 .await1492}14931494fn unix_now() -> Result<u64> {1495 Ok(std::time::SystemTime::now()1496 .duration_since(std::time::UNIX_EPOCH)1497 .context("system clock is before unix epoch")?1498 .as_secs())1499}15001501fn write_new_private(path: &std::path::Path, contents: &[u8]) -> Result<()> {1502 if let Some(parent) = path1503 .parent()1504 .filter(|parent| !parent.as_os_str().is_empty())1505 {1506 std::fs::create_dir_all(parent)?;1507 }1508 let mut options = OpenOptions::new();1509 options.write(true).create_new(true);1510 #[cfg(unix)]1511 {1512 use std::os::unix::fs::OpenOptionsExt;1513 options.mode(0o600);1514 }1515 let mut file = options1516 .open(path)1517 .with_context(|| format!("creating {} without overwriting it", path.display()))?;1518 use std::io::Write;1519 file.write_all(contents)?;1520 Ok(())1521}15221523#[allow(clippy::too_many_arguments)]1524async fn run_tunnel(1525 command: &str,1526 server: &str,1527 client: Arc<ClientEndpoint>,1528 api: Arc<ControlPlane>,1529 name: &str,1530 resource_id: &str,1531 expires_at: u64,1532 listen: SocketAddr,1533 output: Output,1534) -> Result<()> {1535 if !listen.ip().is_loopback() {1536 bail!("--listen must be a loopback address; refusing to publish the tunnel on the LAN");1537 }1538 let listener = TcpListener::bind(listen)1539 .await1540 .with_context(|| format!("binding local listener {listen}"))?;1541 let bound = listener.local_addr()?;15421543 if output.json {1544 output.event(1545 command,1546 "listening",1547 serde_json::json!({1548 "name": name,1549 "resource_id": resource_id,1550 "resource_url": format!("{server}/s/{resource_id}"),1551 "listen": bound.to_string(),1552 "expires_at": expires_at,1553 }),1554 );1555 } else {1556 println!("connected to {} ({server}/s/{})", name, resource_id);1557 println!(" listening on {bound}");1558 if expires_at > 0 {1559 println!(" session expires at unix time {expires_at}");1560 }1561 println!(" press Ctrl-C to stop");1562 }15631564 loop {1565 let (local, peer) = tokio::select! {1566 accepted = listener.accept() => accepted.context("accepting local connection")?,1567 _ = tokio::signal::ctrl_c() => {1568 if output.json {1569 output.event(command, "shutdown", serde_json::json!({}));1570 } else {1571 println!("\nshutting down");1572 }1573 if let Err(err) = api.delete("/api/tunnel/session").await {1574 tracing::warn!("could not revoke tunnel session during shutdown: {err:#}");1575 }1576 client.close().await;1577 return Ok(());1578 }1579 };1580 let api = Arc::clone(&api);1581 let client = Arc::clone(&client);1582 tokio::spawn(async move {1583 if let Err(err) = forward_connection(&api, &client, local).await {1584 tracing::warn!(%peer, "service connection failed: {err:#}");1585 }1586 });1587 }1588}15891590async fn forward_connection(1591 api: &ControlPlane,1592 client: &ClientEndpoint,1593 local: TcpStream,1594) -> Result<()> {1595 let grant: CapabilityResponse = api1596 .post("/api/tunnel/capability", &serde_json::json!({}))1597 .await?;1598 let raw = data_encoding::BASE64URL_NOPAD1599 .decode(grant.capability.as_bytes())1600 .context("the server returned a malformed capability")?;1601 let capability =1602 SignedCapability::from_bytes(&raw).context("the server returned a malformed capability")?;1603 let daemon: iroh::EndpointId = grant1604 .daemon_endpoint_id1605 .parse()1606 .context("the server returned an invalid daemon endpoint")?;1607 let ServiceStream { mut send, mut recv } = client.connect(daemon, capability).await?;1608 let (mut local_read, mut local_write) = local.into_split();16091610 let local_to_service = async {1611 copy(&mut local_read, &mut send).await?;1612 send.finish().context("finishing service input")?;1613 Ok::<_, anyhow::Error>(())1614 };1615 let service_to_local = async {1616 copy(&mut recv, &mut local_write).await?;1617 local_write.shutdown().await.ok();1618 Ok::<_, anyhow::Error>(())1619 };1620 tokio::try_join!(local_to_service, service_to_local)?;1621 Ok(())1622}16231624async fn theme(paths: &Paths, server: &str, command: ThemeCommand, output: Output) -> Result<()> {1625 // The property list is public: it is the documentation, and it comes from1626 // the binary that enforces it rather than from a copy kept here.1627 if let ThemeCommand::Properties = command {1628 let api = ControlPlane::new(server, None);1629 let listing: ThemeProperties = api.get("/api/theme/properties").await?;1630 if output.json {1631 output.success(1632 "theme.properties",1633 serde_json::json!({ "properties": listing.properties }),1634 );1635 } else {1636 for property in listing.properties {1637 println!("{:<44} {}", property.name, property.accepts);1638 }1639 }1640 return Ok(());1641 }16421643 let config = load_authenticated(paths)?;1644 let server = enrolled_server(&config)?;1645 let api = ControlPlane::new(server, config.machine_credential.clone());16461647 match command {1648 ThemeCommand::Properties => unreachable!("handled above, before authentication"),1649 ThemeCommand::Show => {1650 let theme: ThemeResponse = api.get("/api/theme").await?;1651 if output.json {1652 output.success("theme.show", serde_json::json!({ "css": theme.css }));1653 } else if theme.css.trim().is_empty() {1654 println!("no theme set — run `devsite theme set <file>`");1655 } else {1656 print!("{}", theme.css);1657 }1658 }1659 ThemeCommand::Set { file } => {1660 let css = if file == "-" {1661 std::io::read_to_string(std::io::stdin())?1662 } else {1663 std::fs::read_to_string(&file).with_context(|| format!("reading {file}"))?1664 };1665 // The server is the only validator. Rejections arrive as its own1666 // message — "`--pico-primary: wine` — expected a colour, e.g. …" —1667 // and are printed as-is rather than re-worded here.1668 let saved: ThemeResponse = api1669 .put("/api/theme", &serde_json::json!({ "css": css }))1670 .await?;1671 if output.json {1672 output.success("theme.set", serde_json::json!({ "css": saved.css }));1673 } else {1674 print!("{}", saved.css);1675 println!("theme saved");1676 }1677 }1678 ThemeCommand::Clear => {1679 let _: ThemeResponse = api1680 .put("/api/theme", &serde_json::json!({ "css": "" }))1681 .await?;1682 if output.json {1683 output.success("theme.clear", serde_json::json!({ "css": "" }));1684 } else {1685 println!("theme cleared");1686 }1687 }1688 }1689 Ok(())1690}16911692fn normalized_handles(handles: &[String]) -> Vec<String> {1693 let mut unique = std::collections::BTreeMap::new();1694 for handle in handles {1695 let handle = handle.trim_start_matches('@');1696 unique1697 .entry(handle.to_ascii_lowercase())1698 .or_insert_with(|| handle.to_string());1699 }1700 unique.into_values().collect()1701}17021703fn display_handles(handles: &[String]) -> String {1704 handles1705 .iter()1706 .map(|handle| format!("@{handle}"))1707 .collect::<Vec<_>>()1708 .join(", ")1709}17101711fn plan_with_local_changes(1712 mut plan: serde_json::Value,1713 local_changes: serde_json::Value,1714) -> serde_json::Value {1715 if let Some(object) = plan.as_object_mut() {1716 object.insert("local_changes".to_string(), local_changes);1717 }1718 plan1719}17201721fn resource_removal_plan(1722 resource: &Resource,1723 local_target: Option<SocketAddr>,1724) -> serde_json::Value {1725 let recipient_changes = resource1726 .shares1727 .iter()1728 .map(|recipient| {1729 serde_json::json!({1730 "handle": recipient.handle,1731 "from": recipient.status,1732 "to": null,1733 "reason": "resource_removed",1734 })1735 })1736 .collect::<Vec<_>>();1737 let accepted = resource1738 .shares1739 .iter()1740 .filter(|recipient| recipient.status == "accepted")1741 .map(|recipient| recipient.handle.clone())1742 .collect::<Vec<_>>();1743 let pending = resource1744 .shares1745 .iter()1746 .filter(|recipient| recipient.status == "pending")1747 .map(|recipient| recipient.handle.clone())1748 .collect::<Vec<_>>();1749 let mut effects = vec![serde_json::json!({1750 "code": "profile_entry_removed",1751 "handles": [],1752 })];1753 if !accepted.is_empty() {1754 effects.push(serde_json::json!({1755 "code": "accepted_access_revoked",1756 "handles": accepted,1757 }));1758 }1759 if !pending.is_empty() {1760 effects.push(serde_json::json!({1761 "code": "pending_invitations_withdrawn",1762 "handles": pending,1763 }));1764 }1765 if local_target.is_some() {1766 effects.push(serde_json::json!({1767 "code": "local_service_mapping_removed",1768 "handles": [],1769 }));1770 }1771 serde_json::json!({1772 "operation": "delete",1773 "target": {1774 "resource_id": resource.resource_id,1775 "kind": resource.kind,1776 "name": resource.name,1777 },1778 "changes": [],1779 "recipient_changes": recipient_changes,1780 "effects": effects,1781 "local_changes": local_target.map(|target| vec![serde_json::json!({1782 "operation": "remove_hosted_service",1783 "target": target.to_string(),1784 })]).unwrap_or_default(),1785 })1786}17871788fn print_resource_plan(plan: &serde_json::Value) {1789 let operation = plan["operation"].as_str().unwrap_or("change");1790 let kind = plan["target"]["kind"].as_str().unwrap_or("resource");1791 let name = plan["target"]["name"].as_str().unwrap_or("(unknown)");1792 println!("plan: {operation} {kind} `{name}`");1793 if let Some(changes) = plan["changes"].as_array() {1794 for change in changes {1795 println!(1796 " {}: {} → {}",1797 change["field"].as_str().unwrap_or("field"),1798 change["from"],1799 change["to"]1800 );1801 }1802 }1803 if let Some(effects) = plan["effects"].as_array() {1804 for effect in effects {1805 println!(" effect: {}", effect["code"].as_str().unwrap_or("change"));1806 }1807 }1808 println!("no changes applied");1809}18101811async fn resources_list(paths: &Paths, output: Output) -> Result<()> {1812 let config = load_authenticated(paths)?;1813 let server = enrolled_server(&config)?.to_string();1814 let api = ControlPlane::new(&server, config.machine_credential.clone());1815 let listing: ResourceListing = api.get("/api/resources").await?;1816 let daemon_running = paths.daemon_running()?;1817 let remote_ids = listing1818 .resources1819 .iter()1820 .map(|resource| resource.resource_id.clone())1821 .collect::<HashSet<_>>();1822 let resources = listing1823 .resources1824 .iter()1825 .map(|resource| {1826 let local = config1827 .resources1828 .iter()1829 .find(|hosted| hosted.resource_id.to_string() == resource.resource_id);1830 let mut issues = Vec::new();1831 if let Some(hosted) = local {1832 if visibility_str(hosted.visibility) != resource.visibility {1833 issues.push(serde_json::json!({1834 "code": "visibility_mismatch",1835 "local": visibility_str(hosted.visibility),1836 "remote": resource.visibility,1837 }));1838 }1839 if resource.kind != "service" {1840 issues.push(serde_json::json!({ "code": "remote_kind_mismatch" }));1841 }1842 }1843 let state = match (resource.kind.as_str(), local, daemon_running) {1844 ("link", _, _) => "profile_entry",1845 ("service", Some(_), true) => "serving_here",1846 ("service", Some(_), false) => "configured_here",1847 ("service", None, _) => "not_configured_here",1848 _ => "profile_entry",1849 };1850 serde_json::json!({1851 "resource_id": resource.resource_id,1852 "name": resource.name,1853 "kind": resource.kind,1854 "visibility": resource.visibility,1855 "url": resource.url,1856 "folder": resource.folder,1857 "recipients": resource.shares,1858 "local": local.map(|hosted| serde_json::json!({1859 "configured": true,1860 "target": hosted.target.to_string(),1861 })),1862 "state": state,1863 "issues": issues,1864 })1865 })1866 .collect::<Vec<_>>();1867 let local_only_services = config1868 .resources1869 .iter()1870 .filter(|hosted| !remote_ids.contains(&hosted.resource_id.to_string()))1871 .map(|hosted| {1872 serde_json::json!({1873 "resource_id": hosted.resource_id.to_string(),1874 "name": hosted.name,1875 "kind": "service",1876 "visibility": visibility_str(hosted.visibility),1877 "target": hosted.target.to_string(),1878 "state": "local_only",1879 "issues": [{ "code": "missing_remote_resource" }],1880 })1881 })1882 .collect::<Vec<_>>();18831884 if output.json {1885 output.success(1886 "resources.list",1887 serde_json::json!({1888 "server": server,1889 "daemon": daemon_state(daemon_running),1890 "resources": resources,1891 "local_only_services": local_only_services,1892 }),1893 );1894 } else {1895 if resources.is_empty() {1896 println!("no remote resources");1897 }1898 for resource in &resources {1899 println!(1900 "{:<8} {:<18} {:<18} {}",1901 resource["kind"].as_str().unwrap_or("resource"),1902 resource["name"].as_str().unwrap_or(""),1903 resource["visibility"].as_str().unwrap_or(""),1904 resource["state"].as_str().unwrap_or("")1905 );1906 }1907 for resource in &local_only_services {1908 println!(1909 "service {:<18} {:<18} local_only",1910 resource["name"].as_str().unwrap_or(""),1911 resource["visibility"].as_str().unwrap_or("")1912 );1913 }1914 }1915 Ok(())1916}19171918#[allow(clippy::too_many_arguments)]1919async fn warn_on_upsert(1920 api: &ControlPlane,1921 kind: &str,1922 name: &str,1923 visibility: Visibility,1924 url: Option<&str>,1925 folder: Option<&str>,1926 shares: &[String],1927) -> Result<Option<UpsertWarning>> {1928 let listing: ResourceListing = api.get("/api/resources").await?;1929 let Some(existing) = listing1930 .resources1931 .into_iter()1932 .find(|resource| resource.kind == kind && resource.name == name)1933 else {1934 return Ok(None);1935 };19361937 let desired_visibility = visibility_str(visibility);1938 let mut changes = Vec::new();1939 if existing.visibility != desired_visibility {1940 changes.push(UpsertChange {1941 field: "visibility",1942 from: existing.visibility.clone(),1943 to: desired_visibility.to_string(),1944 });1945 }19461947 let existing_shares = existing1948 .shares1949 .iter()1950 .map(|share| share.handle.to_ascii_lowercase())1951 .collect::<std::collections::BTreeSet<_>>();1952 let desired_shares = shares1953 .iter()1954 .map(|handle| handle.to_ascii_lowercase())1955 .collect::<std::collections::BTreeSet<_>>();1956 if existing_shares != desired_shares {1957 let before = existing1958 .shares1959 .iter()1960 .map(|share| share.handle.clone())1961 .collect::<Vec<_>>();1962 changes.push(UpsertChange {1963 field: "recipients",1964 from: if before.is_empty() {1965 "none".to_string()1966 } else {1967 display_handles(&before)1968 },1969 to: if shares.is_empty() {1970 "none".to_string()1971 } else {1972 display_handles(shares)1973 },1974 });1975 }19761977 let mut requires_fresh_approval = false;1978 if existing.url.as_deref() != url {1979 changes.push(UpsertChange {1980 field: "destination",1981 from: existing.url.as_deref().unwrap_or("none").to_string(),1982 to: url.unwrap_or("none").to_string(),1983 });1984 if existing.visibility == "shared" || desired_visibility == "shared" {1985 requires_fresh_approval = true;1986 }1987 }1988 if existing.folder.as_deref() != folder {1989 changes.push(UpsertChange {1990 field: "folder",1991 from: existing.folder.as_deref().unwrap_or("none").to_string(),1992 to: folder.unwrap_or("none").to_string(),1993 });1994 }1995 Ok(Some(UpsertWarning {1996 kind: kind.to_string(),1997 name: name.to_string(),1998 changes,1999 requires_fresh_approval,2000 }))2001}20022003#[derive(Serialize)]2004struct UpsertWarning {2005 kind: String,2006 name: String,2007 changes: Vec<UpsertChange>,2008 requires_fresh_approval: bool,2009}20102011#[derive(Serialize)]2012struct UpsertChange {2013 field: &'static str,2014 from: String,2015 to: String,2016}20172018impl UpsertWarning {2019 fn print_human(&self) {2020 eprintln!("warning: updating existing {} `{}`", self.kind, self.name);2021 if self.changes.is_empty() {2022 eprintln!(" no visibility, recipient, destination, or folder changes");2023 } else {2024 for change in &self.changes {2025 eprintln!(" {}: {} → {}", change.field, change.from, change.to);2026 }2027 }2028 if self.requires_fresh_approval {2029 eprintln!(" recipients must approve the new destination again");2030 }2031 }2032}20332034fn visibility_str(visibility: Visibility) -> &'static str {2035 match visibility {2036 Visibility::Public => "public",2037 Visibility::Private => "private",2038 Visibility::Shared => "shared",2039 }2040}20412042fn push_doctor_check(2043 checks: &mut Vec<serde_json::Value>,2044 counts: &mut [u64; 4],2045 id: &str,2046 status: &str,2047 message: impl Into<String>,2048) {2049 let index = match status {2050 "pass" => 0,2051 "warning" => 1,2052 "failure" => 2,2053 _ => 3,2054 };2055 counts[index] += 1;2056 checks.push(serde_json::json!({2057 "id": id,2058 "status": status,2059 "message": message.into(),2060 }));2061}20622063#[cfg(unix)]2064fn private_file_permissions(path: &std::path::Path) -> Result<bool> {2065 use std::os::unix::fs::MetadataExt;20662067 Ok(std::fs::metadata(path)?.mode() & 0o077 == 0)2068}20692070#[cfg(not(unix))]2071fn private_file_permissions(_path: &std::path::Path) -> Result<bool> {2072 Ok(true)2073}20742075async fn doctor(paths: &Paths, output: Output) -> Result<()> {2076 let mut checks = Vec::new();2077 let mut actions = Vec::new();2078 let mut counts = [0_u64; 4];20792080 let config_exists = paths.config().exists();2081 let config = match paths.load_config() {2082 Ok(config) => {2083 push_doctor_check(2084 &mut checks,2085 &mut counts,2086 "config.readable",2087 if config_exists { "pass" } else { "skipped" },2088 if config_exists {2089 format!("Loaded {}.", paths.config().display())2090 } else {2091 "No config file exists yet.".to_string()2092 },2093 );2094 config2095 }2096 Err(err) => {2097 push_doctor_check(2098 &mut checks,2099 &mut counts,2100 "config.readable",2101 "failure",2102 format!("{err:#}"),2103 );2104 DaemonConfig::default()2105 }2106 };2107 if paths.config().exists() {2108 match private_file_permissions(&paths.config()) {2109 Ok(true) => push_doctor_check(2110 &mut checks,2111 &mut counts,2112 "config.permissions",2113 "pass",2114 "The credential-bearing config is private.",2115 ),2116 Ok(false) => push_doctor_check(2117 &mut checks,2118 &mut counts,2119 "config.permissions",2120 "failure",2121 "The credential-bearing config is readable by another user.",2122 ),2123 Err(err) => push_doctor_check(2124 &mut checks,2125 &mut counts,2126 "config.permissions",2127 "failure",2128 format!("{err:#}"),2129 ),2130 }2131 } else {2132 push_doctor_check(2133 &mut checks,2134 &mut counts,2135 "config.permissions",2136 "skipped",2137 "No config file exists yet.",2138 );2139 }21402141 let identity = match std::fs::read(paths.identity()) {2142 Ok(raw) => {2143 let parsed = raw2144 .as_slice()2145 .try_into()2146 .map(iroh::SecretKey::from_bytes)2147 .map_err(|_| anyhow::anyhow!("endpoint key is not 32 bytes"));2148 match parsed {2149 Ok(key) => {2150 push_doctor_check(2151 &mut checks,2152 &mut counts,2153 "identity.private_key",2154 "pass",2155 "The endpoint key is readable and valid.",2156 );2157 Some(key)2158 }2159 Err(err) => {2160 push_doctor_check(2161 &mut checks,2162 &mut counts,2163 "identity.private_key",2164 "failure",2165 err.to_string(),2166 );2167 None2168 }2169 }2170 }2171 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {2172 let legacy = paths.root.join("identity.key");2173 let (status, message) = if legacy.exists() {2174 (2175 "failure",2176 "Version 0.6.0 does not read identity.key or identity.pub. Start version 0.5.1 once to move these files.",2177 )2178 } else if config.machine_credential.is_some() {2179 ("failure", "No endpoint identity exists yet.")2180 } else {2181 ("skipped", "No endpoint identity exists yet.")2182 };2183 push_doctor_check(2184 &mut checks,2185 &mut counts,2186 "identity.private_key",2187 status,2188 message,2189 );2190 None2191 }2192 Err(err) => {2193 push_doctor_check(2194 &mut checks,2195 &mut counts,2196 "identity.private_key",2197 "failure",2198 format!("{err:#}"),2199 );2200 None2201 }2202 };2203 if paths.identity().exists() {2204 match private_file_permissions(&paths.identity()) {2205 Ok(true) => push_doctor_check(2206 &mut checks,2207 &mut counts,2208 "identity.permissions",2209 "pass",2210 "The endpoint key is private.",2211 ),2212 Ok(false) => push_doctor_check(2213 &mut checks,2214 &mut counts,2215 "identity.permissions",2216 "failure",2217 "The endpoint key is readable by another user.",2218 ),2219 Err(err) => push_doctor_check(2220 &mut checks,2221 &mut counts,2222 "identity.permissions",2223 "failure",2224 format!("{err:#}"),2225 ),2226 }2227 }22282229 let endpoint_id = identity.as_ref().map(|key| key.public().to_string());2230 match (&endpoint_id, std::fs::read_to_string(paths.identity_public())) {2231 (Some(expected), Ok(actual)) if actual.trim() == expected => push_doctor_check(2232 &mut checks,2233 &mut counts,2234 "identity.public_key",2235 "pass",2236 "devsite-endpoint.pub matches the endpoint key.",2237 ),2238 (Some(_), Ok(_)) => push_doctor_check(2239 &mut checks,2240 &mut counts,2241 "identity.public_key",2242 "warning",2243 "devsite-endpoint.pub does not match the endpoint key; login or daemon startup will rewrite it.",2244 ),2245 (Some(_), Err(_)) => push_doctor_check(2246 &mut checks,2247 &mut counts,2248 "identity.public_key",2249 "warning",2250 "devsite-endpoint.pub is missing; login or daemon startup will create it.",2251 ),2252 (None, _) => push_doctor_check(2253 &mut checks,2254 &mut counts,2255 "identity.public_key",2256 "skipped",2257 "No valid endpoint key is available for comparison.",2258 ),2259 }22602261 let daemon_running = paths.daemon_running()?;2262 let daemon_status = if daemon_running || config.resources.is_empty() {2263 "pass"2264 } else {2265 "warning"2266 };2267 push_doctor_check(2268 &mut checks,2269 &mut counts,2270 "daemon.local",2271 daemon_status,2272 if daemon_running {2273 "The local daemon is running.".to_string()2274 } else if config.resources.is_empty() {2275 "The local daemon is stopped and no services are configured.".to_string()2276 } else {2277 format!(2278 "The local daemon is stopped while {} service(s) are configured.",2279 config.resources.len()2280 )2281 },2282 );2283 if !daemon_running && !config.resources.is_empty() {2284 actions.push(serde_json::json!({2285 "id": "start-daemon",2286 "priority": 1,2287 "reason": "configured_services_not_served",2288 "commands": daemon_start_commands(),2289 "mutates": "local_process",2290 "requires_user_input": false,2291 }));2292 }22932294 let Some(server) = config.server_url.as_deref() else {2295 push_doctor_check(2296 &mut checks,2297 &mut counts,2298 "server.reachable",2299 "skipped",2300 "No control plane is configured.",2301 );2302 actions.push(serde_json::json!({2303 "id": "login",2304 "priority": 1,2305 "reason": "not_enrolled",2306 "commands": [["devsite", "login", "TICKET"]],2307 "mutates": "local_and_remote_identity",2308 "requires_user_input": true,2309 }));2310 return write_doctor_report(output, checks, counts, actions);2311 };2312 let public = ControlPlane::new(server, None);2313 let public_config: PublicConfig = match public.get::<PublicConfig>("/api/config").await {2314 Ok(config) => {2315 push_doctor_check(2316 &mut checks,2317 &mut counts,2318 "server.reachable",2319 "pass",2320 format!("Reached {server} (server {}).", config.server_version),2321 );2322 config2323 }2324 Err(err) => {2325 push_doctor_check(2326 &mut checks,2327 &mut counts,2328 "server.reachable",2329 "failure",2330 format!("{err:#}"),2331 );2332 return write_doctor_report(output, checks, counts, actions);2333 }2334 };2335 push_doctor_check(2336 &mut checks,2337 &mut counts,2338 "server.api_compatible",2339 if public_config.api_version == 3 {2340 "pass"2341 } else {2342 "failure"2343 },2344 format!(2345 "Server API version is {} (CLI expects 3).",2346 public_config.api_version2347 ),2348 );2349 let cli_compatible = validate_cli_version(2350 &public_config.minimum_cli_version,2351 env!("CARGO_PKG_VERSION"),2352 );2353 let cli_is_compatible = cli_compatible.is_ok();2354 let cli_compatibility_message = match &cli_compatible {2355 Ok(()) => format!(2356 "CLI {} meets the server minimum {}.",2357 env!("CARGO_PKG_VERSION"),2358 public_config.minimum_cli_version2359 ),2360 Err(err) => err.to_string(),2361 };2362 push_doctor_check(2363 &mut checks,2364 &mut counts,2365 "server.cli_compatible",2366 if cli_is_compatible { "pass" } else { "failure" },2367 cli_compatibility_message,2368 );2369 if !cli_is_compatible {2370 actions.push(serde_json::json!({2371 "id": "upgrade-cli",2372 "priority": 1,2373 "reason": "cli_below_server_minimum",2374 "commands": upgrade_commands(),2375 "mutates": "installed_binary",2376 "requires_user_input": false,2377 }));2378 }2379 let expected_protocol = String::from_utf8_lossy(devsite_proto::ALPN);2380 push_doctor_check(2381 &mut checks,2382 &mut counts,2383 "server.daemon_protocol",2384 if public_config.daemon_protocol == expected_protocol {2385 "pass"2386 } else {2387 "failure"2388 },2389 format!(2390 "Server daemon protocol is {}.",2391 public_config.daemon_protocol2392 ),2393 );23942395 match public.get::<PubKeyResponse>("/api/pubkey").await {2396 Ok(remote) => {2397 let matches = config.control_plane_key.as_deref() == Some(remote.public_key.as_str());2398 push_doctor_check(2399 &mut checks,2400 &mut counts,2401 "server.signing_key_matches",2402 if matches { "pass" } else { "failure" },2403 if matches {2404 "The server signing key matches the key pinned at login."2405 } else {2406 "The server signing key differs from the key pinned at login; verify the control-plane origin before logging in again."2407 },2408 );2409 if !matches {2410 actions.push(serde_json::json!({2411 "id": "verify-signing-key-change",2412 "priority": 1,2413 "reason": "server_signing_key_changed",2414 "commands": [["devsite", "login", "TICKET"]],2415 "mutates": "pinned_trust_and_machine_credential",2416 "requires_user_input": true,2417 }));2418 }2419 }2420 Err(err) => push_doctor_check(2421 &mut checks,2422 &mut counts,2423 "server.signing_key_matches",2424 "failure",2425 format!("{err:#}"),2426 ),2427 }2428 match config.verifying_key() {2429 Ok(_) => push_doctor_check(2430 &mut checks,2431 &mut counts,2432 "config.signing_key",2433 "pass",2434 "The pinned signing key is valid Ed25519 material.",2435 ),2436 Err(err) => push_doctor_check(2437 &mut checks,2438 &mut counts,2439 "config.signing_key",2440 "failure",2441 format!("{err:#}"),2442 ),2443 }24442445 let Some(credential) = config.machine_credential.clone() else {2446 push_doctor_check(2447 &mut checks,2448 &mut counts,2449 "auth.credential_valid",2450 "failure",2451 "No machine credential is configured.",2452 );2453 actions.push(serde_json::json!({2454 "id": "login",2455 "priority": 1,2456 "reason": "machine_credential_missing",2457 "commands": [["devsite", "login", "TICKET"]],2458 "mutates": "local_and_remote_identity",2459 "requires_user_input": true,2460 }));2461 return write_doctor_report(output, checks, counts, actions);2462 };2463 let api = ControlPlane::new(server, Some(credential));2464 match api.get::<MeResponse>("/api/me").await {2465 Ok(me) => push_doctor_check(2466 &mut checks,2467 &mut counts,2468 "auth.credential_valid",2469 "pass",2470 format!(2471 "The machine credential is valid for @{}.",2472 me.handle.as_deref().unwrap_or("(no handle)")2473 ),2474 ),2475 Err(err) => {2476 push_doctor_check(2477 &mut checks,2478 &mut counts,2479 "auth.credential_valid",2480 "failure",2481 format!("{err:#}"),2482 );2483 actions.push(serde_json::json!({2484 "id": "login",2485 "priority": 1,2486 "reason": "machine_credential_invalid",2487 "commands": [["devsite", "login", "TICKET"]],2488 "mutates": "local_and_remote_identity",2489 "requires_user_input": true,2490 }));2491 return write_doctor_report(output, checks, counts, actions);2492 }2493 }24942495 match api.get::<DaemonInfo>("/api/daemon").await {2496 Ok(info) => {2497 let bound_matches =2498 endpoint_id.as_deref() == Some(info.credential_endpoint_id.as_str());2499 push_doctor_check(2500 &mut checks,2501 &mut counts,2502 "identity.credential_binding",2503 if bound_matches { "pass" } else { "failure" },2504 if bound_matches {2505 "The machine credential is bound to this endpoint identity."2506 } else {2507 "The machine credential is bound to a different endpoint identity."2508 },2509 );2510 let registered_matches_local =2511 info.registered_endpoint_id.as_deref() == endpoint_id.as_deref();2512 let registration_status = if info.registered_endpoint_id.is_none() {2513 if config.resources.is_empty() {2514 "pass"2515 } else {2516 "warning"2517 }2518 } else if registered_matches_local {2519 "pass"2520 } else if daemon_running {2521 "failure"2522 } else {2523 "warning"2524 };2525 push_doctor_check(2526 &mut checks,2527 &mut counts,2528 "daemon.registration",2529 registration_status,2530 match info.registered_endpoint_id {2531 None => "No daemon endpoint has been registered yet.".to_string(),2532 Some(ref registered) if Some(registered.as_str()) == endpoint_id.as_deref() => {2533 "The account is registered to this endpoint identity.".to_string()2534 }2535 Some(registered) if info.registration_matches_credential => format!(2536 "The account is registered to the credential endpoint ({registered}), but that is not this machine's local identity."2537 ),2538 Some(registered) => format!(2539 "The account is registered to another endpoint identity ({registered})."2540 ),2541 },2542 );2543 if bound_matches && !registered_matches_local && !config.resources.is_empty() {2544 actions.push(serde_json::json!({2545 "id": "register-this-endpoint",2546 "priority": 2,2547 "reason": "account_registered_to_another_endpoint",2548 "commands": daemon_start_commands(),2549 "mutates": "account_daemon_registration",2550 "requires_user_input": true,2551 }));2552 }2553 }2554 Err(err) => push_doctor_check(2555 &mut checks,2556 &mut counts,2557 "identity.credential_binding",2558 "failure",2559 format!("{err:#}"),2560 ),2561 }25622563 match api.get::<ResourceListing>("/api/resources").await {2564 Ok(listing) => {2565 let remote = listing2566 .resources2567 .iter()2568 .map(|resource| resource.resource_id.as_str())2569 .collect::<HashSet<_>>();2570 let local_only = config2571 .resources2572 .iter()2573 .filter(|resource| !remote.contains(resource.resource_id.to_string().as_str()))2574 .map(|resource| resource.name.clone())2575 .collect::<Vec<_>>();2576 let visibility_mismatches = config2577 .resources2578 .iter()2579 .filter_map(|local| {2580 listing2581 .resources2582 .iter()2583 .find(|remote| remote.resource_id == local.resource_id.to_string())2584 .filter(|remote| remote.visibility != visibility_str(local.visibility))2585 .map(|_| local.name.clone())2586 })2587 .collect::<Vec<_>>();2588 let reconciled = local_only.is_empty() && visibility_mismatches.is_empty();2589 push_doctor_check(2590 &mut checks,2591 &mut counts,2592 "resources.reconciled",2593 if reconciled { "pass" } else { "warning" },2594 if reconciled {2595 "Every local service mapping has a remote resource.".to_string()2596 } else {2597 format!(2598 "Local-only services: {}; visibility mismatches: {}.",2599 if local_only.is_empty() {2600 "none".to_string()2601 } else {2602 local_only.join(", ")2603 },2604 if visibility_mismatches.is_empty() {2605 "none".to_string()2606 } else {2607 visibility_mismatches.join(", ")2608 }2609 )2610 },2611 );2612 for name in local_only {2613 actions.push(serde_json::json!({2614 "id": format!("remove-local-only-{name}"),2615 "priority": 2,2616 "reason": "missing_remote_resource",2617 "commands": [["devsite", "service", "remove", name]],2618 "mutates": "local_config",2619 "requires_user_input": false,2620 }));2621 }2622 }2623 Err(err) => push_doctor_check(2624 &mut checks,2625 &mut counts,2626 "resources.reconciled",2627 "failure",2628 format!("{err:#}"),2629 ),2630 }26312632 write_doctor_report(output, checks, counts, actions)2633}26342635fn write_doctor_report(2636 output: Output,2637 checks: Vec<serde_json::Value>,2638 counts: [u64; 4],2639 actions: Vec<serde_json::Value>,2640) -> Result<()> {2641 let report = serde_json::json!({2642 "healthy": counts[2] == 0,2643 "summary": {2644 "pass": counts[0],2645 "warning": counts[1],2646 "failure": counts[2],2647 "skipped": counts[3],2648 },2649 "checks": checks,2650 "actions": actions,2651 });2652 if output.json {2653 output.success("doctor", report);2654 } else {2655 println!(2656 "doctor: {} pass, {} warning, {} failure, {} skipped",2657 counts[0], counts[1], counts[2], counts[3]2658 );2659 for check in report["checks"].as_array().unwrap_or(&Vec::new()) {2660 println!(2661 " {:<7} {:<32} {}",2662 check["status"].as_str().unwrap_or("unknown"),2663 check["id"].as_str().unwrap_or("check"),2664 check["message"].as_str().unwrap_or("")2665 );2666 }2667 }2668 Ok(())2669}26702671fn status(paths: &Paths, output: Output) -> Result<()> {2672 let config = paths.load_config()?;2673 let daemon_running = paths.daemon_running()?;2674 let endpoint_id = std::fs::read_to_string(paths.identity_public())2675 .ok()2676 .map(|value| value.trim().to_string())2677 .filter(|value| !value.is_empty());2678 if output.json {2679 let services = config2680 .resources2681 .iter()2682 .map(|resource| {2683 serde_json::json!({2684 "resource_id": resource.resource_id.to_string(),2685 "name": resource.name,2686 "visibility": visibility_str(resource.visibility),2687 "target": resource.target.to_string(),2688 })2689 })2690 .collect::<Vec<_>>();2691 output.success(2692 "status",2693 serde_json::json!({2694 "config_path": paths.config(),2695 "identity_path": paths.identity(),2696 "identity": {2697 "public_path": paths.identity_public(),2698 "endpoint_id": endpoint_id,2699 },2700 "authentication": {2701 "configured": config.machine_credential.is_some(),2702 },2703 "server": config.server_url,2704 "control_plane_key": config.control_plane_key,2705 "daemon": daemon_state(daemon_running),2706 "services": services,2707 }),2708 );2709 } else {2710 println!("config {}", paths.config().display());2711 println!("identity {}", paths.identity().display());2712 println!(2713 "server {}",2714 config.server_url.as_deref().unwrap_or("(not signed in)")2715 );2716 println!(2717 "signing key {}",2718 config2719 .control_plane_key2720 .as_deref()2721 .unwrap_or("(not pinned)")2722 );2723 println!(2724 "daemon {}",2725 if daemon_running { "running" } else { "stopped" }2726 );2727 if !daemon_running {2728 print_daemon_start_hint();2729 }27302731 if config.resources.is_empty() {2732 println!("\nno services hosted yet");2733 return Ok(());2734 }2735 println!("\nhosted services:");2736 for resource in &config.resources {2737 println!(2738 " {:<16} {:<10} {}",2739 resource.name,2740 visibility_str(resource.visibility),2741 resource.target2742 );2743 }2744 }2745 Ok(())2746}27472748fn daemon_status(paths: &Paths, output: Output) -> Result<()> {2749 let running = paths.daemon_running()?;2750 if output.json {2751 output.success("daemon.status", daemon_state(running));2752 Ok(())2753 } else {2754 print_daemon_status(paths, output)2755 }2756}27572758fn print_daemon_status(paths: &Paths, _output: Output) -> Result<()> {2759 if paths.daemon_running()? {2760 println!("daemon is running; config changes reload automatically");2761 } else {2762 println!("daemon is stopped; this service is not reachable yet");2763 print_daemon_start_hint();2764 }2765 Ok(())2766}27672768fn daemon_state(running: bool) -> serde_json::Value {2769 serde_json::json!({2770 "running": running,2771 "start_hints": if running { Vec::new() } else { daemon_start_hints().to_vec() },2772 })2773}27742775fn print_daemon_start_hint() {2776 for hint in daemon_start_hints() {2777 println!(" {hint}");2778 }2779}27802781#[cfg(target_os = "macos")]2782fn daemon_start_hints() -> &'static [&'static str] {2783 &[2784 "start it with `brew services start devsite`",2785 "or keep `devsite daemon run` alive with another service manager",2786 ]2787}27882789#[cfg(target_os = "linux")]2790fn daemon_start_hints() -> &'static [&'static str] {2791 &[2792 "start it with `systemctl --user enable --now devsite.service`",2793 "or keep `devsite daemon run` alive with another service manager",2794 ]2795}27962797#[cfg(not(any(target_os = "macos", target_os = "linux")))]2798fn daemon_start_hints() -> &'static [&'static str] {2799 &["keep `devsite daemon run` alive with your service manager"]2800}28012802#[cfg(target_os = "macos")]2803fn daemon_start_commands() -> serde_json::Value {2804 serde_json::json!([2805 ["brew", "services", "start", "devsite"],2806 ["devsite", "daemon", "run"]2807 ])2808}28092810#[cfg(target_os = "linux")]2811fn daemon_start_commands() -> serde_json::Value {2812 serde_json::json!([2813 ["systemctl", "--user", "enable", "--now", "devsite.service"],2814 ["devsite", "daemon", "run"]2815 ])2816}28172818#[cfg(not(any(target_os = "macos", target_os = "linux")))]2819fn daemon_start_commands() -> serde_json::Value {2820 serde_json::json!([["devsite", "daemon", "run"]])2821}28222823#[cfg(target_os = "macos")]2824fn upgrade_commands() -> serde_json::Value {2825 serde_json::json!([["brew", "upgrade", "devsite"]])2826}28272828#[cfg(not(target_os = "macos"))]2829fn upgrade_commands() -> serde_json::Value {2830 serde_json::json!([])2831}28322833async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {2834 let config = load_authenticated(paths)?;2835 let _daemon_lock = paths2836 .try_daemon_lock()?2837 .context("a daemon is already running for this config directory")?;2838 let server_url = config2839 .server_url2840 .clone()2841 .unwrap_or_else(|| server.to_string());2842 let token = config.machine_credential.clone();28432844 let secret_key = paths.load_or_create_identity()?;2845 let proof = endpoint_proof(&secret_key);2846 let endpoint_id = secret_key.public().to_string();28472848 // The endpoint id is the public half of the key in DEVSITE_HOME/identity, so2849 // it is the same on every run. Register it before the endpoint binds so the2850 // control plane can return its relay settings.2851 let api = ControlPlane::new(&server_url, token);2852 let relay_access: devsite_iroh::RelayAccess = api2853 .put("/api/daemon", &serde_json::json!({ "proof": proof }))2854 .await2855 .context("registering this daemon with the control plane")?;2856 let daemon = Arc::new(Daemon::bind_with_relay(secret_key, config, relay_access).await?);28572858 let relay = daemon2859 .endpoint()2860 .addr()2861 .relay_urls()2862 .next()2863 .map(|u| u.to_string())2864 .context("no relay was assigned; check network connectivity")?;28652866 if output.json {2867 output.event(2868 "daemon.run",2869 "online",2870 serde_json::json!({ "endpoint_id": endpoint_id, "relay": relay }),2871 );2872 } else {2873 println!("daemon online");2874 println!(" endpoint {endpoint_id}");2875 println!(" relay {relay}");2876 }28772878 let reload_paths = Paths {2879 root: paths.root.clone(),2880 };2881 let authorization_api = api.clone();2882 tokio::select! {2883 result = Arc::clone(&daemon).serve() => result?,2884 result = reload_config(reload_paths, Arc::clone(&daemon), output) => result?,2885 result = sync_authorizations(authorization_api, Arc::clone(&daemon)) => result?,2886 _ = tokio::signal::ctrl_c() => {2887 if output.json {2888 output.event("daemon.run", "shutdown", serde_json::json!({}));2889 } else {2890 println!("\nshutting down");2891 }2892 },2893 }2894 daemon.close().await;2895 Ok(())2896}28972898async fn sync_authorizations(api: ControlPlane, daemon: Arc<Daemon>) -> Result<()> {2899 const REFRESH: Duration = Duration::from_secs(2);2900 const STALE_LEASE: Duration = Duration::from_secs(15);29012902 let mut interval = tokio::time::interval(REFRESH);2903 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);2904 let mut last_success = Instant::now();2905 let mut failed_closed = false;29062907 loop {2908 interval.tick().await;2909 match api2910 .get::<AuthorizationListing>("/api/daemon/authorizations")2911 .await2912 {2913 Ok(listing) => {2914 let allowed = listing2915 .authorizations2916 .into_iter()2917 .map(|entry| {2918 Ok((2919 entry.viewer_id.parse::<AccountId>()?,2920 entry.resource_id.parse::<ResourceId>()?,2921 ))2922 })2923 .collect::<Result<HashSet<_>>>()?;2924 let revoked = daemon.replace_authorizations(allowed).await;2925 if revoked > 0 {2926 tracing::info!(revoked, "closed streams whose authorization was revoked");2927 }2928 last_success = Instant::now();2929 failed_closed = false;2930 }2931 Err(err) => {2932 tracing::warn!("could not refresh active-stream authorizations: {err:#}");2933 if last_success.elapsed() >= STALE_LEASE {2934 let revoked = daemon.revoke_all_active().await;2935 if !failed_closed || revoked > 0 {2936 tracing::warn!(2937 revoked,2938 "authorization lease expired; closed active streams"2939 );2940 }2941 failed_closed = true;2942 }2943 }2944 }2945 }2946}29472948async fn reload_config(paths: Paths, daemon: Arc<Daemon>, output: Output) -> Result<()> {2949 let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));2950 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);2951 let mut last_error = None;2952 // The initial config was loaded immediately before the daemon started; the2953 // first useful tick is the next one.2954 interval.tick().await;2955 loop {2956 interval.tick().await;2957 match paths.load_config() {2958 Ok(config) => {2959 last_error = None;2960 if daemon.replace_resources(config.resources).await {2961 if output.json {2962 output.event("daemon.run", "reloaded", serde_json::json!({}));2963 } else {2964 println!("reloaded hosted services");2965 }2966 }2967 }2968 Err(err) => {2969 let message = format!("{err:#}");2970 if last_error.as_deref() != Some(message.as_str()) {2971 tracing::warn!("could not reload daemon config: {message}");2972 last_error = Some(message);2973 }2974 }2975 }2976 }2977}29782979#[cfg(test)]2980mod tests {2981 use super::validate_cli_version;29822983 #[test]2984 fn cli_version_validation_accepts_equal_or_newer_versions() {2985 assert!(validate_cli_version("0.3.0", "0.3.0").is_ok());2986 assert!(validate_cli_version("0.3.0", "0.4.0").is_ok());2987 }29882989 #[test]2990 fn cli_version_validation_rejects_older_or_invalid_versions() {2991 assert!(validate_cli_version("0.3.1", "0.3.0")2992 .unwrap_err()2993 .to_string()2994 .contains("server requires CLI 0.3.1 or newer"));2995 assert!(validate_cli_version("not-a-version", "0.3.0").is_err());2996 }2997}