use crate::Result;
#[derive(Debug, clap::Args)]
#[clap(
verbatim_doc_comment,
long_about = "\
Manage the pitchfork reverse proxy
The reverse proxy routes requests from stable slug-based URLs like:
https://myapp.localhost
to the daemon's actual listening port (e.g. localhost:3000).
Slugs are defined in the global config (~/.config/pitchfork/config.toml)
under [slugs]. Each slug maps to a project directory and daemon name.
Enable the proxy in your pitchfork.toml or settings:
[settings.proxy]
enable = true
Subcommands:
trust Install the proxy's TLS certificate into the system trust store
untrust Remove the proxy's TLS certificate from the system trust store
add Add a slug mapping to the global config
remove Remove a slug mapping from the global config
status Show all registered slugs and their current state"
)]
pub struct Proxy {
#[clap(subcommand)]
command: ProxyCommands,
}
#[derive(Debug, clap::Subcommand)]
enum ProxyCommands {
Trust(Trust),
Untrust(Untrust),
Status(ProxyStatus),
Add(Add),
Remove(Remove),
}
impl Proxy {
pub async fn run(&self) -> Result<()> {
match &self.command {
ProxyCommands::Trust(trust) => trust.run().await,
ProxyCommands::Untrust(untrust) => untrust.run().await,
ProxyCommands::Status(status) => status.run().await,
ProxyCommands::Add(add) => add.run().await,
ProxyCommands::Remove(remove) => remove.run().await,
}
}
}
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment)]
struct Trust {
#[clap(long)]
cert: Option<std::path::PathBuf>,
}
impl Trust {
async fn run(&self) -> Result<()> {
let cert_path = self.cert.clone().unwrap_or_else(|| {
crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
});
if crate::proxy::trust::is_ca_trusted(&cert_path) {
println!("CA certificate is already trusted.");
return Ok(());
}
crate::proxy::trust::install_cert(&cert_path)?;
println!(
"CA certificate installed: {}\n\
\n\
Browsers and tools will now trust HTTPS proxy URLs like:\n\
https://docs.pf.localhost:7777",
cert_path.display()
);
Ok(())
}
}
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment)]
struct Untrust {
#[clap(long)]
cert: Option<std::path::PathBuf>,
}
impl Untrust {
async fn run(&self) -> Result<()> {
let cert_path = self
.cert
.clone()
.unwrap_or_else(|| crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem"));
crate::proxy::trust::uninstall_cert(&cert_path)?;
println!("CA certificate removed from system trust store.");
Ok(())
}
}
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment)]
struct ProxyStatus {}
impl ProxyStatus {
async fn run(&self) -> Result<()> {
use crate::pitchfork_toml::PitchforkToml;
use crate::settings::settings;
let s = settings();
if !s.proxy.enable {
println!("Proxy: disabled");
println!();
println!("Enable with:");
println!(" PITCHFORK_PROXY_ENABLE=true pitchfork supervisor start");
println!(" # or in pitchfork.toml: [settings.proxy] / enable = true");
return Ok(());
}
let Some(effective_port) = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0) else {
println!("Proxy: enabled");
println!(
" proxy.port {} is out of valid port range (1-65535)",
s.proxy.port
);
return Ok(());
};
let scheme = if s.proxy.https { "https" } else { "http" };
let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
let tld = if lan_enabled { "local" } else { &s.proxy.tld };
println!("Proxy: enabled");
println!(" Scheme: {scheme}");
println!(" TLD: {tld}");
println!(" Port: {effective_port}");
if lan_enabled {
let lan_ip = if !s.proxy.lan_ip.is_empty() {
s.proxy.lan_ip.clone()
} else {
"auto-detect".to_string()
};
println!(" LAN: enabled (IP: {lan_ip})");
}
if s.proxy.https {
let cert = if s.proxy.tls_cert.is_empty() {
format!(
"{} (auto-generated)",
crate::env::PITCHFORK_STATE_DIR
.join("proxy")
.join("ca.pem")
.display()
)
} else {
s.proxy.tls_cert.clone()
};
println!(" TLS cert: {cert}");
let cert_path = if s.proxy.tls_cert.is_empty() {
crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
} else {
std::path::PathBuf::from(&s.proxy.tls_cert)
};
let trusted = crate::proxy::trust::is_ca_trusted(&cert_path);
println!(
" Trusted: {}",
if trusted {
"yes"
} else {
"no (run: pitchfork proxy trust)"
}
);
}
println!();
let slugs = PitchforkToml::read_global_slugs();
if slugs.is_empty() {
println!("No slugs registered.");
println!();
println!("Add a slug with:");
println!(" pitchfork proxy add <slug>");
println!(" pitchfork proxy add <slug> --dir /path/to/project --daemon <name>");
} else {
println!("Registered slugs:");
println!();
let state_file =
crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE).ok();
let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
for (slug, entry) in &slugs {
let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
let url = if effective_port == standard_port {
format!("{scheme}://{slug}.{tld}")
} else {
format!("{scheme}://{slug}.{tld}:{effective_port}")
};
let expected_ns = entry.resolve_dir().and_then(|dir| {
crate::pitchfork_toml::PitchforkToml::namespace_for_dir(&dir).ok()
});
let status_str = if let Some(sf) = &state_file {
let daemon_entry = sf.daemons.iter().find(|(id, _)| {
id.name() == daemon_name
&& match &expected_ns {
Some(ns) => id.namespace() == ns,
None => true,
}
});
if let Some((_, daemon)) = daemon_entry {
let port_str = daemon
.active_port
.or_else(|| daemon.resolved_port.first().copied())
.map(|p| format!(" (port {p})"))
.unwrap_or_default();
if daemon.status.is_running() {
format!("running{port_str}")
} else {
format!("{}", daemon.status)
}
} else {
"not started".to_string()
}
} else {
"unknown".to_string()
};
println!(" {slug}");
println!(" URL: {url}");
println!(
" Dir: {}",
entry
.resolve_dir()
.map(|d| d.display().to_string())
.unwrap_or_else(|| "(unresolved)".to_string())
);
println!(" Daemon: {daemon_name}");
println!(" Status: {status_str}");
println!();
}
}
Ok(())
}
}
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment)]
struct Add {
slug: String,
#[clap(long)]
dir: Option<std::path::PathBuf>,
#[clap(long)]
daemon: Option<String>,
#[clap(long)]
namespace: Option<String>,
}
impl Add {
async fn run(&self) -> Result<()> {
use crate::pitchfork_toml::PitchforkToml;
let slug = &self.slug;
if slug.is_empty() {
miette::bail!("Slug must be non-empty.");
}
if slug.contains('.') {
miette::bail!(
"Slug '{slug}' contains a dot ('.'). \
Slugs must not contain dots because they are used as \
DNS subdomain labels in proxy URLs (<slug>.<tld>)."
);
}
if !slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
miette::bail!(
"Slug '{slug}' contains invalid characters. \
Slugs must be alphanumeric with '-' and '_' allowed."
);
}
let dir = self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone());
let dir = dir.canonicalize().unwrap_or(dir);
let daemon = self.daemon.as_deref();
let resolved_ns = self
.namespace
.clone()
.or_else(|| crate::pitchfork_toml::PitchforkToml::namespace_for_dir(&dir).ok());
let Some(resolved_ns) = resolved_ns else {
miette::bail!(
"Cannot derive a namespace for '{}'. \
Make sure the directory contains a pitchfork.toml with a valid `namespace`, \
or provide an explicit `--namespace`.",
dir.display()
);
};
let namespaces = crate::pitchfork_toml::PitchforkToml::read_global_namespaces();
if !namespaces.contains_key(&resolved_ns) {
crate::pitchfork_toml::PitchforkToml::register_namespace(
&resolved_ns,
&dir.to_string_lossy(),
)?;
println!("Registered namespace '{resolved_ns}' at {}", dir.display());
}
let stored_daemon = if daemon == Some(slug.as_str()) {
None
} else {
daemon
};
PitchforkToml::add_slug_with_namespace(slug, Some(&resolved_ns), stored_daemon)?;
if let Ok(client) = crate::ipc::client::IpcClient::connect(false).await {
let _ = client.sync_mdns().await;
}
let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
let daemon_display = daemon.unwrap_or(slug);
println!("Added slug '{slug}' → namespace '{resolved_ns}' (daemon: {daemon_display})");
println!(" Config: {}", global_path.display());
let s = crate::settings::settings();
if s.proxy.enable {
let scheme = if s.proxy.https { "https" } else { "http" };
let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
let tld = if lan_enabled { "local" } else { &s.proxy.tld };
let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
if let Some(effective_port) = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0) {
let url = if effective_port == standard_port {
format!("{scheme}://{slug}.{tld}")
} else {
format!("{scheme}://{slug}.{tld}:{effective_port}")
};
println!(" URL: {url}");
}
}
Ok(())
}
}
#[derive(Debug, clap::Args)]
#[clap(visible_alias = "rm", verbatim_doc_comment)]
struct Remove {
slug: String,
}
impl Remove {
async fn run(&self) -> Result<()> {
use crate::pitchfork_toml::PitchforkToml;
if PitchforkToml::remove_slug(&self.slug)? {
println!("Removed slug '{}'", self.slug);
if let Ok(client) = crate::ipc::client::IpcClient::connect(false).await {
let _ = client.sync_mdns().await;
}
} else {
println!("Slug '{}' was not registered.", self.slug);
}
Ok(())
}
}