Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/sync/invited.rs

//! **The invite an upstream gave this downstream** (lane HOL-WGDS; direct invites since
//! HOL-PRODUCTS-7).
//!
//! The downstream's RON names its upstream with the invite it was given
//! (`sync.upstreams[].invite`). At its first start the invite is verified ONCE under that
//! upstream's key (signer, kind, this downstream, its key, not expired) and kept
//! (`holger-sync/enrolled-<up>.json`, written once, 0600); every later start reads the kept
//! copy, and another invite in its place is refused by name.
//!
//! * A **WireGuard** invite (schema v2, the link's pre-shared key in it) is for a downstream
//!   behind the upstream's front: `tunnel::start` brings the link up from it.
//! * A **direct** invite (a plain server's, schema v1, no tunnel) is for a downstream that
//!   reaches its upstream as it is (`via: Front`): its `door` is the upstream's sync door, so
//!   the RON needs no `url:` ([`door_of`]). It is bound to the downstream's SYNC key.

use std::path::{Path, PathBuf};

use holger_sync::invite::{open_invite, DownstreamInvite};

use super::*;

/// The kept invite of upstream `<name>`, under [`STATE_DIR`].
pub fn enrolled_file(dir: &Path, upstream: &str) -> PathBuf {
    dir.join(format!(
        "enrolled-{}.json",
        upstream
            .chars()
            .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            })
            .collect::<String>()
    ))
}

/// ★ **The invite this downstream runs under**: the kept one, else `invite_path` verified
/// once under `upstream`'s key and kept. Refused by name: a signer the RON does not trust, an
/// invite for another downstream or key, an expired one, a second invite that differs from
/// the kept one. With no file at `invite_path`, the kept one is the invite (RA5-20).
pub fn enrol(
    data_root: &Path,
    me: &str,
    own_key: &str,
    upstream: &SyncPeer,
    invite_path: &Path,
    now: i64,
) -> Result<DownstreamInvite, SyncFailure> {
    let given = match std::fs::read(invite_path) {
        Ok(b) => Some(b),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Err(e) => {
            return Err(SyncError::Io(format!("the invite {}: {e}", invite_path.display())).into())
        }
    };
    enrol_with(
        data_root,
        me,
        own_key,
        upstream,
        given.as_deref(),
        &invite_path.display().to_string(),
        now,
    )?
    .ok_or_else(|| {
        SyncFailure::new(
            &holger_errcode::SYNC_DOWNSTREAM_REFUSED,
            404,
            format!(
                "upstream '{}': no invite at {} and none kept: this downstream is not invited yet",
                upstream.name,
                invite_path.display()
            ),
        )
    })
}

/// ★ **The invite of `upstream`, from the bytes `given` (where they came from is `from`)**:
/// the kept one when this downstream enrolled already (`given` must then be that same
/// invite, else 409 by name), else `given` verified once and kept. `Ok(None)`: nothing given
/// and nothing kept — not invited yet.
pub fn enrol_with(
    data_root: &Path,
    me: &str,
    own_key: &str,
    upstream: &SyncPeer,
    given: Option<&[u8]>,
    from: &str,
    now: i64,
) -> Result<Option<DownstreamInvite>, SyncFailure> {
    let dir = data_root.join(STATE_DIR);
    let kept = enrolled_file(&dir, &upstream.name);
    let trusted = [upstream.trusted()];
    if let Ok(k) = std::fs::read(&kept) {
        if given.is_some_and(|g| g != k.as_slice()) {
            return Err(SyncFailure::new(
                &holger_errcode::SYNC_DOWNSTREAM_REFUSED,
                409,
                format!("this downstream enrolled with upstream '{}' once ({}); the invite {from} is another one — remove the kept invite by hand to enrol again", upstream.name, kept.display()),
            ));
        }
        // Enrolled: the signature, the names and the key still hold; the expiry was the enrolment's.
        let s = Signed::from_bytes(&k)?;
        let issued: DownstreamInvite = s.open(holger_sync::Kind::Invite, &trusted)?;
        return Ok(Some(pin_its_endpoint(open_invite(
            &k,
            &trusted,
            me,
            own_key,
            issued.issued_at,
        )?)));
    }
    let Some(given) = given else { return Ok(None) };
    let invite = open_invite(given, &trusted, me, own_key, now)?;
    std::fs::create_dir_all(&dir)
        .map_err(|e| SyncError::Io(format!("create {}: {e}", dir.display())))?;
    // 0600: an invite of schema v2 holds the link's pre-shared key (lane WG-PSK).
    use std::os::unix::fs::OpenOptionsExt as _;
    let mut f = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&kept)
        .map_err(|e| SyncError::Io(format!("keep {}: {e}", kept.display())))?;
    use std::io::Write as _;
    f.write_all(given)
        .and_then(|()| f.sync_all())
        .map_err(|e| SyncError::Io(format!("keep {}: {e}", kept.display())))?;
    Ok(Some(pin_its_endpoint(invite)))
}

