use crate::cmd::GlobalOpts;
use crate::cmd::Client;
use crate::config::{Config, ContextEntry, resolve_endpoint};
use crate::output::{self, Table};
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use std::time::Duration;
#[derive(Args, Debug)]
pub struct LoginArgs {
#[arg(long, value_name = "URL")]
pub server: Option<String>,
#[arg(long, value_name = "NAME")]
pub user: Option<String>,
#[arg(long, value_name = "PASSWORD")]
pub password: Option<String>,
#[arg(long, conflicts_with = "password")]
pub password_stdin: bool,
#[arg(long, value_name = "CMD", conflicts_with_all = ["password", "password_stdin"])]
pub password_command: Option<String>,
#[arg(long)]
pub no_store_password: bool,
#[arg(long, value_name = "TOKEN", conflicts_with_all = ["password", "password_stdin", "password_command", "user"])]
pub token: Option<String>,
#[arg(long, conflicts_with_all = ["token", "password", "password_stdin", "password_command", "user"])]
pub token_stdin: bool,
#[arg(long, value_name = "CMD", conflicts_with_all = ["token", "token_stdin", "password", "password_stdin", "password_command", "user"])]
pub token_command: Option<String>,
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
#[arg(long)]
pub insecure_skip_tls_verify: bool,
#[arg(long)]
pub no_switch: bool,
}
pub fn login(globals: &GlobalOpts, args: &LoginArgs) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let mut config = Config::load(&path)?;
let server = args
.server
.clone()
.or_else(|| globals.server.clone())
.unwrap_or_else(|| crate::cmd::DEFAULT_SERVER.to_string());
let user = args
.user
.clone()
.or_else(|| globals.user.clone())
.unwrap_or_else(|| "admin".to_string());
let insecure = args.insecure_skip_tls_verify || globals.insecure_skip_tls_verify;
let timeout = Duration::from_secs(globals.request_timeout);
let token = match (&args.token_command, &args.token, args.token_stdin) {
(Some(cmd), _, _) => Some(run_command(cmd)?),
(None, Some(t), _) => Some(t.clone()),
(None, None, true) => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
.context("reading the token from stdin")?;
Some(buf.trim_end_matches(['\n', '\r']).to_string())
}
(None, None, false) => globals.token.clone(),
};
if let Some(token) = token {
if token.is_empty() {
bail!("an empty token will not authenticate against anything");
}
let client = Client::connect(&server, None, None, Some(&token), insecure, timeout)?;
let (status, detail) = client.status_detail_of("/deployments")?;
if let Some(why) = detail.filter(|_| status == 401 || status == 403) {
bail!("{why}");
}
match status {
200 => {}
401 => bail!(
"the server did not recognise this token. Either it is not this server's \
token — one minted on a different app-lb, or copied incompletely — or a \
sign-in gate in front answered before app-lb saw it, which is what \
happens when the gate's `provider` does not list \"app-token\""
),
403 => bail!(
"the server knows this token but will not let it list deployments. Most \
often its admin scope is `none`, which is the default in the dashboard's \
mint form and reaches no admin route at all — mint one with `admin` \
(`heyctl token mint <NAME> --admin admin --all-deployments`). Otherwise \
it is scoped to particular deployments, or it is a namespace-scoped key \
that has to be used against its own namespace's door"
),
404 => bail!(
"{server} has no /deployments — for a Heyo cloud, the server is \
https://<cloud>/namespaces/<namespace>/lb"
),
code => bail!("unexpected HTTP {code} from /deployments while verifying the token"),
}
println!("Logged in to {} with a token.", client.server());
if args.no_store_password {
println!(" token not stored — set HEYCTL_TOKEN for later commands");
}
return save_context(
&mut config,
&path,
args,
ContextEntry {
server: client.server().to_string(),
token: (!args.no_store_password && args.token_command.is_none()).then(|| token.clone()),
token_command: args.token_command.clone(),
insecure_skip_tls_verify: insecure,
..Default::default()
},
);
}
let anon = Client::connect(&server, None, None, None, insecure, timeout)?;
anon.healthz()
.with_context(|| format!("cannot reach an app-lb admin API at {server}"))?;
let gates = Client::gates_of(&anon)?;
if !gates.any() {
println!(
"{server} has no authentication configured — nothing to log in with.\n\
(Set APP_LB_DASHBOARD_PASSWORD on the server to gate the dashboard and /metrics, \
and APP_LB_ADMIN_AUTH=1 to gate the deployment API too.)"
);
save_context(
&mut config,
&path,
args,
ContextEntry {
server: anon.server().to_string(),
insecure_skip_tls_verify: insecure,
..Default::default()
},
)?;
return Ok(());
}
let password = match (&args.password_command, &args.password, args.password_stdin) {
(Some(cmd), _, _) => run_command(cmd)?,
(None, Some(p), _) => p.clone(),
(None, None, true) => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
.context("reading the password from stdin")?;
buf.trim_end_matches(['\n', '\r']).to_string()
}
(None, None, false) => match globals.password.clone() {
Some(p) => p,
None => rpassword::prompt_password(format!("Password for {user}@{server}: "))
.context("reading the password")?,
},
};
if password.is_empty() {
bail!("an empty password will not authenticate against app-lb");
}
let client = Client::connect(&server, Some(&user), Some(&password), None, insecure, timeout)?;
let verify_path = if gates.crud { "/deployments" } else { "/metrics" };
match client.status_of(verify_path)? {
200 => {}
401 => bail!("the server rejected these credentials — wrong user or password"),
code => bail!("unexpected HTTP {code} from {verify_path} while verifying credentials"),
}
println!(
"Logged in to {} as {user}.\n gated: {}",
client.server(),
gate_summary(gates.view, gates.crud)
);
if args.no_store_password {
println!(" password not stored — set HEYCTL_PASSWORD for later commands");
}
save_context(
&mut config,
&path,
args,
ContextEntry {
server: client.server().to_string(),
user: Some(user.clone()),
password: (!args.no_store_password && args.password_command.is_none())
.then(|| password.clone()),
password_command: args.password_command.clone(),
insecure_skip_tls_verify: insecure,
..Default::default()
},
)
}
fn gate_summary(metrics: bool, crud: bool) -> String {
match (metrics, crud) {
(true, true) => "dashboard, /metrics and the deployment API".into(),
(true, false) => "dashboard and /metrics (the deployment API is open)".into(),
(false, true) => "the deployment API".into(),
(false, false) => "nothing".into(),
}
}
fn save_context(
config: &mut Config,
path: &std::path::Path,
args: &LoginArgs,
entry: ContextEntry,
) -> Result<()> {
let name = args
.name
.clone()
.unwrap_or_else(|| context_name_for(&entry.server));
config.contexts.insert(name.clone(), entry);
if !args.no_switch {
config.current_context = Some(name.clone());
}
config.save(path)?;
println!("Context {name:?} saved to {}.", path.display());
Ok(())
}
fn context_name_for(server: &str) -> String {
let host = server
.split("://")
.nth(1)
.unwrap_or(server)
.split('/')
.next()
.unwrap_or(server);
let bare = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
match bare {
"127.0.0.1" | "localhost" | "::1" | "[::1]" => "local".to_string(),
other if other.is_empty() => "default".to_string(),
other => other.to_string(),
}
}
fn run_command(cmd: &str) -> Result<String> {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.with_context(|| format!("running {cmd:?}"))?;
if !out.status.success() {
bail!("{cmd:?} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8(out.stdout)
.context("the password command produced non-UTF-8 output")?
.trim_end_matches(['\n', '\r'])
.to_string())
}
#[derive(Args, Debug)]
pub struct LogoutArgs {
#[arg(value_name = "CONTEXT")]
pub name: Option<String>,
#[arg(long)]
pub keep_context: bool,
}
pub fn logout(globals: &GlobalOpts, args: &LogoutArgs) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let mut config = Config::load(&path)?;
let name = args
.name
.clone()
.or_else(|| globals.context.clone())
.or_else(|| config.current_context.clone())
.context("no context to log out of")?;
let entry = config
.contexts
.get_mut(&name)
.with_context(|| format!("no context named {name:?}"))?;
if args.keep_context {
entry.password = None;
entry.password_command = None;
entry.token = None;
entry.token_command = None;
println!("Cleared the stored credentials for context {name:?}.");
} else {
config.contexts.remove(&name);
if config.current_context.as_deref() == Some(name.as_str()) {
config.current_context = config.contexts.keys().next().cloned();
}
println!("Removed context {name:?}.");
}
config.save(&path)
}
pub fn whoami(globals: &GlobalOpts) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let config = Config::load(&path)?;
let endpoint = resolve_endpoint(
&config,
globals.context.as_deref(),
globals.server.as_deref(),
globals.user.as_deref(),
globals.password.as_deref(),
globals.token.as_deref(),
globals.insecure_skip_tls_verify,
)?;
output::section("Client");
output::field("Config file", path.display().to_string());
output::field("Context", &endpoint.name);
output::field("Server", &endpoint.server);
if endpoint.token.is_some() {
output::field("Token", endpoint.token_source.describe_token());
output::field("Sent as", "Authorization: Bearer (the user/password are not used)");
} else {
output::field("User", endpoint.user.as_deref().unwrap_or("admin (default)"));
output::field("Password", endpoint.password_source.describe());
}
if endpoint.insecure_skip_tls_verify {
output::field("TLS", "certificate verification disabled");
}
if let Ok(reg) = crate::config::resolve_registry_endpoint(
&config,
None,
None,
None,
globals.insecure_skip_tls_verify,
) {
output::section("Artifact store");
output::field("Registry", ®.name);
output::field("URL", ®.url);
output::field("API key", reg.api_key_source.describe_api_key());
let c = crate::artifact::RegistryClient::new(
®.url,
reg.api_key.as_deref(),
reg.insecure_skip_tls_verify,
Duration::from_secs(globals.request_timeout),
)?;
match c.healthz() {
Err(e) => output::field("Reachable", format!("no — {e:#}")),
Ok(()) => {
output::field("Reachable", "yes (GET /healthz)");
output::field(
"Writes",
match c.tags() {
Ok(_) => "allowed".to_string(),
Err(e) if !c.has_credentials() => {
format!("denied (no API key) — {e:#}")
}
Err(e) => format!("denied — {e:#}"),
},
);
}
}
}
let client = Client::connect(
&endpoint.server,
endpoint.user.as_deref(),
endpoint.password.as_deref(),
endpoint.token.as_deref(),
endpoint.insecure_skip_tls_verify,
Duration::from_secs(globals.request_timeout),
)?;
output::section("Server");
if let Err(e) = client.healthz() {
if endpoint.token.is_some() {
return match client.status_of("/deployments")? {
200 => {
output::field("Reachable", "yes (GET /deployments)");
output::field("Deployment API", "allowed");
Ok(())
}
code => {
output::field("Reachable", format!("yes, but GET /deployments answered HTTP {code}"));
Ok(())
}
};
}
output::field("Reachable", format!("no — {e:#}"));
return Ok(());
}
output::field("Reachable", "yes (GET /healthz)");
let gates = Client::gates_of(&client)?;
output::field("Auth required for", gate_summary(gates.view, gates.crud));
let access = |path: &str| -> String {
match client.status_of(path) {
Ok(200) => "allowed".to_string(),
Ok(401) if client.has_credentials() => "denied (credentials rejected)".to_string(),
Ok(401) => "denied (no credentials)".to_string(),
Ok(code) => format!("HTTP {code}"),
Err(e) => format!("error: {e}"),
}
};
output::field("Deployment API", access("/deployments"));
output::field("Metrics", access("/metrics"));
Ok(())
}
#[derive(Subcommand, Debug)]
pub enum ConfigCmd {
View {
#[arg(long)]
show_secrets: bool,
},
GetContexts,
CurrentContext,
UseContext {
#[arg(value_name = "NAME")]
name: String,
},
SetContext(SetContextArgs),
DeleteContext {
#[arg(value_name = "NAME")]
name: String,
},
Path,
}
#[derive(Args, Debug)]
pub struct SetContextArgs {
#[arg(value_name = "NAME")]
pub name: String,
#[arg(long, value_name = "URL")]
pub server: Option<String>,
#[arg(long, value_name = "NAME")]
pub user: Option<String>,
#[arg(long, value_name = "PASSWORD")]
pub password: Option<String>,
#[arg(long, value_name = "CMD", conflicts_with = "password")]
pub password_command: Option<String>,
#[arg(long)]
pub insecure_skip_tls_verify: bool,
#[arg(long)]
pub current: bool,
}
pub fn config(globals: &GlobalOpts, cmd: &ConfigCmd) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let mut config = Config::load(&path)?;
match cmd {
ConfigCmd::Path => println!("{}", path.display()),
ConfigCmd::View { show_secrets } => {
let mut view = serde_json::to_value(&config)?;
if !show_secrets
&& let Some(contexts) = view.get_mut("contexts").and_then(|c| c.as_object_mut())
{
for (_, entry) in contexts.iter_mut() {
if let Some(obj) = entry.as_object_mut()
&& obj.contains_key("password")
{
obj["password"] = serde_json::Value::String("<redacted>".into());
}
}
}
println!("{}", serde_json::to_string_pretty(&view)?);
}
ConfigCmd::GetContexts => {
if config.contexts.is_empty() {
println!(
"No contexts. `heyctl login` creates one; without it, commands go to {}.",
crate::cmd::DEFAULT_SERVER
);
return Ok(());
}
let mut table = Table::new(["CURRENT", "NAME", "SERVER", "USER", "PASSWORD"]);
for (name, entry) in &config.contexts {
table.row([
if config.current_context.as_deref() == Some(name.as_str()) {
"*".to_string()
} else {
String::new()
},
name.clone(),
entry.server.clone(),
entry.user.clone().unwrap_or_else(|| "—".into()),
match (&entry.password, &entry.password_command) {
(_, Some(_)) => "command".to_string(),
(Some(_), None) => "stored".to_string(),
(None, None) => "—".to_string(),
},
]);
}
table.print();
}
ConfigCmd::CurrentContext => match &config.current_context {
Some(name) => println!("{name}"),
None => bail!("no current context is set"),
},
ConfigCmd::UseContext { name } => {
if !config.contexts.contains_key(name) {
bail!("no context named {name:?} — `heyctl config get-contexts` lists them");
}
config.current_context = Some(name.clone());
config.save(&path)?;
println!("Switched to context {name:?}.");
}
ConfigCmd::SetContext(args) => {
let entry = config.contexts.entry(args.name.clone()).or_default();
if let Some(s) = &args.server {
entry.server = s.clone();
}
if let Some(u) = &args.user {
entry.user = Some(u.clone());
}
if let Some(p) = &args.password {
entry.password = Some(p.clone());
entry.password_command = None;
}
if let Some(c) = &args.password_command {
entry.password_command = Some(c.clone());
entry.password = None;
}
if args.insecure_skip_tls_verify {
entry.insecure_skip_tls_verify = true;
}
if entry.server.is_empty() {
bail!("context {:?} needs a --server", args.name);
}
if args.current {
config.current_context = Some(args.name.clone());
}
config.save(&path)?;
println!("Context {:?} saved.", args.name);
}
ConfigCmd::DeleteContext { name } => {
if config.contexts.remove(name).is_none() {
bail!("no context named {name:?}");
}
if config.current_context.as_deref() == Some(name.as_str()) {
config.current_context = config.contexts.keys().next().cloned();
}
config.save(&path)?;
println!("Removed context {name:?}.");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::context_name_for;
#[test]
fn context_names_come_from_the_host() {
assert_eq!(context_name_for("http://127.0.0.1:9090"), "local");
assert_eq!(context_name_for("http://localhost:9090"), "local");
assert_eq!(context_name_for("https://lb.example.com"), "lb.example.com");
assert_eq!(context_name_for("https://lb.example.com:443/"), "lb.example.com");
}
}