diff --git a/.github/workflows/store.yml b/.github/workflows/store.yml new file mode 100644 index 0000000..7471e19 --- /dev/null +++ b/.github/workflows/store.yml @@ -0,0 +1,117 @@ +name: store + +# Upload a release to the Chrome Web Store, and publish it. +# +# What this can and cannot do, because the line is not where you would guess: +# +# - It cannot create the listing. The Store listing and Privacy tabs have to be filled in the +# dashboard by a person, and the extension ID does not exist until they have been. +# Description, screenshots, category, the data disclosure — none of them are reachable from +# this API. The first submission is by hand and always will be. +# - After that it can do every update: upload the package and publish it. +# +# The human gate is not removed, it moves. Merging the release PR is the decision; a release is +# what triggers this. Nothing reaches anybody that was not merged first. +# +# A service account rather than a refresh token. Refresh tokens issued by an OAuth consent +# screen still in "Testing" expire in a week, which makes a release pipeline that worked in +# March fail in April for a reason nobody changed. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to upload, e.g. v0.5.0" + required: true + type: string + publish: + description: "Publish after uploading, rather than leaving it as a draft" + type: boolean + default: false + +permissions: + contents: read + +jobs: + store: + name: upload to the Chrome Web Store + runs-on: ubuntu-latest + steps: + - name: Refuse early if this has never been set up + # Before the token exchange, so a missing secret is named rather than arriving as an + # authentication error twenty lines into a log. + env: + KEY: ${{ secrets.CWS_SERVICE_ACCOUNT }} + PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }} + ITEM: ${{ secrets.CWS_EXTENSION_ID }} + run: | + missing= + [ -n "$KEY" ] || missing="$missing CWS_SERVICE_ACCOUNT" + [ -n "$PUBLISHER" ] || missing="$missing CWS_PUBLISHER_ID" + [ -n "$ITEM" ] || missing="$missing CWS_EXTENSION_ID" + if [ -n "$missing" ]; then + echo "::error::missing repository secrets:$missing — see extension/STORE.md" + exit 1 + fi + + - id: auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + credentials_json: ${{ secrets.CWS_SERVICE_ACCOUNT }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/chromewebstore + + - name: Fetch the release package + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + REPO: ${{ github.repository }} + run: | + gh release download "$TAG" --repo "$REPO" --pattern '*.zip' --dir . + # Exactly one, or the shell picks. Two zips on a release would otherwise mean + # uploading whichever sorted first. + count=$(ls -1 ./*.zip | wc -l) + test "$count" -eq 1 || { echo "::error::expected one zip, found $count"; exit 1; } + mv ./*.zip package.zip + unzip -p package.zip manifest.json | grep '"version"' + + - name: Upload + env: + TOKEN: ${{ steps.auth.outputs.access_token }} + PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }} + ITEM: ${{ secrets.CWS_EXTENSION_ID }} + run: | + # `--fail-with-body` rather than `--fail`: the store says why in the body, and a bare + # "exit 22" is the least useful half of that. + curl --silent --show-error --fail-with-body \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/zip" \ + --data-binary @package.zip \ + "https://chromewebstore.googleapis.com/upload/v2/publishers/$PUBLISHER/items/$ITEM:upload" \ + | tee upload.json + # The HTTP status can be 200 while the item state is FAILURE, so the body decides. + grep -q '"uploadState"[[:space:]]*:[[:space:]]*"SUCCESS"' upload.json + + - name: Publish + # A release publishes; a manual run does so only when asked. The button defaults to the + # cautious answer because somebody pressing it is usually testing the pipeline. + if: ${{ github.event_name == 'release' || inputs.publish }} + env: + TOKEN: ${{ steps.auth.outputs.access_token }} + PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }} + ITEM: ${{ secrets.CWS_EXTENSION_ID }} + run: | + # Visibility is whatever the dashboard already says; this API does not set it. If it + # was changed by hand, the store refuses until it has been published by hand once at + # the new visibility — so a failure here is worth reading rather than retrying. + curl --silent --show-error --fail-with-body \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Length: 0" \ + "https://chromewebstore.googleapis.com/v2/publishers/$PUBLISHER/items/$ITEM:publish" \ + | tee publish.json + # Review is not instant. This says it was accepted, not that it is live. + echo "submitted; the store reviews it before anyone sees it" diff --git a/README.md b/README.md index 4c1bb68..efb3696 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,22 @@ At runtime it needs `ssh` on your `PATH` and nothing more. Trusting the https ce command your OS already has — `certutil`, `security`, or `update-ca-certificates` — and `ssh-browser trust` prints the one for your platform. +### Starting it without thinking about it + +``` +ssh-browser autostart +``` + +Registers the daemon to start when you log in, and says what it wrote and where. `--off` takes +it back out. No administrator rights on any of the three: a file in your Startup folder on +Windows, a launchd agent in your own `LaunchAgents` on macOS, a systemd user unit on Linux. + +This is not a convenience. A bookmark that resolves only after you remember to start something +is not a bookmark, and the daemon is not a program anyone wants to run — it is what makes a URL +work. Unlike `trust`, which prints a command and executes nothing because trusting a root +changes what the whole machine believes, this one does the thing: starting a program of your +own at login is what you just asked for. + A host on its own means that account's home directory, which the daemon asks the remote for. `ssh-browser hosts` prints what your `~/.ssh/config` already knows how to reach — user, hostname, port and any `ProxyJump` — which is the list worth picking an alias from. diff --git a/crates/ssh-browser/src/autostart/mod.rs b/crates/ssh-browser/src/autostart/mod.rs new file mode 100644 index 0000000..41e1f8d --- /dev/null +++ b/crates/ssh-browser/src/autostart/mod.rs @@ -0,0 +1,474 @@ +//! Start the daemon when you log in, so nobody has to remember to. +//! +//! A reader who must run `ssh-browser serve` before their bookmarks resolve does not have a +//! product, they have a program they run. The daemon is not a thing anyone wants to think +//! about — it is what makes a URL work — so it should already be there. +//! +//! Unlike [`crate::tls`], which prints a command and executes nothing, this does the thing. +//! The difference is about consent rather than effort: trusting a certificate authority changes +//! what the whole machine believes, so it is a decision to make with your own hands. Starting a +//! program of your own at login is what was asked for, and printing a command to copy would be +//! the same failure in a politer form. +//! +//! The three platforms are a parameter rather than a `cfg!`, for the reason written out in +//! `tls::Store`: a platform-specific string only its own platform can run is a string nobody +//! tests. Here the plan is worked out as data, checked on any machine, and only [`apply`] +//! touches anything. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +/// How a platform starts something at login. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Task Scheduler, a task for this account. + Windows, + /// A launchd agent in the user's own `LaunchAgents`. + MacOs, + /// A systemd user unit. + Systemd, +} + +impl Kind { + /// The one this is running on. + pub fn here() -> Self { + if cfg!(windows) { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else { + Self::Systemd + } + } +} + +/// Everything a platform needs done, worked out without doing any of it. +/// +/// Files first and commands second, always: each of these registers a path that has to exist +/// by the time the command referring to it runs. +#[derive(Debug, PartialEq, Eq)] +pub struct Plan { + pub files: Vec<(PathBuf, String)>, + pub commands: Vec>, + /// Paths to delete, after the commands. Removal fills this; installing leaves it empty. + pub remove: Vec, + /// What to tell the reader, including how to undo it. + pub note: String, +} + +/// The reverse-DNS label launchd wants, and the name the unit goes by elsewhere. +const LABEL: &str = "com.qatlashub.ssh-browser"; + +/// The folder Windows runs the contents of at login. +/// +/// Chosen over a Task Scheduler entry, and not for simplicity. `schtasks /Create /SC ONLOGON` +/// writes to the machine's task store, so it wants administrator rights — measured, on the +/// machine this was written for, with the task never created: +/// +/// ```text +/// Error: schtasks failed: エラー: アクセスが拒否されました。 +/// ``` +/// +/// Asking somebody to open an elevated prompt in order to start a program of their own is the +/// thing this command exists to avoid. This folder needs nothing, is where Windows itself +/// documents that login programs go, and is undone by deleting a file you can see. +fn startup_dir(home: &Path) -> PathBuf { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Roaming")) + .join("Microsoft") + .join("Windows") + .join("Start Menu") + .join("Programs") + .join("Startup") +} + +/// Where the login entry lives, per platform. +/// +/// Two of the three are fixed by convention; the third is ours to choose, so it goes beside +/// everything else the daemon remembers between runs. +fn entry_path(kind: Kind, home: &Path, state: &Path) -> PathBuf { + // Kept in the signature though only the other two read it: a state directory is where the + // Windows entry lived before `schtasks` turned out to need elevation, and a caller should + // not have to know which platforms happen to want which directory today. + let _ = state; + match kind { + Kind::Windows => startup_dir(home).join("ssh-browser.vbs"), + Kind::MacOs => home + .join("Library") + .join("LaunchAgents") + .join(format!("{LABEL}.plist")), + Kind::Systemd => home + .join(".config") + .join("systemd") + .join("user") + .join("ssh-browser.service"), + } +} + +/// What installing looks like on `kind`, for a daemon at `exe`. +pub fn install_plan(kind: Kind, exe: &Path, home: &Path, state: &Path) -> Plan { + let entry = entry_path(kind, home, state); + let exe = exe.display().to_string(); + match kind { + Kind::Windows => Plan { + // A one-line script, because Task Scheduler runs a console program in a console + // window. Left visible that window sits there for the session, and the first thing + // anybody does with a window they did not ask for is close it -- which kills the + // daemon. `Run(..., 0, False)` is the documented way to start something with no + // window at all, and one line of VBScript is a thing a suspicious reader can read. + files: vec![( + entry.clone(), + format!("CreateObject(\"WScript.Shell\").Run \"\"\"{exe}\"\" serve\", 0, False\n"), + )], + // Nothing to run. Writing the file is the whole of it, which is also what makes + // installing twice the same as installing once. + commands: Vec::new(), + remove: Vec::new(), + note: format!( + "ssh-browser will start when you log in.\n\ + \x20 {}\n\n\ + Undo with `ssh-browser autostart --off`, or delete that file.\n", + entry.display() + ), + }, + Kind::MacOs => Plan { + files: vec![(entry.clone(), launch_agent(&exe))], + // `bootstrap` rather than the deprecated `load`, and into this user's own GUI + // domain, so it asks for no password. + commands: vec![vec![ + "launchctl".into(), + "bootstrap".into(), + format!("gui/{}", users_uid()), + entry.display().to_string(), + ]], + remove: Vec::new(), + note: format!( + "ssh-browser will start when you log in.\n\ + \x20 agent {}\n\n\ + Undo with `ssh-browser autostart --off`.\n", + entry.display() + ), + }, + Kind::Systemd => Plan { + files: vec![(entry.clone(), user_unit(&exe))], + commands: vec![ + vec!["systemctl".into(), "--user".into(), "daemon-reload".into()], + vec![ + "systemctl".into(), + "--user".into(), + "enable".into(), + "--now".into(), + "ssh-browser.service".into(), + ], + ], + remove: Vec::new(), + note: format!( + "ssh-browser will start when you log in.\n\ + \x20 unit {}\n\n\ + On a machine you reach over ssh rather than log into, a user unit stops when\n\ + your last session ends. `loginctl enable-linger` is what keeps it running.\n\n\ + Undo with `ssh-browser autostart --off`.\n", + entry.display() + ), + }, + } +} + +/// What removing looks like. +/// +/// A separate function rather than a flag on the first, because the commands are not the +/// install commands backwards. +pub fn remove_plan(kind: Kind, home: &Path, state: &Path) -> Plan { + let entry = entry_path(kind, home, state); + let note = "ssh-browser will no longer start when you log in. One running now keeps\n\ + running; stop it however you started it.\n" + .to_string(); + match kind { + Kind::Windows => Plan { + files: Vec::new(), + commands: Vec::new(), + remove: vec![entry], + note, + }, + Kind::MacOs => Plan { + files: Vec::new(), + commands: vec![vec![ + "launchctl".into(), + "bootout".into(), + format!("gui/{}/{LABEL}", users_uid()), + ]], + remove: vec![entry], + note, + }, + Kind::Systemd => Plan { + files: Vec::new(), + commands: vec![vec![ + "systemctl".into(), + "--user".into(), + "disable".into(), + "--now".into(), + "ssh-browser.service".into(), + ]], + remove: vec![entry], + note, + }, + } +} + +/// This account's user id, which launchd wants as part of the domain it is bootstrapped into. +/// +/// Asked of `id -u` rather than through a libc binding, because that is one dependency for one +/// number. A wrong answer fails loudly at `launchctl` rather than quietly at login. +fn users_uid() -> String { + std::process::Command::new("id") + .arg("-u") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "501".to_string()) +} + +fn launch_agent(exe: &str) -> String { + format!( + "\n\ + \n\ + \n\ + \n\ + \x20 Label\n\ + \x20 {LABEL}\n\ + \x20 ProgramArguments\n\ + \x20 \n\ + \x20 {exe}\n\ + \x20 serve\n\ + \x20 \n\ + \x20 RunAtLoad\n\ + \x20 \n\ + \x20 KeepAlive\n\ + \x20 \n\ + \n\ + \n" + ) +} + +fn user_unit(exe: &str) -> String { + format!( + "[Unit]\n\ + Description=ssh-browser, serving SSH hosts as browser origins\n\ + \n\ + [Service]\n\ + ExecStart={exe} serve\n\ + Restart=on-failure\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n" + ) +} + +/// Do it, saying what was done as it happens. +/// +/// Reported step by step rather than summarised at the end, because the step that fails is the +/// one worth naming and a summary printed afterwards never arrives. +pub fn apply(plan: &Plan) -> Result<()> { + for (path, body) in &plan.files { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("making {}", parent.display()))?; + } + std::fs::write(path, body).with_context(|| format!("writing {}", path.display()))?; + eprintln!(" wrote {}", path.display()); + } + + for command in &plan.commands { + let (program, args) = command.split_first().expect("a command has a program"); + eprintln!(" {}", command.join(" ")); + let out = std::process::Command::new(program) + .args(args) + .output() + .with_context(|| format!("running {program}"))?; + if !out.status.success() { + // Both streams: `schtasks` reports on stdout and `systemctl` on stderr, and a + // failure that prints only the empty one is a failure with no reason attached. + let said = [out.stdout, out.stderr] + .iter() + .map(|s| String::from_utf8_lossy(s).trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"); + bail!("{program} failed: {said}"); + } + } + + for path in &plan.remove { + match std::fs::remove_file(path) { + Ok(()) => eprintln!(" removed {}", path.display()), + // Already gone is the state that was wanted. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("removing {}", path.display())), + } + } + Ok(()) +} + +/// The home directory, which two of the three platforms put their login entry under. +pub fn home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .filter(|h| !h.is_empty()) + .map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dirs() -> (PathBuf, PathBuf) { + (PathBuf::from("/home/you"), PathBuf::from("/state")) + } + + const EVERY: [Kind; 3] = [Kind::Windows, Kind::MacOs, Kind::Systemd]; + + /// Every platform, on whichever one happens to be running this. The whole reason `Kind` is + /// an argument: two of the three are otherwise checked by nobody until somebody on that + /// platform tries them, which is the wrong moment to find out. + /// + /// Not "and runs a command", which is what this said until Windows stopped needing one. + /// What every platform has in common is the entry, and that it names the daemon by path. + #[test] + fn each_platform_writes_an_entry_naming_the_daemon() { + let (home, state) = dirs(); + let exe = PathBuf::from("/bin/ssh-browser"); + for kind in EVERY { + let plan = install_plan(kind, &exe, &home, &state); + assert_eq!(plan.files.len(), 1, "{kind:?}"); + assert!( + plan.remove.is_empty(), + "{kind:?} installs, it does not delete" + ); + // The daemon's own path, not a bare name: a login session's PATH is not a shell's, + // and an entry that starts whatever `ssh-browser` it finds may find none. + let (_, body) = &plan.files[0]; + assert!(body.contains("/bin/ssh-browser"), "{kind:?}: {body}"); + assert!(body.contains("serve"), "{kind:?}: {body}"); + assert!(plan.note.contains("autostart --off"), "{kind:?}"); + } + } + + /// Removing undoes exactly what installing wrote. + /// + /// Compared as paths rather than by reading both functions, because the failure this + /// prevents is silent: an uninstall that deletes a file nobody wrote leaves the login entry + /// in place and reports success. + #[test] + fn removing_touches_what_installing_wrote() { + let (home, state) = dirs(); + let exe = PathBuf::from("/bin/ssh-browser"); + for kind in EVERY { + let installed = install_plan(kind, &exe, &home, &state); + let removed = remove_plan(kind, &home, &state); + assert_eq!( + removed.remove, + vec![installed.files[0].0.clone()], + "{kind:?}" + ); + assert!(removed.files.is_empty(), "{kind:?}"); + } + } + + /// The Windows launcher hides its window, and that is load-bearing rather than tidy. + #[test] + fn the_windows_launcher_asks_for_no_window() { + let (home, state) = dirs(); + let plan = install_plan( + Kind::Windows, + &PathBuf::from("C:/bin/ssh-browser.exe"), + &home, + &state, + ); + let (path, body) = &plan.files[0]; + // The tail rather than the whole path: `APPDATA` is a real environment variable on the + // machine running this and a roaming profile moves it, so asserting the prefix would be + // asserting something about the test runner. + assert!( + path.ends_with("Start Menu/Programs/Startup/ssh-browser.vbs") + || path.ends_with(r"Start Menu\Programs\Startup\ssh-browser.vbs"), + "{path:?}" + ); + // Nothing to run at all, which is the point of this location: no elevation, and + // writing the file twice is the same as writing it once. + assert!(plan.commands.is_empty(), "{:?}", plan.commands); + assert!( + body.contains(", 0, False"), + "the window style must be hidden: {body}" + ); + // Quoted, because a path with a space in it is the ordinary case on Windows and an + // unquoted one runs the wrong program or none. + assert!(body.contains("\"\"\"C:/bin/ssh-browser.exe\"\""), "{body}"); + } + + /// The launchd agent asks to be started at login, and is a plist launchd will accept. + #[test] + fn the_launch_agent_runs_at_load() { + let (home, state) = dirs(); + let plan = install_plan( + Kind::MacOs, + &PathBuf::from("/bin/ssh-browser"), + &home, + &state, + ); + let (path, body) = &plan.files[0]; + assert!( + path.starts_with("/home/you/Library/LaunchAgents"), + "{path:?}" + ); + assert!(body.starts_with("RunAtLoad\n "), "{body}"); + assert!(body.contains(LABEL), "{body}"); + } + + /// The systemd unit is wanted by the user's default target, which is what starts it. + #[test] + fn the_user_unit_is_wanted_by_default_target() { + let (home, state) = dirs(); + let plan = install_plan( + Kind::Systemd, + &PathBuf::from("/bin/ssh-browser"), + &home, + &state, + ); + let (path, body) = &plan.files[0]; + assert!( + path.starts_with("/home/you/.config/systemd/user"), + "{path:?}" + ); + assert!(body.contains("WantedBy=default.target"), "{body}"); + assert!(body.contains("ExecStart=/bin/ssh-browser serve"), "{body}"); + } + + /// Installing twice is installing once, everywhere. + /// + /// Somebody unsure whether they did it already will do it again, and the answer has to be + /// "you have it" rather than an error. Writing a file is idempotent on its own; the two + /// platforms that also run something have to have chosen a command that is. + #[test] + fn installing_again_is_not_an_error() { + let (home, state) = dirs(); + for kind in EVERY { + let plan = install_plan(kind, &PathBuf::from("/bin/x"), &home, &state); + for command in &plan.commands { + let line = command.join(" "); + let forgiving = line.contains("daemon-reload") + || line.contains("enable") + || line.contains("bootstrap"); + assert!( + forgiving, + "{kind:?} runs something that may refuse twice: {line}" + ); + } + } + } +} diff --git a/crates/ssh-browser/src/lib.rs b/crates/ssh-browser/src/lib.rs index ac0da4a..b4717c4 100644 --- a/crates/ssh-browser/src/lib.rs +++ b/crates/ssh-browser/src/lib.rs @@ -1,5 +1,6 @@ //! Turn an SSH host into a real browser origin. +pub mod autostart; pub mod cache; pub mod config; pub mod control; diff --git a/crates/ssh-browser/src/main.rs b/crates/ssh-browser/src/main.rs index 443c09a..929d3b8 100644 --- a/crates/ssh-browser/src/main.rs +++ b/crates/ssh-browser/src/main.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; +use ssh_browser::autostart; use ssh_browser::config; use ssh_browser::control::{self, Token}; use ssh_browser::origin::{Alias, Origin, pac}; @@ -12,7 +13,7 @@ use ssh_browser::theme; use ssh_browser::tls; const USAGE: &str = "usage:\n ssh-browser serve [--config FILE] [--port N] [--suffix S] [--scheme http|https] [--new-token] [=[:] ...]\n ssh-browser pac [--config FILE] [--port N] [--suffix S] - ssh-browser trust [--config FILE] [--suffix S]\n ssh-browser hosts\n\nWith no --config, a file at /ssh-browser/config.toml is used if it exists:\n\n [server]\n port = 7391\n suffix = \"ssh-browser\"\n scheme = \"http\" # https terminates TLS behind CONNECT; see `ssh-browser trust`\n\n [[alias]]\n name = \"docs\"\n host = \"myhost\"\n base = \"~/docs\" # or an absolute path; omit for the home directory itself"; + ssh-browser trust [--config FILE] [--suffix S]\n ssh-browser autostart [--off]\n ssh-browser hosts\n\nWith no --config, a file at /ssh-browser/config.toml is used if it exists:\n\n [server]\n port = 7391\n suffix = \"ssh-browser\"\n scheme = \"http\" # https terminates TLS behind CONNECT; see `ssh-browser trust`\n\n [[alias]]\n name = \"docs\"\n host = \"myhost\"\n base = \"~/docs\" # or an absolute path; omit for the home directory itself"; #[tokio::main] async fn main() -> Result<()> { @@ -24,6 +25,7 @@ async fn main() -> Result<()> { let mut named_config: Option = None; let mut cli = config::Overrides::default(); let mut new_token = false; + let mut off = false; // Driven by an iterator rather than an index, so the number of tokens consumed is the // number actually taken. With a hand-kept counter, an arm that forgets its step silently @@ -51,6 +53,7 @@ async fn main() -> Result<()> { // A flag rather than a value, so it consumes nothing: rotating is a thing you // do, not a thing you configure. "--new-token" => new_token = true, + "--off" => off = true, spec => cli.aliases.push(parse_alias(spec)?), } } @@ -159,6 +162,32 @@ async fn main() -> Result<()> { } Ok(()) } + // Does it, rather than printing what to do -- the opposite of `trust` above, and the + // difference is consent. Trusting a root changes what the whole machine believes; + // starting a program of your own at login is the thing that was asked for, and handing + // back a command to paste would be the same failure more politely. + "autostart" => { + let kind = autostart::Kind::here(); + let Some(home) = autostart::home() else { + bail!("no home directory, so nowhere to put a login entry"); + }; + let Some(state) = control::state_dir() else { + bail!("no state directory to keep a login entry in"); + }; + + let plan = if off { + autostart::remove_plan(kind, &home, &state) + } else { + // Its own path, resolved now. A login session's PATH is not a shell's, and an + // entry that starts whatever `ssh-browser` it can find may find none. + let exe = std::env::current_exe().context("finding this executable")?; + autostart::install_plan(kind, &exe, &home, &state) + }; + autostart::apply(&plan)?; + println!(); + println!("{}", plan.note); + Ok(()) + } "serve" => { let (token, source) = Token::load_or_generate(new_token)?; // Printed as well as written, because a first run has nowhere else to look. diff --git a/extension/STORE.md b/extension/STORE.md index 770725f..d0665fb 100644 --- a/extension/STORE.md +++ b/extension/STORE.md @@ -101,8 +101,32 @@ the entry that would raise the question. **Remote code** +Answered **no** on the form. Not an obvious no, so the reasoning is written here rather than +left in somebody's memory of a decision made while filling in a text box. + > None. Everything the extension executes is in the uploaded package. +What makes it a question: `applyPac` fetches `http://127.0.0.1:/proxy.pac` from the +user's own daemon and hands the text to `chrome.proxy` as `pacScript.data`. Chrome's definition +of remotely hosted code is "anything that is executed by the browser that is loaded from +someplace other than the extension's own files", and a PAC is JavaScript. + +Why it is still no: + +- It does not run in the extension. `chrome.proxy` is a first-party API whose documented input + is a PAC string, and the browser's network stack evaluates it — no page, no service worker, + no `eval`. +- It is not remotely hosted. It comes from loopback, from a program the user installed and + started. Nothing on the network can serve it and no update of ours can change it. +- The policy exists so that an extension's behaviour cannot be changed after review by a server + its author controls. Nobody controls this one but the person running the extension. + +If a reviewer disagrees, the fix is small and already scoped: the PAC is a function of the +suffix and the port, and the extension knows both. Generating it locally makes the answer +unambiguous and keeps what the fetch was for — the suffix stays data the daemon reports, so +changing it still needs no extension release. What would be lost is having one generator, and +an e2e check that runs both and compares their answers covers that. + ## Data disclosure Tick **one**. Under-declaring is a policy violation, so anything arguable is declared — but @@ -151,10 +175,51 @@ the first run of this produced exactly that. `shots.yml` runs on a fresh runner throwaway sshd and the invented `ssh_config` in `e2e/shots-config/`, and `shots.mjs` refuses any host but a local one so it cannot happen by habit. -## Still to do by hand +## The first submission, by hand + +Once. The API cannot create a listing — description, screenshots, category and the data +disclosure are not reachable from it, and the extension ID does not exist until the Store +listing and Privacy tabs have been filled in. 1. Register the developer account. Five dollars, once. 2. Upload the zip, paste the text above, attach the screenshots. 3. Choose visibility. Unlisted is worth considering first: this extension does nothing without a daemon installed separately, and a public listing collects installs from people who have not done that and will reasonably report it as broken. + +## Every release after that, by CI + +`.github/workflows/store.yml` uploads the release's zip and publishes it. The decision is still +a person's — it is merging the release PR — but nothing is retyped, and what reaches the store +is the package that was built, tested and attached to the release rather than one somebody +dragged into a browser. + +Three repository secrets, set once: + +| secret | where it comes from | +|---|---| +| `CWS_EXTENSION_ID` | the item's ID, from its dashboard URL, once it exists | +| `CWS_PUBLISHER_ID` | Developer Dashboard → Account | +| `CWS_SERVICE_ACCOUNT` | the JSON key of a Google Cloud service account | + +A service account rather than a refresh token: a refresh token issued while the OAuth consent +screen is still in "Testing" expires after a week, so the pipeline would work today and fail +next month having changed nothing. + +1. In the Google Cloud console, create a project and enable the **Chrome Web Store API**. +2. Create a service account. It needs no roles. +3. Create a JSON key for it, and put the whole file in `CWS_SERVICE_ACCOUNT`. +4. In the Developer Dashboard, under **Account**, add the service account's email address. + Only one service account can be attached to a publisher, so this is the one. + +Then `gh workflow run store.yml -f tag=v0.5.0` tries it without publishing, and after that +every published release goes on its own. + +Two things that will bite: + +- **Visibility is not set by this API.** The item publishes at whatever the dashboard says, and + if visibility is changed by hand the store refuses API publishing until it has been published + by hand once at the new setting. +- **A version cannot be uploaded twice.** `versions agree` in CI keeps `manifest.json` in step + with `Cargo.toml`; if that ever drifts, the store rejects the upload rather than quietly + taking it. diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png index 2bfa11c..57f3e81 100644 Binary files a/extension/icons/icon-128.png and b/extension/icons/icon-128.png differ diff --git a/extension/make-icons.mjs b/extension/make-icons.mjs index a50d2b0..9f5df68 100644 --- a/extension/make-icons.mjs +++ b/extension/make-icons.mjs @@ -24,7 +24,21 @@ const OUT = join(here, "icons"); /// The four Chrome asks for: 16 in the toolbar, 48 on the extensions page, 128 in the store, /// and 32 for the displays that sit between them. -const SIZES = [16, 32, 48, 128]; +/// +/// The 128 is drawn smaller inside its square, and that is the store's rule rather than a +/// preference: the listing wants 96×96 of artwork with 16 transparent pixels on every side, +/// because it draws its own rounded frame around whatever it is given and a tile that fills +/// the canvas has that frame drawn across its corners. The others are toolbar icons, where the +/// opposite holds and padding is wasted pixels at sixteen across. +/// +/// Still one drawing. `fill` is where it is sampled from, not a second set of numbers. +const SIZES = [ + { px: 16, fill: 1 }, + { px: 32, fill: 1 }, + { px: 48, fill: 1 }, + // The tile is 93% of the canvas, so 96/128 of the canvas means 96/119 of the tile. + { px: 128, fill: 96 / 119 }, +]; /// Supersampling factor. Drawn straight at 16×16 the diagonals stair-step; drawn at 64×64 and /// averaged down, the same shape has edges that read as smooth. @@ -63,7 +77,7 @@ function ink(x, y) { return Math.min(upper, lower, bar); } -function render(size) { +function render(size, fill) { const n = size * SS; const px = new Uint8Array(size * size * 4); @@ -77,8 +91,10 @@ function render(size) { for (let sy = 0; sy < SS; sy += 1) { for (let sx = 0; sx < SS; sx += 1) { // The centre of each subpixel, mapped to -1..1. - const u = ((pxi * SS + sx + 0.5) / n) * 2 - 1; - const v = ((py * SS + sy + 0.5) / n) * 2 - 1; + // Divided by `fill`, so a smaller `fill` moves the sample further out and the + // drawing lands smaller in the same square. One drawing, sampled from further away. + const u = (((pxi * SS + sx + 0.5) / n) * 2 - 1) / fill; + const v = (((py * SS + sy + 0.5) / n) * 2 - 1) / fill; if (roundedRect(u, v, 1.86, 1.86, 0.42) > 0) { continue; @@ -139,7 +155,7 @@ function png(size, pixels) { } await mkdir(OUT, { recursive: true }); -for (const size of SIZES) { - await writeFile(join(OUT, `icon-${size}.png`), png(size, render(size))); +for (const { px: size, fill } of SIZES) { + await writeFile(join(OUT, `icon-${size}.png`), png(size, render(size, fill))); console.log(` wrote icons/icon-${size}.png`); }