///|
/// Credential file + device-flow facet. `for_relay` derives the
/// on-disk path and the HTTP base URL from the relay WebSocket URL;
/// `acquire` reads the file or runs the device flow (writing on
/// success); `remove_stale` deletes the file after the relay has
/// rejected the token with 401/403.
struct CredentialStore {
path : String
http_base : @url.Url
}
///|
/// Build a CredentialStore for the given relay WebSocket URL. The
/// on-disk path is scoped per relay host
/// (`//credential.key`) so a dev relay and production
/// each keep their own token. `$TUN_CREDENTIAL_PATH` overrides for
/// containers / systemd LoadCredential. `:` in the host segment is
/// rewritten to `_` for Windows compatibility.
///
/// Raises if the WebSocket URL cannot be re-parsed into an HTTP base
/// (unreachable in practice: the ws URL is already valid).
fn CredentialStore::for_relay(ws_url : @url.Url) -> CredentialStore raise {
let path = match @env.get_env_var("TUN_CREDENTIAL_PATH") {
Some(p) if p != "" => p
_ => {
let host = ws_url.host().replace(old=":", new="_")
"\{@xdg.data_dir()}/\{host}/credential.key"
}
}
let http_base = @url.Url::parse(ws_url.href())
let scheme = if ws_url.protocol() == "wss:" { "https:" } else { "http:" }
http_base.set_protocol(scheme)
http_base.set_pathname("/")
{ path, http_base }
}
///|
/// Return a usable token: read from the credential file if present,
/// otherwise run the device flow and persist the result.
async fn CredentialStore::acquire(self : CredentialStore) -> String {
if read_credential_file(self.path) is Some(existing) {
@log.info("credential: loaded from \{self.path}")
return existing
}
@log.info("credential: missing at \{self.path}, starting device flow")
let token = run_device_flow(self.http_base)
write_credential_file(self.path, token)
@log.info("credential: wrote \{self.path}")
token
}
///|
/// Best-effort removal after the relay has rejected the token.
/// Missing-file and permission errors are swallowed — the caller has
/// already logged the rejection, and the next run will re-pair via
/// device flow.
async fn CredentialStore::remove_stale(self : CredentialStore) -> Unit {
@async/fs.remove(self.path) catch {
err if @async.is_being_cancelled() => raise err
_ => ()
}
}
///|
/// Read the credential file if it exists. Returns None on missing file
/// or any read error.
async fn read_credential_file(path : String) -> String? {
let exists = @async/fs.exists(path) catch {
err if @async.is_being_cancelled() => raise err
_ => false
}
if !exists {
return None
}
let data = @async/fs.read_file(path) catch {
err if @async.is_being_cancelled() => raise err
_ => return None
}
let text = data.text().trim(char_set=" \t\r\n").to_string()
if text == "" {
None
} else {
Some(text)
}
}
///|
/// Write the credential file with mode 0600. `@async/fs` doesn't
/// expose rename(2), so we write directly — the file is small and torn
/// writes would just be rejected by the relay on next connect,
/// triggering a re-pair on the next run.
async fn write_credential_file(path : String, token : String) -> Unit {
// rfind("/") instead of @path.Path::dirname() because win32 dirname
// only recognises "\", not "/".
let parent = match path.rev_find("/") {
Some(i) => path.view(start_offset=0, end_offset=i).to_string()
None => path
}
ensure_dir(parent)
@async/fs.write_file(path, @utf8.encode(token[:]), create=0o600)
chmod_credential_file(path)
}
///|
#cfg(not(platform="windows"))
async fn chmod_credential_file(path : String) -> Unit {
@async/fs.chmod(path, 0o600) catch {
err if @async.is_being_cancelled() => raise err
_ => ()
}
}
///|
#cfg(platform="windows")
fn chmod_credential_file(path : String) -> Unit {
ignore(path)
}
///|
/// Recursively ensure a directory exists. Works around a bug in
/// `@async/fs.mkdir(recursive=true)` on Windows where `EEXIST` is not
/// handled during parent-directory recursion.
async fn ensure_dir(path : String) -> Unit {
@log.info("ensure_dir: \{path}")
@async/fs.mkdir(path, permission=0o700) catch {
err if @async.is_being_cancelled() => raise err
@os_error.OSError(_) as e if e.is_EEXIST() =>
@log.info("ensure_dir: \{path} already exists")
@os_error.OSError(_) as e if e.is_ENOENT() => {
@log.info("ensure_dir: \{path} ENOENT, creating parent")
let parent = match path.rev_find("/") {
Some(i) => path.view(start_offset=0, end_offset=i).to_string()
None => path
}
if parent == path {
raise e
}
ensure_dir(parent)
@async/fs.mkdir(path, permission=0o700) catch {
err if @async.is_being_cancelled() => raise err
@os_error.OSError(_) as e if e.is_EEXIST() => ()
e => raise e
}
}
e => {
@log.error("ensure_dir: \{path} unexpected error: \{e}")
raise e
}
}
}
///|
/// POST a JSON body and parse the response as JSON. Raises on non-2xx.
async fn http_post_json(url : String, body : Json) -> Json {
let body_text = body.stringify()
let headers : Map[String, String] = { "Content-Type": "application/json" }
let (response, data) = @http.post(url, body_text, headers~)
let text = data.text()
if response.code < 200 || response.code >= 300 {
fail("HTTP \{response.code}: \{text}")
}
@json.parse(text)
}
///|
/// Device-code flow: request a pairing code, print the verification
/// URL (OSC 8 clickable if the terminal supports it), poll until
/// approved or expired. Returns the raw `tp_*` token; raises on
/// denial/expiry/network.
async fn run_device_flow(http_base : @url.Url) -> String {
let auth_url = @url.Url::parse("/device/authorization", base=http_base).to_string()
let token_url = @url.Url::parse("/device/token", base=http_base).to_string()
let hostname = @os.gethostname()
@log.info("device flow: requesting pairing code hostname=\{hostname}")
let auth_req : @proto.DeviceAuthorizationRequest = { hostname, }
let auth_resp_json = http_post_json(auth_url, auth_req.to_json()) catch {
err if @async.is_being_cancelled() => raise err
e => fail("device /authorization failed: \{e}")
}
let auth_resp : @proto.DeviceAuthorizationResponse = @json.from_json(
auth_resp_json,
) catch {
e => fail("device /authorization: malformed response: \{e}")
}
let device_code = auth_resp.device_code
let user_code = auth_resp.user_code
let verification_uri_complete = auth_resp.verification_uri_complete.unwrap_or(
auth_resp.verification_uri + "?code=" + user_code,
)
let expires_in = auth_resp.expires_in
let mut interval_ms = auth_resp.interval * 1000
if interval_ms < 1000 {
interval_ms = 1000
}
// OSC 8 hyperlink: ESC ] 8 ;; ESC \