/// HOL-EGRESS-WG: a WireGuard invite this downstream took (verified, given or kept) pins its
/// endpoint as the line to its upstream's door ([`crate::egress::pin_invite_endpoint`]).
fn pin_its_endpoint(invite: DownstreamInvite) -> DownstreamInvite {
    if !invite.is_direct() {
        crate::egress::pin_invite_endpoint(&invite.door, &invite.endpoint);
    }
    invite
}

/// The invite at `path` (or the kept one) opened under `upstream`'s key alone, to tell a
/// direct invite from a WireGuard one before either is verified for this downstream.
fn peek(data_root: &Path, upstream: &SyncPeer, path: &Path) -> Option<DownstreamInvite> {
    let kept = enrolled_file(&data_root.join(STATE_DIR), &upstream.name);
    let bytes = std::fs::read(&kept).or_else(|_| std::fs::read(path)).ok()?;
    let s = Signed::from_bytes(&bytes).ok()?;
    s.open(holger_sync::Kind::Invite, &[upstream.trusted()])
        .ok()
}

/// Is the invite `upstream` names (or the one this downstream kept) a direct one?
pub fn is_direct_at(data_root: &Path, upstream: &SyncPeer, path: &Path) -> bool {
    peek(data_root, upstream, path).is_some_and(|i| i.is_direct())
}

/// ★ **The start's direct invites** (HOL-PRODUCTS-7): every upstream whose `invite:` is a
/// direct one is verified for this downstream's sync key `own_key` and kept once. Nothing here
/// fails the start: a refused or missing invite is a line said by name, and the box serves on
/// locally. Returns the lines.
pub fn start_direct(
    data_root: &Path,
    me: &str,
    own_key: &str,
    upstreams: &[SyncPeer],
) -> Vec<String> {
    let mut said = Vec::new();
    for u in upstreams.iter().filter(|u| u.invite.is_some()) {
        let path = PathBuf::from(u.invite.clone().unwrap_or_default());
        let given = std::fs::read(&path).ok();
        let kept = enrolled_file(&data_root.join(STATE_DIR), &u.name);
        // A WireGuard invite is the tunnel's; an unreadable or untrusted one is refused below.
        if let Some(i) = peek(data_root, u, &path) {
            if !i.is_direct() {
                if cfg!(not(feature = "downstream-wg")) {
                    said.push(format!("sync: upstream '{}': REFUSED its invite is for a WireGuard link and this build has no tunnel (build with `downstream-wg`)", u.name));
                }
                continue;
            }
        }
        if given.is_none() && !kept.exists() {
            said.push(format!("sync: upstream '{}': its invite {} is not here yet (nor kept): ask its admin for one (`holger-cli downstream invite --name {me}`)", u.name, path.display()));
            continue;
        }
        let was_kept = kept.exists();
        match enrol_with(
            data_root,
            me,
            own_key,
            u,
            given.as_deref(),
            &format!("at {}", path.display()),
            holger_sync::now_secs(),
        ) {
            Ok(Some(i)) if i.is_direct() => said.push(format!(
                "sync: upstream '{}' invited this downstream directly: its door {} ({} invite)",
                u.name,
                i.door,
                if was_kept { "the kept" } else { "the given" }
            )),
            Ok(Some(_)) => said.push(format!(
                "sync: upstream '{}': REFUSED the invite is for a WireGuard link, not a direct one",
                u.name
            )),
            Ok(None) => said.push(format!("sync: upstream '{}': no invite", u.name)),
            Err(e) => said.push(format!(
                "sync: upstream '{}': REFUSED {}",
                u.name,
                e.tagged()
            )),
        }
    }
    said
}

/// **Where this downstream reaches `upstream`**: its `url:`, else the door of the direct
/// invite it kept (verified again under the upstream's key, for this downstream and `own_key`).
pub fn door_of(data_root: &Path, me: &str, own_key: &str, upstream: &SyncPeer) -> Option<String> {
    if let Some(u) = &upstream.url {
        return Some(u.clone());
    }
    let kept = std::fs::read(enrolled_file(&data_root.join(STATE_DIR), &upstream.name)).ok()?;
    let trusted = [upstream.trusted()];
    let s = Signed::from_bytes(&kept).ok()?;
    let issued: DownstreamInvite = s.open(holger_sync::Kind::Invite, &trusted).ok()?;
    let i = open_invite(&kept, &trusted, me, own_key, issued.issued_at).ok()?;
    i.is_direct().then_some(i.door)
}