hivectl: extend --password to forge; clarify matrix M_USER_IN_USE asymmetry (#663 / argus)
This commit is contained in:
parent
8571e3243a
commit
97797cf790
3 changed files with 100 additions and 13 deletions
|
|
@ -70,11 +70,32 @@ enum ForgeCmd {
|
|||
/// non-container account), creates the forgejo user and prints the
|
||||
/// freshly-minted token to stdout — no `/var/lib/hyperhive/agents/`
|
||||
/// directory is created for the user (#662).
|
||||
///
|
||||
/// Without `--password` / `--password-stdin` a random throwaway is
|
||||
/// used (fine for agents — they auth by token via tea / hive-forge).
|
||||
/// Set a password to log into the forge web UI afterwards (#663).
|
||||
/// `--password` is idempotent: re-running with the same value sets
|
||||
/// the same password (covers password resets on already-created
|
||||
/// accounts since `forgejo admin user create` silently no-ops once
|
||||
/// the user exists).
|
||||
CreateUser {
|
||||
/// Forgejo username. For agents: the container/agent name
|
||||
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
|
||||
/// For humans: any forgejo username — `mara`, `damocles`, etc.
|
||||
name: String,
|
||||
/// Set the account password to this string instead of a random
|
||||
/// throwaway. Use this for operator accounts that need to log
|
||||
/// into the forge web UI. Mutually exclusive with
|
||||
/// `--password-stdin`. WARNING: the password is visible in
|
||||
/// shell history + process listings; prefer `--password-stdin`
|
||||
/// for anything sensitive.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Read the password from stdin (single line, trailing newline
|
||||
/// stripped) instead of an inline flag. Mutually exclusive with
|
||||
/// `--password`.
|
||||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +149,11 @@ async fn main() -> Result<()> {
|
|||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Forge { cmd } => match cmd {
|
||||
ForgeCmd::CreateUser { name } => forge_create_user(&name).await,
|
||||
ForgeCmd::CreateUser {
|
||||
name,
|
||||
password,
|
||||
password_stdin,
|
||||
} => forge_create_user(&name, password.as_deref(), password_stdin).await,
|
||||
},
|
||||
Cmd::Matrix { cmd } => match cmd {
|
||||
MatrixCmd::CreateUser {
|
||||
|
|
@ -149,13 +174,23 @@ fn is_agent(name: &str) -> bool {
|
|||
Coordinator::agent_state_root(name).exists()
|
||||
}
|
||||
|
||||
async fn forge_create_user(name: &str) -> Result<()> {
|
||||
async fn forge_create_user(
|
||||
name: &str,
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<()> {
|
||||
if !hive_c0re::forge::is_present().await {
|
||||
bail!(
|
||||
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users"
|
||||
);
|
||||
}
|
||||
let user_password = resolve_password(password, password_stdin)?;
|
||||
if is_agent(name) {
|
||||
if user_password.is_some() {
|
||||
bail!(
|
||||
"forge create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token"
|
||||
);
|
||||
}
|
||||
hive_c0re::forge::ensure_user_for(name)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
|
|
@ -163,11 +198,18 @@ async fn forge_create_user(name: &str) -> Result<()> {
|
|||
println!("forge: provisioned agent user '{name}'");
|
||||
println!("token persisted at: {}", path.display());
|
||||
} else {
|
||||
let token = hive_c0re::forge::provision_user_token(name)
|
||||
let token = hive_c0re::forge::provision_user_token(name, user_password.as_deref())
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
println!("forge: provisioned user '{name}' (not an agent — token not persisted)");
|
||||
println!("token: {token}");
|
||||
if password.is_some() || password_stdin {
|
||||
println!("password: set as supplied — use it to log into the forge web UI");
|
||||
} else {
|
||||
println!(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -221,7 +263,7 @@ async fn matrix_create_user(
|
|||
// doesn't accept a password — agents auth by access_token,
|
||||
// never by password. Refuse rather than silently dropping it.
|
||||
bail!(
|
||||
"matrix create-user: --password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||
"matrix create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||
);
|
||||
}
|
||||
hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token)
|
||||
|
|
|
|||
|
|
@ -195,12 +195,19 @@ async fn forge_http(
|
|||
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||
/// returns a "user already exists" error which we treat as success.
|
||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
||||
/// `core` user that drives the API.
|
||||
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
|
||||
let mut args = vec!["user", "create", "--username", name, "--email"];
|
||||
/// `core` user that drives the API. `password` picks the initial
|
||||
/// account password: `None` uses `--random-password` (the existing
|
||||
/// agent provisioning shape — the password is never read, agents auth
|
||||
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
|
||||
/// in `hivectl` can set a real password for matrix-style web-UI login
|
||||
/// (#663).
|
||||
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
|
||||
let email = agent_email(name);
|
||||
args.push(&email);
|
||||
args.extend(["--random-password", "--must-change-password=false"]);
|
||||
let mut args = vec!["user", "create", "--username", name, "--email", &email];
|
||||
match password {
|
||||
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
|
||||
None => args.extend(["--random-password", "--must-change-password=false"]),
|
||||
}
|
||||
if admin {
|
||||
args.push("--admin");
|
||||
}
|
||||
|
|
@ -226,6 +233,23 @@ async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Set the forgejo password for an existing user. Used by the operator
|
||||
/// path in `hivectl forge create-user --password` so re-running on an
|
||||
/// already-created account still updates the password (covers the
|
||||
/// "I forgot the password I set last week" case + the "argus retried
|
||||
/// the verb to verify the fix" case — `forgejo admin user create`
|
||||
/// silently skips a password change once the account exists). Idempotent
|
||||
/// from the operator's point of view: same password input → same final
|
||||
/// account state.
|
||||
async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
||||
let args = ["user", "change-password", "--username", name, "--password", password];
|
||||
forge_admin(&args)
|
||||
.await
|
||||
.with_context(|| format!("forgejo admin user change-password {name}"))?;
|
||||
tracing::info!(%name, "forge: changed user password");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotently align the Forgejo account email to `agent_email(name)`.
|
||||
/// Existing agents were created with `{name}@hive.local`; this corrects
|
||||
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
|
||||
|
|
@ -322,7 +346,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
|
|||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_user_exists(name, false).await?;
|
||||
ensure_user_exists(name, false, None).await?;
|
||||
ensure_user_email(name).await;
|
||||
mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await
|
||||
}
|
||||
|
|
@ -333,13 +357,28 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
|
|||
/// forge create-user` for human (non-agent) accounts so we don't create
|
||||
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
|
||||
/// aren't agents (#662).
|
||||
pub async fn provision_user_token(name: &str) -> Result<String> {
|
||||
///
|
||||
/// `password` picks the account password. `None` keeps the existing
|
||||
/// random-throwaway shape (caller doesn't need web UI access — token
|
||||
/// alone is enough). `Some(pw)` sets `pw` as the password, including
|
||||
/// running `forgejo admin user change-password` if the account already
|
||||
/// exists, so the operator can log into the forge web UI afterwards
|
||||
/// (#663). Idempotent: re-running with the same `Some(pw)` lands on
|
||||
/// the same final state.
|
||||
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
|
||||
if !is_present().await {
|
||||
anyhow::bail!(
|
||||
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users"
|
||||
);
|
||||
}
|
||||
ensure_user_exists(name, false).await?;
|
||||
ensure_user_exists(name, false, password).await?;
|
||||
if let Some(pw) = password {
|
||||
// `user create` silently no-ops on an existing account, so
|
||||
// we run change-password unconditionally when the caller
|
||||
// asked for a specific password — keeps the verb idempotent
|
||||
// for "set or reset" use.
|
||||
change_user_password(name, pw).await?;
|
||||
}
|
||||
ensure_user_email(name).await;
|
||||
mint_token(name, TOKEN_SCOPES).await
|
||||
}
|
||||
|
|
@ -421,7 +460,7 @@ async fn ensure_core_user_and_token() -> Result<String> {
|
|||
return Ok(trimmed);
|
||||
}
|
||||
}
|
||||
ensure_user_exists("core", true).await?;
|
||||
ensure_user_exists("core", true, None).await?;
|
||||
mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
||||
|
|
|
|||
|
|
@ -289,6 +289,12 @@ pub async fn ensure_user_for(
|
|||
/// web clients afterwards (#663); for headless agent re-provisioning
|
||||
/// the caller can pass [`random_password`] to keep the existing
|
||||
/// throwaway behaviour.
|
||||
///
|
||||
/// **Not idempotent** (unlike [`forge::provision_user_token`]): the
|
||||
/// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400)
|
||||
/// on second call for the same localpart. Callers re-running this for
|
||||
/// a known-existing matrix user should expect a hard error from this
|
||||
/// fn and route to a password-reset path instead.
|
||||
pub async fn provision_user_token(
|
||||
client: &reqwest::Client,
|
||||
name: &str,
|
||||
|
|
|
|||
Loading…
Reference in a new issue