use base64::Engine as _;
use clap::{Args, Subcommand};
use boatramp_core::cose::{TokenPublicKey, mint_overlay_dial};
use boatramp_overlay::{OverlayId, OverlaySecret};
use crate::output::Rendered;
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("overlay: {0}")]
Overlay(String),
#[error("overlay signer: {0}")]
Signer(String),
}
const TICKET_MAGIC: &str = "brfwd1";
#[derive(Args, Debug)]
pub struct OverlayArgs {
#[command(subcommand)]
command: OverlayCommand,
}
#[derive(Subcommand, Debug)]
enum OverlayCommand {
Grant {
service: String,
#[arg(long)]
relay: String,
#[arg(long)]
relay_id: String,
#[arg(long)]
egress_id: String,
#[arg(long)]
holder_pub: Option<String>,
#[arg(long, default_value_t = 3600)]
ttl_secs: u64,
#[arg(long, default_value = "boatramp.cfg")]
config: std::path::PathBuf,
},
Id,
Ls {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Resolve {
name: String,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ForwardTicket {
pub relay: String,
pub relay_id: String,
pub egress_id: String,
pub service: String,
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
}
impl ForwardTicket {
pub fn encode(&self) -> Result<String> {
let json = serde_json::to_vec(self).map_err(|e| Error::Overlay(e.to_string()))?;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
Ok(format!("{TICKET_MAGIC}.{b64}"))
}
pub fn decode(blob: &str) -> Result<Self> {
let b64 = blob
.trim()
.strip_prefix(TICKET_MAGIC)
.and_then(|r| r.strip_prefix('.'))
.ok_or_else(|| {
Error::Overlay(format!(
"not a forward ticket (expected `{TICKET_MAGIC}.…`)"
))
})?;
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64)
.map_err(|e| Error::Overlay(format!("malformed forward ticket: {e}")))?;
let t: Self = serde_json::from_slice(&json)
.map_err(|e| Error::Overlay(format!("malformed forward ticket: {e}")))?;
if t.relay.is_empty()
|| t.relay_id.is_empty()
|| t.egress_id.is_empty()
|| t.service.is_empty()
|| t.token.is_empty()
{
return Err(Error::Overlay(
"forward ticket is missing a required field".into(),
));
}
Ok(t)
}
}
#[derive(serde::Serialize)]
pub enum OverlayOutput {
Granted {
ticket: String,
client_id: String,
enroll: String,
secret_ticket: bool,
},
Identity {
id: String,
bech32: String,
},
Directory { services: Vec<ServiceEntry> },
Resolved(ServiceEntry),
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct ServiceEntry {
pub service: String,
pub relay: String,
pub relay_id: String,
pub egress_id: String,
#[serde(default)]
pub client_auth: String,
}
impl Rendered for OverlayOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Identity { id, bech32 } => {
writeln!(w, "{id}")?;
writeln!(w, "{bech32}")
}
Self::Directory { services } => {
if services.is_empty() {
return writeln!(w, "no overlay services in the directory");
}
for s in services {
writeln!(
w,
"{}\t{}\t{}\t{}",
s.service, s.relay, s.egress_id, s.client_auth
)?;
}
Ok(())
}
Self::Resolved(s) => {
writeln!(w, "service: {}", s.service)?;
writeln!(w, "relay: {} ({})", s.relay, s.relay_id)?;
writeln!(w, "egress id: {}", s.egress_id)?;
if !s.client_auth.is_empty() {
writeln!(w, "client auth: {}", s.client_auth)?;
}
Ok(())
}
Self::Granted {
ticket,
client_id,
enroll,
secret_ticket,
} => {
writeln!(w, "{ticket}")?;
writeln!(w)?;
if *secret_ticket {
writeln!(
w,
"# This ticket embeds the client's private seed — treat it as a secret."
)?;
}
writeln!(
w,
"# Redeem on the client: boatramp forward <local-addr> --ticket <ticket>"
)?;
writeln!(
w,
"# If the relay is mutual-pin, enroll this client id in [overlay] allowed_clients"
)?;
writeln!(
w,
"# and reload; a capability-only relay needs NO enrollment — the ticket works as-is."
)?;
writeln!(w, "# client id: {client_id}")?;
writeln!(w, "# allowed_clients entry (mutual-pin only): {enroll}")
}
}
}
}
fn parse_id(s: &str) -> Result<OverlayId> {
if let Ok(id) = OverlayId::from_bech32(s) {
return Ok(id);
}
OverlayId::from_hex(s).map_err(|e| Error::Overlay(format!("overlay identity `{s}`: {e}")))
}
pub async fn run(
args: OverlayArgs,
config: &crate::config::ProjectConfig,
) -> Result<OverlayOutput> {
match args.command {
OverlayCommand::Id => {
let secret = operator_identity()?;
let id = secret.id();
Ok(OverlayOutput::Identity {
id: id.to_hex(),
bech32: id.to_bech32(),
})
}
OverlayCommand::Ls { server } => {
let services = fetch_directory(server, config).await?;
Ok(OverlayOutput::Directory { services })
}
OverlayCommand::Resolve { name, server } => {
let services = fetch_directory(server, config).await?;
services
.into_iter()
.find(|s| s.service == name)
.map(OverlayOutput::Resolved)
.ok_or_else(|| {
Error::Overlay(format!(
"no overlay service `{name}` in the directory — run `boatramp overlay ls` to \
see available services"
))
})
}
OverlayCommand::Grant {
service,
relay,
relay_id,
egress_id,
holder_pub,
ttl_secs,
config,
} => {
let _ = parse_id(&relay_id)?;
let _ = parse_id(&egress_id)?;
let (holder, client_id, identity) = match &holder_pub {
Some(pub_spec) => {
let id = parse_id(pub_spec)?;
(id.to_public(), id.to_hex(), None)
}
None => {
let secret =
OverlaySecret::generate().map_err(|e| Error::Overlay(e.to_string()))?;
let id = secret.id();
(
id.to_public(),
id.to_hex(),
Some(secret.seed_hex().to_string()),
)
}
};
let token = mint_dial(&holder, &service, ttl_secs, &config).await?;
let secret_ticket = identity.is_some();
let ticket = ForwardTicket {
relay,
relay_id,
egress_id,
service,
token,
identity,
}
.encode()?;
let enroll = format!("\"{client_id}\"");
Ok(OverlayOutput::Granted {
ticket,
client_id,
enroll,
secret_ticket,
})
}
}
}
async fn mint_dial(
holder: &TokenPublicKey,
service: &str,
ttl_secs: u64,
config: &std::path::Path,
) -> Result<String> {
let signer = crate::token::resolve_signer(config)
.await
.map_err(|e| Error::Signer(e.to_string()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
mint_overlay_dial(holder, service, ttl_secs, now, &*signer)
.await
.map_err(|e| Error::Overlay(format!("minting dial capability: {e}")))
}
fn operator_key_path() -> Result<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.ok_or_else(|| {
Error::Overlay("cannot locate the config dir (set HOME or XDG_CONFIG_HOME)".into())
})?;
Ok(base.join("boatramp").join("overlay.key"))
}
pub(crate) fn operator_identity() -> Result<OverlaySecret> {
let path = operator_key_path()?;
let io = |e: std::io::Error| Error::Overlay(format!("{}: {e}", path.display()));
match std::fs::read_to_string(&path) {
Ok(seed) => {
OverlaySecret::from_seed_hex(seed.trim()).map_err(|e| Error::Overlay(e.to_string()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let secret = OverlaySecret::generate().map_err(|e| Error::Overlay(e.to_string()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(io)?;
f.write_all(secret.seed_hex().as_bytes()).map_err(io)?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, secret.seed_hex().as_bytes()).map_err(io)?;
}
eprintln!(
"boatramp overlay: generated a persistent overlay identity at {}",
path.display()
);
Ok(secret)
}
Err(e) => Err(io(e)),
}
}
#[derive(serde::Serialize)]
pub(crate) struct DialRequestBody {
pub service: String,
pub holder_pubkey: String,
}
#[derive(serde::Deserialize)]
pub(crate) struct DialResponseBody {
pub relay: String,
pub relay_id: String,
pub egress_id: String,
#[serde(default)]
pub relay_client_auth: String,
pub token: String,
}
async fn fetch_directory(
server: Option<String>,
config: &crate::config::ProjectConfig,
) -> Result<Vec<ServiceEntry>> {
let (server, http) =
crate::client::connect(server, config).map_err(|e| Error::Overlay(e.to_string()))?;
http.get(format!("{server}/api/overlay/services"))
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?
.error_for_status()
.map_err(|e| Error::Overlay(e.to_string()))?
.json()
.await
.map_err(|e| Error::Overlay(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forward_ticket_round_trips() {
let t = ForwardTicket {
relay: "10.0.0.1:9100".into(),
relay_id: "ed25519:aa".into(),
egress_id: "ed25519:bb".into(),
service: "svc/db".into(),
token: "COSEBLOB".into(),
identity: Some("ed25519:cc".into()),
};
let blob = t.encode().unwrap();
assert!(blob.starts_with("brfwd1."));
let back = ForwardTicket::decode(&blob).unwrap();
assert_eq!(back.relay, "10.0.0.1:9100");
assert_eq!(back.service, "svc/db");
assert_eq!(back.token, "COSEBLOB");
assert_eq!(back.identity.as_deref(), Some("ed25519:cc"));
}
#[test]
fn forward_ticket_rejects_garbage() {
assert!(ForwardTicket::decode("not-a-ticket").is_err());
assert!(ForwardTicket::decode("brfwd1.@@@@").is_err());
let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"relay\":\"\",\"relay_id\":\"x\",\"egress_id\":\"y\",\"service\":\"s\",\"token\":\"t\"}");
assert!(ForwardTicket::decode(&format!("brfwd1.{bad}")).is_err());
}
}