cmd_remote.v

14.1 KB · raw

1//! `koh remote` — the machine's remote, set up once and used by every repo.2//!3//! Setup is interactive by default, because it ends with a live connection4//! test: a remote is only saved once it has proved it can store an object and5//! refuse to overwrite it. `folder` and `s3` forms are available for a machine6//! being set up by a script, and an agent can read the current setting with7//! `--json` (which never prints the secret key).89module cli1011import json212import net.http13import net.urllib14import os15import src.exchange16import src.fmt17import src.machine18import src.snapshot19import src.remote20import src.storage21import term.termios2223// cmd_remote shows the machine's remote (a wizard when none is set), or sets it from `folder`, `s3` or a Kepr24// host, or forgets it with --forget. `--code` belongs to the Kepr form only.25pub fn cmd_remote(raw []string) int {26	args := parse('remote', raw, Options{27		values: ['code']28		flags:  ['json', 'forget', 'yes']29	}) or { return usage_error(err.msg()) }30	if args.positional.len > 0 && !args.flag('forget') {31		kind := args.positional_at(0)32		expected := if kind == machine.kind_folder {33			234		} else if kind == machine.kind_s3 { 4 } else { 1 }35		if args.positional.len != expected {36			return usage_error('remote ${kind} needs ${expected - 1} arguments')37		}38		if kind in [machine.kind_folder, machine.kind_s3] {39			if args.value_or_none('code') != none {40				return usage_error('--code belongs to "koh remote <host>"')41			}42		} else {43			remote.kepr_host(kind) or { return refuse(args.json(), err.msg()) }44		}45	}46	// A repo stolen from a Kepr has an address of its own, and it belongs above the machine's — as47	// a line for a person: `--json` stays one document.48	if !args.json() {49		if _ := storage.find_root(os.getwd()) {50			store := open_store() or { return refuse(args.json(), err.msg()) }51			defer {52				store.close()53			}54			source := exchange.source_of(store)55			if source != '' {56				println('  ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')57			}58		}59	}60	if args.value('code') != '' && args.positional.len == 0 {61		return usage_error('--code belongs to "koh remote <host>"')62	}63	if args.flag('forget') {64		if args.positional.len > 0 {65			return usage_error('--forget takes no remote target')66		}67		if !machine.has_remote() {68			return refuse(args.json(), 'this machine has no remote to forget')69		}70		if !args.json() { note("forget this machine's remote") }71		if args.json() && !args.flag('yes') {72			return refuse(true, 'needs --yes with --json')73		}74		approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }75		if !approved {76			return refuse(args.json(), 'nothing was changed')77		}78		machine.forget_remote() or { return refuse(args.json(), err.msg()) }79		if args.json() {80			emit_json(json2.encode(RemoteJson{81				configured: false82			}))83			return 084		}85		ok("forgot this machine's remote")86		return 087	}88	if args.positional.len == 0 {89		if !machine.has_remote() {90			if args.json() {91				emit_json(json2.encode(RemoteJson{92					configured: false93				}))94				return 095			}96			return setup_wizard()97		}98		settings := machine.load_remote() or {99			// Otherwise the message to run "koh remote" again would loop.100			if !args.json() && interactive() {101				warn(err.msg())102				approved := confirm(args.flag('yes')) or { return fail(err.msg()) }103				if !approved {104					return fail('nothing was changed')105				}106				return setup_wizard()107			}108			return refuse(args.json(), err.msg())109		}110		if args.json() {111			emit_json(json2.encode(RemoteJson{112				configured: true113				kind:       settings.kind114				target:     describe(settings)115			}))116			return 0117		}118		ok('remote: ${describe(settings)}')119		if !interactive() {120			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')121			return 0122		}123		if !args.flag('yes') {124			eprint('  replace it? [y/N] ')125			if os.get_line().trim_right('\r') !in ['y', 'Y'] {126				return 0127			}128		}129		// The old remote stays until the new one passes its test.130		return setup_wizard()131	}132	if machine.has_remote() {133		if !args.json() {134			note('replace the machine remote with ${args.positional.map(snapshot.escape_path(it)).join(' ')}')135		}136		if args.json() && !args.flag('yes') {137			return refuse(true, 'needs --yes with --json')138		}139		approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }140		if !approved {141			return refuse(args.json(), 'nothing was changed')142		}143	}144	// A non-interactive form, still tested before it is saved.145	return set_remote(args)146}147148// set_remote handles `folder` and `s3`, and passes any other first word to set_kepr_remote as a host.149// Every form goes through save_after_check.150fn set_remote(args Args) int {151	if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {152		return set_kepr_remote(args)153	}154	if args.value('code') != '' {155		return usage_error('--code belongs to "koh remote <host>", not to folder or s3')156	}157	kind := args.positional_at(0)158	match kind {159		machine.kind_folder {160			path := args.positional_at(1)161			if path == '' {162				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')163			}164			settings := machine.RemoteSettings{165				kind: machine.kind_folder166				path: os.real_path(path)167			}168			return save_after_check(settings, args, '', '')169		}170		machine.kind_s3 {171			endpoint := args.positional_at(1)172			bucket := args.positional_at(2)173			region := args.positional_at(3)174			if endpoint == '' || bucket == '' || region == '' {175				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')176			}177			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }178			settings := machine.RemoteSettings{179				kind:     machine.kind_s3180				endpoint: endpoint181				bucket:   bucket182				region:   region183			}184			return save_after_check(settings, args, access_key, secret_key)185		}186		else {187			return usage_error('remote takes "folder", "s3", or a Kepr host')188		}189	}190}191192// set_kepr_remote signs in to a Kepr: its connect page gives a one-time code, /api/connect swaps the code193// for S3 settings, and save_after_check tests them before they are kept.194fn set_kepr_remote(args Args) int {195	if args.positional.len != 1 {196		return usage_error('koh remote <host> accepts one Kepr host')197	}198	host := remote.kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) }199	connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}'200	if !args.json() { println('Open ${connect_url} and sign in') }201	mut code := args.value('code')202	if code == '' {203		if !interactive() {204			return refuse(args.json(), 'setting up a Kepr remote needs a terminal, or use --code <code>')205		}206		// Only a person about to type the code gets a browser opened for them: with `--code` the207		// page has been read already, and a script must never take over the desktop. `open` or208		// `xdg-open` is launched with an argument vector rather than a shell — neither the host nor209		// this machine's name is ours to quote for one — and a browser that will not open is not210		// an error.211		mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' })212		browser.set_args([connect_url])213		browser.run()214		eprint('paste the code: ')215		code = os.get_line().trim_space()216	}217	// Redirects are refused: a 3xx would hand the one-time code to whatever it points at.218	response := http.fetch(219		url:            '${host}/api/connect'220		validate:       true221		verify:         remote.tls_roots('${host}/api/connect') or { return refuse(args.json(), err.msg()) }222		method:         .post223		header:         http.new_header(224			key:   .content_type225			value: 'application/x-www-form-urlencoded'226		)227		data:           'code=${urllib.query_escape(code)}'228		enable_http2:   false229		allow_redirect: false230	) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }231	if response.status_code == 404 {232		// One 404 covers a wrong code, an expired one, and a Kepr that hands out nothing. The two233		// are told apart by asking for the connect page: 302 means the endpoint is there.234		probe := http.fetch(235			url:            connect_url236			validate:       true237			verify:         remote.tls_roots(connect_url) or { return refuse(args.json(), err.msg()) }238			method:         .get239			enable_http2:   false240			allow_redirect: false241		) or {242			return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')243		}244		if probe.status_code == 404 {245			return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')246		}247		if probe.status_code != 302 {248			return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed')249		}250		return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')251	}252	if response.status_code != 200 {253		return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed')254	}255	reply := json2.decode[KeprReply](response.body) or {256		return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')257	}258	if reply.endpoint == '' || reply.bucket == '' || reply.region == ''259		|| reply.access_key_id == '' || reply.secret_access_key == '' {260		return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed')261	}262	settings := machine.RemoteSettings{263		kind:     machine.kind_s3264		endpoint: reply.endpoint265		bucket:   reply.bucket266		region:   reply.region267		kepr:     host268	}269	return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)270}271272// save_after_check is the whole point: nothing is written to ~/.config/koh273// until the remote has passed the test.274fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {275	mut backend := build_backend(settings, access_key, secret_key) or {276		return refuse(args.json(), err.msg())277	}278	remote.check(mut backend) or {279		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')280	}281	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }282	if settings.kind == machine.kind_s3 {283		machine.save_credentials(machine.Credentials{284			access_key: access_key285			secret_key: secret_key286		}) or { return refuse(args.json(), err.msg()) }287	}288	if args.json() {289		emit_json(json2.encode(RemoteJson{290			configured: true291			kind:       settings.kind292			target:     describe(settings)293		}))294		return 0295	}296	ok('remote set to ${describe(settings)}')297	note('every repo on this machine now offers to it')298	return 0299}300301fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {302	if settings.kind == machine.kind_folder {303		return remote.new_folder(settings.path)!304	}305	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,306		secret_key)!307}308309fn setup_wizard() int {310	if !interactive() {311		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')312	}313	println('  choose a remote:')314	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')315	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')316	eprint('  which? [1] ')317	if os.get_line().trim_space() == '2' {318		return wizard_s3()319	}320	eprint('  folder path: ')321	path := os.get_line().trim_space()322	if path == '' {323		return fail('no path given — nothing was saved')324	}325	settings := machine.RemoteSettings{326		kind: machine.kind_folder327		path: os.real_path(path)328	}329	return save_after_check(settings, Args{}, '', '')330}331332fn wizard_s3() int {333	eprint('  endpoint [https://s3.fr-par.scw.cloud]: ')334	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')335	eprint('  bucket: ')336	bucket := os.get_line().trim_space()337	eprint('  region [fr-par]: ')338	region := or_default(os.get_line().trim_space(), 'fr-par')339	if bucket == '' {340		return fail('no bucket given — nothing was saved')341	}342	access_key, secret_key := s3_keys() or { return fail(err.msg()) }343	settings := machine.RemoteSettings{344		kind:     machine.kind_s3345		endpoint: endpoint346		bucket:   bucket347		region:   region348	}349	return save_after_check(settings, Args{}, access_key, secret_key)350}351352// s3_keys takes the keys from the environment when a script supplies them, and353// otherwise asks, so they never have to be typed into a shell history.354fn s3_keys() !(string, string) {355	access_key := os.getenv('KOH_ACCESS_KEY')356	secret_key := os.getenv('KOH_SECRET_KEY')357	if access_key != '' && secret_key != '' {358		return access_key, secret_key359	}360	if !interactive() {361		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')362	}363	eprint('  access key: ')364	access_key_typed := os.get_line().trim_space()365	// Hidden input needs a terminal on both ends; with output redirected, the366	// key is read like any other line.367	mut secret_key_typed := ''368	mut saved := termios.Termios{}369	if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {370		// V's hidden read returns on end of input without turning echo back371		// on, so the terminal's state is kept here and put back. A failed372		// read is an empty key, which is refused below.373		eprint('  secret key: ')374		secret_key_typed = os.input_password('') or {375			println('')376			if termios.set_state(0, saved) != 0 {377				warn('the terminal may no longer echo — run "stty sane"')378			}379			''380		}381	} else {382		eprint('  secret key: ')383		secret_key_typed = os.get_line()384	}385	secret_key_typed = secret_key_typed.trim_space()386	if access_key_typed == '' || secret_key_typed == '' {387		return error('both keys are needed — nothing was saved')388	}389	return access_key_typed, secret_key_typed390}391392fn describe(settings machine.RemoteSettings) string {393	if settings.kind == machine.kind_folder {394		return 'folder ${settings.path}'395	}396	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'397}398399fn or_default(value string, fallback string) string {400	return if value == '' { fallback } else { value }401}402403struct RemoteJson {404	configured bool405	kind       string406	target     string407}408409struct KeprReply {410	endpoint          string411	bucket            string412	region            string413	access_key_id     string414	secret_access_key string415}