//! `koh remote` — the machine's remote, set up once and used by every repo. //! //! Setup is interactive by default, because it ends with a live connection //! test: a remote is only saved once it has proved it can store an object and //! refuse to overwrite it. `folder` and `s3` forms are available for a machine //! being set up by a script, and an agent can read the current setting with //! `--json` (which never prints the secret key). module cli import json2 import net.http import net.urllib import os import src.exchange import src.fmt import src.machine import src.snapshot import src.remote import src.storage import term.termios // cmd_remote shows the machine's remote (a wizard when none is set), or sets it from `folder`, `s3` or a Kepr // host, or forgets it with --forget. `--code` belongs to the Kepr form only. pub fn cmd_remote(raw []string) int { args := parse('remote', raw, Options{ values: ['code'] flags: ['json', 'forget', 'yes'] }) or { return usage_error(err.msg()) } if args.positional.len > 0 && !args.flag('forget') { kind := args.positional_at(0) expected := if kind == machine.kind_folder { 2 } else if kind == machine.kind_s3 { 4 } else { 1 } if args.positional.len != expected { return usage_error('remote ${kind} needs ${expected - 1} arguments') } if kind in [machine.kind_folder, machine.kind_s3] { if args.value_or_none('code') != none { return usage_error('--code belongs to "koh remote "') } } else { remote.kepr_host(kind) or { return refuse(args.json(), err.msg()) } } } // A repo stolen from a Kepr has an address of its own, and it belongs above the machine's — as // a line for a person: `--json` stays one document. if !args.json() { if _ := storage.find_root(os.getwd()) { store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } source := exchange.source_of(store) if source != '' { println(' ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}') } } } if args.value('code') != '' && args.positional.len == 0 { return usage_error('--code belongs to "koh remote "') } if args.flag('forget') { if args.positional.len > 0 { return usage_error('--forget takes no remote target') } if !machine.has_remote() { return refuse(args.json(), 'this machine has no remote to forget') } if !args.json() { note("forget this machine's remote") } if args.json() && !args.flag('yes') { return refuse(true, 'needs --yes with --json') } approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) } if !approved { return refuse(args.json(), 'nothing was changed') } machine.forget_remote() or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(RemoteJson{ configured: false })) return 0 } ok("forgot this machine's remote") return 0 } if args.positional.len == 0 { if !machine.has_remote() { if args.json() { emit_json(json2.encode(RemoteJson{ configured: false })) return 0 } return setup_wizard() } settings := machine.load_remote() or { // Otherwise the message to run "koh remote" again would loop. if !args.json() && interactive() { warn(err.msg()) approved := confirm(args.flag('yes')) or { return fail(err.msg()) } if !approved { return fail('nothing was changed') } return setup_wizard() } return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(RemoteJson{ configured: true kind: settings.kind target: describe(settings) })) return 0 } ok('remote: ${describe(settings)}') if !interactive() { note('run "koh remote folder " or "koh remote s3 " to replace it') return 0 } if !args.flag('yes') { eprint(' replace it? [y/N] ') if os.get_line().trim_right('\r') !in ['y', 'Y'] { return 0 } } // The old remote stays until the new one passes its test. return setup_wizard() } if machine.has_remote() { if !args.json() { note('replace the machine remote with ${args.positional.map(snapshot.escape_path(it)).join(' ')}') } if args.json() && !args.flag('yes') { return refuse(true, 'needs --yes with --json') } approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) } if !approved { return refuse(args.json(), 'nothing was changed') } } // A non-interactive form, still tested before it is saved. return set_remote(args) } // set_remote handles `folder` and `s3`, and passes any other first word to set_kepr_remote as a host. // Every form goes through save_after_check. fn set_remote(args Args) int { if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] { return set_kepr_remote(args) } if args.value('code') != '' { return usage_error('--code belongs to "koh remote ", not to folder or s3') } kind := args.positional_at(0) match kind { machine.kind_folder { path := args.positional_at(1) if path == '' { return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh') } settings := machine.RemoteSettings{ kind: machine.kind_folder path: os.real_path(path) } return save_after_check(settings, args, '', '') } machine.kind_s3 { endpoint := args.positional_at(1) bucket := args.positional_at(2) region := args.positional_at(3) if endpoint == '' || bucket == '' || region == '' { return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par') } access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) } settings := machine.RemoteSettings{ kind: machine.kind_s3 endpoint: endpoint bucket: bucket region: region } return save_after_check(settings, args, access_key, secret_key) } else { return usage_error('remote takes "folder", "s3", or a Kepr host') } } } // set_kepr_remote signs in to a Kepr: its connect page gives a one-time code, /api/connect swaps the code // for S3 settings, and save_after_check tests them before they are kept. fn set_kepr_remote(args Args) int { if args.positional.len != 1 { return usage_error('koh remote accepts one Kepr host') } host := remote.kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) } connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}' if !args.json() { println('Open ${connect_url} and sign in') } mut code := args.value('code') if code == '' { if !interactive() { return refuse(args.json(), 'setting up a Kepr remote needs a terminal, or use --code ') } // Only a person about to type the code gets a browser opened for them: with `--code` the // page has been read already, and a script must never take over the desktop. `open` or // `xdg-open` is launched with an argument vector rather than a shell — neither the host nor // this machine's name is ours to quote for one — and a browser that will not open is not // an error. mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' }) browser.set_args([connect_url]) browser.run() eprint('paste the code: ') code = os.get_line().trim_space() } // Redirects are refused: a 3xx would hand the one-time code to whatever it points at. response := http.fetch( url: '${host}/api/connect' validate: true verify: remote.tls_roots('${host}/api/connect') or { return refuse(args.json(), err.msg()) } method: .post header: http.new_header( key: .content_type value: 'application/x-www-form-urlencoded' ) data: 'code=${urllib.query_escape(code)}' enable_http2: false allow_redirect: false ) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') } if response.status_code == 404 { // One 404 covers a wrong code, an expired one, and a Kepr that hands out nothing. The two // are told apart by asking for the connect page: 302 means the endpoint is there. probe := http.fetch( url: connect_url validate: true verify: remote.tls_roots(connect_url) or { return refuse(args.json(), err.msg()) } method: .get enable_http2: false allow_redirect: false ) or { return refuse(args.json(), 'that code is wrong or has expired — nothing was changed') } if probe.status_code == 404 { return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"') } if probe.status_code != 302 { return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed') } return refuse(args.json(), 'that code is wrong or has expired — nothing was changed') } if response.status_code != 200 { return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed') } reply := json2.decode[KeprReply](response.body) or { return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed') } if reply.endpoint == '' || reply.bucket == '' || reply.region == '' || reply.access_key_id == '' || reply.secret_access_key == '' { return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed') } settings := machine.RemoteSettings{ kind: machine.kind_s3 endpoint: reply.endpoint bucket: reply.bucket region: reply.region kepr: host } return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key) } // save_after_check is the whole point: nothing is written to ~/.config/koh // until the remote has passed the test. fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int { mut backend := build_backend(settings, access_key, secret_key) or { return refuse(args.json(), err.msg()) } remote.check(mut backend) or { return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}') } machine.save_remote(settings) or { return refuse(args.json(), err.msg()) } if settings.kind == machine.kind_s3 { machine.save_credentials(machine.Credentials{ access_key: access_key secret_key: secret_key }) or { return refuse(args.json(), err.msg()) } } if args.json() { emit_json(json2.encode(RemoteJson{ configured: true kind: settings.kind target: describe(settings) })) return 0 } ok('remote set to ${describe(settings)}') note('every repo on this machine now offers to it') return 0 } fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend { if settings.kind == machine.kind_folder { return remote.new_folder(settings.path)! } return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key, secret_key)! } fn setup_wizard() int { if !interactive() { return fail('setting up a remote needs a terminal, or use: koh remote folder , koh remote s3 ') } println(' choose a remote:') println(' ${fmt.amber('1')} folder a directory, on a disk or a NAS') println(' ${fmt.amber('2')} s3 an S3-compatible bucket') eprint(' which? [1] ') if os.get_line().trim_space() == '2' { return wizard_s3() } eprint(' folder path: ') path := os.get_line().trim_space() if path == '' { return fail('no path given — nothing was saved') } settings := machine.RemoteSettings{ kind: machine.kind_folder path: os.real_path(path) } return save_after_check(settings, Args{}, '', '') } fn wizard_s3() int { eprint(' endpoint [https://s3.fr-par.scw.cloud]: ') endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud') eprint(' bucket: ') bucket := os.get_line().trim_space() eprint(' region [fr-par]: ') region := or_default(os.get_line().trim_space(), 'fr-par') if bucket == '' { return fail('no bucket given — nothing was saved') } access_key, secret_key := s3_keys() or { return fail(err.msg()) } settings := machine.RemoteSettings{ kind: machine.kind_s3 endpoint: endpoint bucket: bucket region: region } return save_after_check(settings, Args{}, access_key, secret_key) } // s3_keys takes the keys from the environment when a script supplies them, and // otherwise asks, so they never have to be typed into a shell history. fn s3_keys() !(string, string) { access_key := os.getenv('KOH_ACCESS_KEY') secret_key := os.getenv('KOH_SECRET_KEY') if access_key != '' && secret_key != '' { return access_key, secret_key } if !interactive() { return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal') } eprint(' access key: ') access_key_typed := os.get_line().trim_space() // Hidden input needs a terminal on both ends; with output redirected, the // key is read like any other line. mut secret_key_typed := '' mut saved := termios.Termios{} if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 { // V's hidden read returns on end of input without turning echo back // on, so the terminal's state is kept here and put back. A failed // read is an empty key, which is refused below. eprint(' secret key: ') secret_key_typed = os.input_password('') or { println('') if termios.set_state(0, saved) != 0 { warn('the terminal may no longer echo — run "stty sane"') } '' } } else { eprint(' secret key: ') secret_key_typed = os.get_line() } secret_key_typed = secret_key_typed.trim_space() if access_key_typed == '' || secret_key_typed == '' { return error('both keys are needed — nothing was saved') } return access_key_typed, secret_key_typed } fn describe(settings machine.RemoteSettings) string { if settings.kind == machine.kind_folder { return 'folder ${settings.path}' } return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})' } fn or_default(value string, fallback string) string { return if value == '' { fallback } else { value } } struct RemoteJson { configured bool kind string target string } struct KeprReply { endpoint string bucket string region string access_key_id string secret_access_key string }