Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ into the server's config, and the CLI does the same:
| DNS for genco's own mails (genco@<panel>, signed with selector `genco-panel`) | Settings → Mail: SPF, DKIM, DMARC and PTR with their state |
| Send genco's mails through a relay as its account | Settings → Mail → Through an SMTP relay, Sender `ie@example.ch` (From and bounce address; signed by genco only as the panel's own domain) |
| Why a mail doesn't arrive | Settings → Mail → Send Test Mail or `genco mail test <to>` waits for postfix and shows its reason (deferred, bounced, a failed relay login; the command exits non-zero then); the panel also says when outgoing port 25 seems blocked (`genco setup` checks it too) |
| A server without mail (port 25 blocked, no relay) | Settings → Mail → No mail: deletions are confirmed in the panel with password and 2FA code, no invites, no new mail domains, reports only in the panel and webhooks |
| A server without mail (port 25 blocked, no relay) | Settings → Mail → No mail: deletions are confirmed in the panel with the 2FA code (the password without 2FA), no invites, no new mail domains, reports only in the panel and webhooks |
| genco sends mail but hosts none | Settings → Mail → uncheck Mail hosting: no new mail domains, mailboxes, forwards or send-only addresses (panel, CLI and agent refuse them); genco's own mails (confirmations, invites, proposal links) keep going. Existing mail domains stay and keep running until deleted; without any, Dovecot, Rspamd and the submission ports stay off as always. No mail implies hosting off |
| A PostgreSQL database | `genco database add <name> --group <g> --postgres`, then `genco apply` (installs PostgreSQL the first time) |
| Environment variables of an app | `genco domain env <name> --set NAME=value --secret NAME=value --unset NAME`, then `genco apply` |
Expand Down Expand Up @@ -173,15 +173,15 @@ days, an action after 24 hours). Below are the decided and expired ones
of the last 30 days. Find (Ctrl+K) finds both by number ("A12", "#12")
and text.

Confirming in the panel takes a second factor, the same for both kinds:
Confirming in the panel takes one more factor, the same for both kinds
(the session already proves the password):

