use std::io::Write as _;
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
use std::sync::Mutex;
use anyhow::{Context as _, Result, bail};
use openmoat_audit::{NewEvent, Store};
use openmoat_core::{Action, CompiledPolicy, EvalContext, Policy, Secret};
use openmoat_proxy::{
Broker, Connection, Limits, Proxy, RULE_AUDIT, RecordError, Recorder, SystemResolver,
};
#[cfg(any(target_os = "macos", target_os = "linux"))]
use crate::cli::Format;
use crate::cli::{ProxyArgs, ProxyStatusArgs};
use crate::context;
use crate::exit::Code;
use crate::home::Home;
use crate::integrity;
use crate::render::Deferred;
use crate::secrets;
use super::service;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::service::State;
const DEFAULT_PORT: u16 = 18080;
pub fn run(args: &ProxyArgs) -> Result<Code> {
if let Some(listen) = args.listen.filter(|a| !a.ip().is_loopback()) {
bail!("--listen {listen} is not a loopback address; the proxy would serve other machines");
}
let home = installed()?;
let policy = home.load_policy()?;
let hosts_port = crate::sandbox::proxy_port(&policy);
let listen = args.listen.unwrap_or_else(|| {
SocketAddr::from((Ipv4Addr::LOCALHOST, hosts_port.unwrap_or(DEFAULT_PORT)))
});
let exit = Exit::open(&home, policy, context::eval_context(None, None)?)?;
let listener = TcpListener::bind(listen).with_context(|| format!("listening on {listen}"))?;
let addr = listener
.local_addr()
.context("reading the listening address")?;
let mut stdout = std::io::stdout().lock();
writeln!(
stdout,
"moat proxy: listening on {addr} (audit session {})",
exit.session()
)?;
if let Some(port) = hosts_port.filter(|p| *p != addr.port()) {
writeln!(
std::io::stderr(),
"moat proxy: note: the host sandboxes send traffic to port {port} (`sandbox.proxy_port`), not here"
)?;
}
for s in exit.secrets() {
writeln!(
stdout,
"moat proxy: secret `{}` goes to {} in {}; give the agent {}",
s.id,
s.host,
s.header,
s.placeholder()
)?;
}
stdout.flush()?;
drop(stdout);
exit.serve(&listener)?;
Ok(Code::Ok)
}
pub fn install() -> Result<Code> {
if !crate::terminal::interactive() {
bail!(
"`moat proxy install` must be run by a person in a terminal, not from a hook \
or script: starting a user service touches the host OS"
);
}
let home = installed()?;
let binary = crate::install::hook_binary()?;
let manager = service::manager(&home)?;
let path = manager.install(&binary, home.root())?;
let mut out = Deferred::default();
writeln!(
out,
"✔ {} installed and started: {}",
service::display_name(),
path.display()
)?;
writeln!(
out,
" the proxy restarts on failure; a policy-lock exit does not loop-restart.",
)?;
writeln!(
out,
" `moat proxy status` reports its state; `moat proxy uninstall` removes it.",
)?;
out.finish()?;
Ok(Code::Ok)
}
pub fn uninstall() -> Result<Code> {
if !crate::terminal::interactive() {
bail!(
"`moat proxy uninstall` must be run by a person in a terminal, not from a hook or script"
);
}
let home = Home::locate()?;
let manager = service::manager(&home)?;
let mut out = Deferred::default();
match manager.uninstall()? {
Some(path) => writeln!(
out,
"✔ {} removed: {}",
service::display_name(),
path.display()
)?,
None => writeln!(out, "· {} was not installed", service::display_name())?,
}
out.finish()?;
Ok(Code::Ok)
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn status(args: &ProxyStatusArgs) -> Result<Code> {
let home = Home::locate()?;
let manager = service::manager(&home)?;
let state = manager.state()?;
let path = manager.file_path()?;
if args.format == Format::Json {
crate::render::json(&serde_json::json!({
"service": service::display_name(),
"path": path,
"state": state,
}))?;
return Ok(Code::Ok);
}
let mut out = Deferred::default();
writeln!(
out,
"{} {}",
service::display_name(),
state.describe(&path)
)?;
out.finish()?;
let code = match state {
State::Running | State::NotInstalled => Code::Ok,
_ => Code::Usage,
};
Ok(code)
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn restart_if_installed(home: &Home) -> Result<Option<String>> {
let manager = service::manager(home)?;
let state = manager.state()?;
match state {
State::Running | State::Stopped => {
manager.restart()?;
Ok(Some(format!("{} restarted", service::display_name())))
}
_ => Ok(None),
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn service_line(home: &Home) -> Option<(bool, String)> {
let manager = service::manager(home).ok()?;
let state = manager.state().ok()?;
let path = manager.file_path().ok()?;
match state {
State::NotInstalled => None,
State::Running => Some((true, format!("service {}", state.describe(&path)))),
_ => Some((false, format!("service {}", state.describe(&path)))),
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn status(_args: &ProxyStatusArgs) -> Result<Code> {
bail!(
"no user service on {} yet (#272); run `moat proxy` in a terminal to keep the proxy up",
std::env::consts::OS
)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn restart_if_installed(home: &Home) -> Result<Option<String>> {
service::manager(home)?;
Ok(None)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn service_line(_home: &Home) -> Option<(bool, String)> {
None
}
pub(super) fn listening_line(port: u16) -> (bool, String) {
let bound = TcpListener::bind((Ipv4Addr::LOCALHOST, port));
if matches!(bound, Err(e) if e.kind() == std::io::ErrorKind::AddrInUse) {
(true, format!("listening on 127.0.0.1:{port}"))
} else {
let text = format!(
"WARNING: nothing listens on 127.0.0.1:{port}, so sandboxed commands have no \
network; start `moat proxy` (keep it running as a user service)"
);
(false, text)
}
}
pub(super) fn installed() -> Result<Home> {
let home = Home::locate()?;
if !home.exists() {
bail!("{} does not exist; run `moat init`", home.root().display());
}
if let Some(drift) = integrity::violation(&home)? {
bail!("policy lock drift: {}", drift.reasons.join("; "));
}
Ok(home)
}
pub(super) struct Exit {
policy: Policy,
ctx: EvalContext,
broker: Broker,
recorder: AuditLog,
}
impl Exit {
pub(super) fn open(home: &Home, policy: Policy, ctx: EvalContext) -> Result<Self> {
CompiledPolicy::compile(&policy, &ctx)?;
let (broker, known) = secrets::broker(&policy.secrets, &ctx.home)?;
let audit_path = home.audit_path();
let store = Store::open_existing(&audit_path)
.with_context(|| format!("opening {}", audit_path.display()))?
.with_secrets(known);
Ok(Self {
policy,
ctx,
broker,
recorder: AuditLog {
store: Mutex::new(store),
session: format!("proxy-{}", crate::time::now_ms()),
},
})
}
pub(super) fn session(&self) -> &str {
&self.recorder.session
}
pub(super) fn secrets(&self) -> &[Secret] {
&self.policy.secrets
}
pub(super) fn serve(&self, listener: &TcpListener) -> Result<()> {
let compiled = CompiledPolicy::compile(&self.policy, &self.ctx)?;
let proxy = Proxy {
policy: &compiled,
resolver: &SystemResolver,
recorder: &self.recorder,
limits: &Limits::default(),
loopback_ok: &[],
broker: &self.broker,
};
proxy.serve(listener).context("accepting connections")
}
}
struct AuditLog {
store: Mutex<Store>,
session: String,
}
impl Recorder for AuditLog {
fn record(&self, c: &Connection<'_>) -> Result<(), RecordError> {
let action = c.destination.map(|(host, port)| {
let scheme = if c.method == Some("CONNECT") {
"connect"
} else {
"http"
};
let host = if host.contains(':') {
format!("[{host}]")
} else {
host.to_owned()
};
Action::Net {
url: format!("{scheme}://{host}:{port}"),
}
});
let event = NewEvent {
host: "proxy",
session_id: &self.session,
call_id: None,
cwd: None,
tool: c.method.unwrap_or("unknown"),
action: action.as_ref(),
decision: c.decision,
latency_us: c.latency_us,
};
let store = self
.store
.lock()
.map_err(|_| RecordError("audit writer poisoned by an earlier panic".to_owned()))?;
store
.record(&event)
.map(drop)
.map_err(|e| RecordError(e.to_string()))
}
fn unrecorded(&self, error: &RecordError) {
eprintln!("{}", unrecorded_line(error));
}
}
fn unrecorded_line(error: &RecordError) -> String {
format!("moat proxy: {RULE_AUDIT}: {error}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_failed_record_is_reported_as_before() {
let error = RecordError("disk full".to_owned());
assert_eq!(
unrecorded_line(&error),
"moat proxy: proxy-audit: audit log unavailable: disk full"
);
}
}