//! `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.remote import src.storage import term.termios pub fn cmd_remote(raw []string) int { args := parse('remote', raw, Options{ values: ['code'] flags: ['json', 'forget'] }) or { return usage_error(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 !machine.has_remote() { return refuse(args.json(), 'this machine has no remote to forget') } 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()) 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 } print(' replace it? [y/N] ') if os.get_line().trim_right('\r') != 'y' { return 0 } // The old remote stays until the new one passes its test. return setup_wizard() } // A non-interactive form, still tested before it is saved. return set_remote(args) } 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') } } } 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())}' println('Open ${connect_url} and sign in') mut code := args.value('code') if code == '' { if !interactive() { return fail('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() print('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' 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 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') print(' which? [1] ') if os.get_line().trim_space() == '2' { return wizard_s3() } print(' 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 { print(' endpoint [https://s3.fr-par.scw.cloud]: ') endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud') print(' bucket: ') bucket := os.get_line().trim_space() print(' 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') } print(' 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. secret_key_typed = os.input_password(' secret key: ') or { println('') if termios.set_state(0, saved) != 0 { warn('the terminal may no longer echo — run "stty sane"') } '' } } else { print(' 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 }