- an admin with two-factor login confirms with their password and a 2FA
code (the code is used up, wrong passwords and codes count toward the
login's limits);
- an admin with two-factor login confirms with a 2FA code (the code is
used up, wrong codes count toward the login's limit);
- an admin without 2FA can only reject in the panel; the link in the mail
confirms (turn on Start → Two-Factor Login to confirm in the panel);
- with mail off (Settings → Mail) nothing is mailed, so the password
confirms, with the 2FA code when the admin has 2FA.
confirms, or the 2FA code when the admin has 2FA.

The mails stay the way to confirm from a phone: each admin gets their own
link, the real link comes first and the description (it may hold names
Expand Down Expand Up @@ -544,7 +544,8 @@ weekly and 12 monthly per group and kind). Everything is in the backup's
log. A failed copy doesn't fail the backup but is a problem of its own,
like no successful copy for more than 2 days.

Set it up in Settings → Backups (your password and 2FA code) or as root:
Set it up in Settings → Backups (your 2FA code, or password without
2FA) or as root:

```sh
# Backblaze B2: a bucket and an application key for it (read, write, delete)
Expand All @@ -558,8 +559,8 @@ genco backup remote test # connects, creates the repository if there is none
```

Where the copy goes decides where all data goes. The panel asks for your
password and 2FA code; when the agent runs `genco backup remote set` or
`off`, it only asks: it waits in Start → Approvals with a number and every
2FA code (your password without 2FA); when the agent runs `genco backup
remote set` or `off`, it only asks: it waits in Start → Approvals with a number and every
admin gets a mail to confirm it (with mail off, the agent gets a link where
an admin confirms it with their password), the same as a deletion. Root in a terminal changes it directly. Whoever does
it, every admin gets a mail (and the AI agents' webhooks a notice) when
Expand All @@ -573,8 +574,9 @@ panel shows neither.

genco makes the remote repository's own password and shows it **once**,
when you save. When an admin confirmed the agent's request, the agent
never sees it: Settings → Backups shows "Show Password" (your password
and 2FA code) to the first admin who opens it, once. Keep it outside the server with the bucket's key, in your
never sees it: Settings → Backups shows "Show Password" (your 2FA code,
or password without 2FA) to the first admin who opens it, once. Keep it
outside the server with the bucket's key, in your
password manager: if the server is lost, the remote copy is useless
without it. To take over an existing remote repository (a new server, see
[section 4](#4-moving-or-rebuilding-the-whole-server)), enter its password
Expand Down Expand Up @@ -865,7 +867,8 @@ agent may:
follows it where the two differ, and the answer to the webhook
starts with "<admin> approved it with a comment: <comment>", the
proposal's `comment` holds it. Approving in the panel takes the
admin's password and 2FA code (see [Approvals](#approvals)). Nobody decided within 7 days, the
admin's 2FA code (see [Approvals](#approvals)). Nobody decided within
7 days, the
status becomes `expired` and the webhook gets "The proposal expired
after 7 days without a decision." once.

Expand Down
62 changes: 25 additions & 37 deletions server/src/actions.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//! Deleting a domain, a database or a group doesn't happen right away: it
//! becomes a pending action ("A12") and every admin gets a mail with their
//! own link. It runs when the admin opens the link while logged in and
//! confirms, or confirms it in Start → Approvals with their password and
//! two-factor code (approvals.rs); any admin may reject it there, the
//! confirms, or confirms it in Start → Approvals with their two-factor
//! code (approvals.rs); any admin may reject it there, the
//! conversation of genco's agent that asked for it gets the reason. What
//! it deletes goes to the trash first (dump, archive), kept 30 days.
//! With mail off (Settings → Mail) the password confirms it in the panel,
//! the two-factor code too when the admin has 2FA. The agent's changes of
//! the remote backup go the same way (backup/remote.rs).
//! the two-factor code instead when the admin has 2FA. The agent's changes
//! of the remote backup go the same way (backup/remote.rs).

use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
Expand Down Expand Up @@ -434,8 +434,8 @@ fn mail_body(cfg: &Config, action: &Action, token: &str) -> String {
Confirm: {link}\n\
(open it while logged in to the panel; your own link, it works for \
24 hours)\n\n\
Or open Start → Approvals, confirming there asks for your password \
and 2FA code; you can reject it there too:\n\n{panel}\n\n\
Or open Start → Approvals, confirming there asks for your 2FA code; \
you can reject it there too:\n\n{panel}\n\n\
Nothing happens without a confirmation. If you didn't expect this, \
reject it or ignore it.\n\n\
----------------------------------------\n\n\
Expand Down Expand Up @@ -936,16 +936,6 @@ async fn get_action(
Ok(Json(find(&db.conn(), &token)?.0))
}

#[derive(Debug, Default, Deserialize)]
struct ConfirmReq {
/// the admin's, in Approvals or for an action that wasn't mailed
#[serde(default)]
password: String,
/// of the authenticator app when the admin has 2FA
#[serde(default)]
code: String,
}

fn pending(action: &Action) -> Result<()> {
if action.status != "pending" {
return Err(Error::Invalid(format!(
Expand All @@ -957,12 +947,12 @@ fn pending(action: &Action) -> Result<()> {
}

/// The link of the mail proves the mailbox of its admin, without one the
/// admin's password and code do.
/// admin's step-up does (`users::verify_step_up`).
async fn check_confirm(
db: &Db,
authed: &Authed,
token: &str,
req: &ConfirmReq,
req: &users::StepUp,
) -> Result<Action> {
let (action, link_user) = find(&db.conn(), token)?;
pending(&action)?;
Expand All @@ -974,27 +964,26 @@ async fn check_confirm(
));
}
if action.by_password {
users::verify_password(db, &authed.user, &req.password).await?;
users::verify_code(db, &authed.user, &req.code).await?;
users::verify_step_up(db, &authed.user, req).await?;
}
Ok(action)
}

/// Confirming in Approvals: the password and 2FA code, like a proposal
/// (`users::verify_approval`); the password alone when nothing was mailed.
/// Confirming in Approvals: the 2FA code, like a proposal
/// (`users::verify_approval`); without 2FA the password when nothing was
/// mailed.
/// Mail switched off later doesn't count, the mailed links stay the way
/// without 2FA.
async fn check_panel(
db: &Db,
authed: &Authed,
id: i64,
req: &ConfirmReq,
req: &users::StepUp,
) -> Result<Action> {
let action = get_one(&db.conn(), id)?;
pending(&action)?;
let mailed = !action.by_password;
users::verify_approval(db, &authed.user, &req.password, &req.code, mailed)
.await?;
users::verify_approval(db, &authed.user, req, mailed).await?;
Ok(action)
}

Expand All @@ -1010,7 +999,7 @@ async fn confirm(
State(db): State<Db>,
State(events): State<Events>,
Path(key): Path<String>,
Json(req): Json<ConfirmReq>,
Json(req): Json<users::StepUp>,
) -> Result<Json<Outcome>> {
let action = match action_id(&key) {
Some(id) => check_panel(&db, &authed, id, &req).await?,
Expand Down Expand Up @@ -1247,9 +1236,9 @@ mod tests {
let token = path.strip_prefix("/confirm/").unwrap();
assert!(find(&db.conn(), token).unwrap().0.by_password);

let none = ConfirmReq::default();
let none = users::StepUp::default();
assert!(check_confirm(&db, &authed, token, &none).await.is_err());
let right = ConfirmReq {
let right = users::StepUp {
password: "admin password".into(),
code: String::new(),
};
Expand Down Expand Up @@ -1321,8 +1310,8 @@ mod tests {
conn.last_insert_rowid()
}

fn req(password: &str, code: &str) -> ConfirmReq {
ConfirmReq {
fn req(password: &str, code: &str) -> users::StepUp {
users::StepUp {
password: password.into(),
code: code.into(),
}
Expand Down Expand Up @@ -1357,12 +1346,11 @@ mod tests {
let tried = check_panel(&db, &authed(&a), id, &password).await;
assert!(matches!(tried, Err(Error::Invalid(e)) if e == users::NO_2FA));

// with 2FA the code alone, the password doesn't help
let code = with_2fa(&db, &mut a);
for wrong in [req("admin password", ""), req("wrong", &code())] {
let tried = check_panel(&db, &authed(&a), id, &wrong).await;
assert!(tried.is_err());
}
let right = req("admin password", &code());
let wrong = req("admin password", "");
assert!(check_panel(&db, &authed(&a), id, &wrong).await.is_err());
let right = req("", &code());
let action = check_panel(&db, &authed(&a), id, &right).await.unwrap();
assert_eq!(action.id, id);
// the code is used up
Expand Down Expand Up @@ -1429,7 +1417,7 @@ mod tests {

let (action, user) = find(&db.conn(), &token).unwrap();
assert_eq!((action.id, user), (id, Some(a.id)));
let none = ConfirmReq::default();
let none = users::StepUp::default();
let other = check_confirm(&db, &authed(&b), &token, &none).await;
assert!(
matches!(other, Err(Error::Invalid(e)) if e.contains("another"))
Expand Down Expand Up @@ -1506,7 +1494,7 @@ mod tests {

// decided once, and nothing to confirm any more
assert!(rejected(&db.conn(), &events, id, &a, "").is_err());
let none = ConfirmReq::default();
let none = users::StepUp::default();
let tried = check_panel(&db, &authed(&a), id, &none).await;
assert!(tried.is_err());
}
Expand Down
2 changes: 1 addition & 1 deletion server/src/approvals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! proposals (proposals.rs, "#12") and the pending actions (actions.rs,
//! "A12": deletions, the agent's remote backup changes), newest first,
//! with the decided and expired ones of the last 30 days below. Both
//! confirm in the panel the same way: the password and the 2FA code
//! confirm in the panel the same way: the 2FA code
//! (`users::verify_approval`).

use axum::extract::State;
Expand Down
16 changes: 8 additions & 8 deletions server/src/backup/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
//! repository password are sealed with the secrets key, the API only says
//! whether they are set, and restic gets them through its environment.
//! Where the copy goes decides where all data goes: the panel asks for the
//! password and code, the agent's changes wait for an admin's confirmation
//! (actions.rs), and every change or stop is sent to all admins.
//! step-up (the 2FA code, or the password), the agent's changes wait for
//! an admin's confirmation (actions.rs), and every change or stop is sent
//! to all admins.

use std::path::{Path, PathBuf};
use std::process::Stdio;
Expand Down Expand Up @@ -946,9 +947,9 @@ async fn get_settings(
struct SaveReq {
#[serde(flatten)]
settings: Settings,
/// the admin's password and code: it decides where all data goes
/// the admin's step-up: it decides where all data goes
#[serde(flatten)]
auth: crate::users::PasswordOnly,
auth: crate::users::StepUp,
}

async fn save_settings(
Expand All @@ -957,8 +958,7 @@ async fn save_settings(
State(db): State<Db>,
Json(req): Json<SaveReq>,
) -> Result<Json<Option<String>>> {
crate::users::verify_password_and_code(&db, &authed.user, &req.auth)
.await?;
crate::users::verify_step_up(&db, &authed.user, &req.auth).await?;
let key_file = crate::secrets::key_file();
let by = &authed.user.name;
let generated = change(&cfg, &db, key_file, req.settings, by).await?;
Expand All @@ -970,9 +970,9 @@ async fn save_settings(
async fn new_password(
authed: Authed,
State(db): State<Db>,
Json(auth): Json<crate::users::PasswordOnly>,
Json(auth): Json<crate::users::StepUp>,
) -> Result<Json<String>> {
crate::users::verify_password_and_code(&db, &authed.user, &auth).await?;
crate::users::verify_step_up(&db, &authed.user, &auth).await?;
let conn = db.conn();
let sealed: Vec<u8> = get_json(&conn, NEW_PASSWORD)
.ok_or_else(|| Error::Invalid("it was shown already".into()))?;
Expand Down
21 changes: 10 additions & 11 deletions server/src/mail.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Mail goes through the local postfix, which delivers it directly or
//! relays it through an external SMTP server (Settings → Mail). The sites
//! use the same postfix for their mails. With mail off (port 25 blocked
//! and no relay) genco sends none: deletions are confirmed with the
//! password and reports only show in the panel.
//! and no relay) genco sends none: deletions are confirmed with the 2FA
//! code (the password without 2FA) and reports only show in the panel.

use axum::extract::State;
use axum::routing::{get, post};
Expand Down Expand Up @@ -453,10 +453,10 @@ struct SaveReq {
/// hosting has its own route, here it is refused instead of ignored
#[serde(default)]
hosting: Option<serde_json::Value>,
/// the admin's password and code: a relay of a stolen session would
/// the admin's step-up: a relay of a stolen session would
/// get the mails that confirm deletions
#[serde(flatten)]
auth: crate::users::PasswordOnly,
auth: crate::users::StepUp,
}

async fn save_settings(
Expand All @@ -469,8 +469,7 @@ async fn save_settings(
"hosting is switched with POST /settings/mail/hosting".into(),
));
}
crate::users::verify_password_and_code(&db, &authed.user, &req.auth)
.await?;
crate::users::verify_step_up(&db, &authed.user, &req.auth).await?;
let mut new = req.settings;
match new.mode.as_str() {
"direct" | "off" => {}
Expand Down Expand Up @@ -688,7 +687,7 @@ mod tests {
}

#[tokio::test]
async fn saving_needs_the_password_and_code() {
async fn saving_needs_the_code_with_2fa() {
let db = Db::memory();
let hash = crate::users::hash_password("admin password").unwrap();
let a = crate::users::create(&db.conn(), "m@example.ch", "A", &hash)
Expand All @@ -710,6 +709,7 @@ mod tests {
"relayHost": "evil.example.ch",
"relayPort": 587,
"password": password,
"code": "wrong",
});
save_settings(
authed(),
Expand All @@ -718,10 +718,9 @@ mod tests {
)
};

let wrong = save("wrong").await.unwrap_err().to_string();
assert!(wrong.contains("password is wrong"), "{wrong}");
let no_code = save("admin password").await.unwrap_err().to_string();
assert!(no_code.contains("two-factor"), "{no_code}");
// the password doesn't help, the code is wrong
let wrong = save("admin password").await.unwrap_err().to_string();
assert!(wrong.contains("two-factor code is wrong"), "{wrong}");
assert_ne!(settings(&db).unwrap().relay_host, "evil.example.ch");
}

Expand Down
Loading