cmd_remote.v

12.5 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.remote19import src.storage20import term.termios2122// cmd_remote shows the machine's remote (a wizard when none is set), or sets it from `folder`, `s3` or a Kepr23// host, or forgets it with --forget. `--code` belongs to the Kepr form only.24pub fn cmd_remote(raw []string) int {25	args := parse('remote', raw, Options{26		values: ['code']27		flags:  ['json', 'forget']28	}) or { return usage_error(err.msg()) }29	// A repo stolen from a Kepr has an address of its own, and it belongs above the machine's — as30	// a line for a person: `--json` stays one document.31	if !args.json() {32		if _ := storage.find_root(os.getwd()) {33			store := open_store() or { return refuse(args.json(), err.msg()) }34			defer {35				store.close()36			}37			source := exchange.source_of(store)38			if source != '' {39				println('  ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')40			}41		}42	}43	if args.value('code') != '' && args.positional.len == 0 {44		return usage_error('--code belongs to "koh remote <host>"')45	}46	if args.flag('forget') {47		if !machine.has_remote() {48			return refuse(args.json(), 'this machine has no remote to forget')49		}50		machine.forget_remote() or { return refuse(args.json(), err.msg()) }51		if args.json() {52			emit_json(json2.encode(RemoteJson{53				configured: false54			}))55			return 056		}57		ok("forgot this machine's remote")58		return 059	}60	if args.positional.len == 0 {61		if !machine.has_remote() {62			if args.json() {63				emit_json(json2.encode(RemoteJson{64					configured: false65				}))66				return 067			}68			return setup_wizard()69		}70		settings := machine.load_remote() or {71			// Otherwise the message to run "koh remote" again would loop.72			if !args.json() && interactive() {73				warn(err.msg())74				return setup_wizard()75			}76			return refuse(args.json(), err.msg())77		}78		if args.json() {79			emit_json(json2.encode(RemoteJson{80				configured: true81				kind:       settings.kind82				target:     describe(settings)83			}))84			return 085		}86		ok('remote: ${describe(settings)}')87		if !interactive() {88			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')89			return 090		}91		print('  replace it? [y/N] ')92		if os.get_line().trim_right('\r') != 'y' {93			return 094		}95		// The old remote stays until the new one passes its test.96		return setup_wizard()97	}98	// A non-interactive form, still tested before it is saved.99	return set_remote(args)100}101102// set_remote handles `folder` and `s3`, and passes any other first word to set_kepr_remote as a host.103// Every form goes through save_after_check.104fn set_remote(args Args) int {105	if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {106		return set_kepr_remote(args)107	}108	if args.value('code') != '' {109		return usage_error('--code belongs to "koh remote <host>", not to folder or s3')110	}111	kind := args.positional_at(0)112	match kind {113		machine.kind_folder {114			path := args.positional_at(1)115			if path == '' {116				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')117			}118			settings := machine.RemoteSettings{119				kind: machine.kind_folder120				path: os.real_path(path)121			}122			return save_after_check(settings, args, '', '')123		}124		machine.kind_s3 {125			endpoint := args.positional_at(1)126			bucket := args.positional_at(2)127			region := args.positional_at(3)128			if endpoint == '' || bucket == '' || region == '' {129				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')130			}131			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }132			settings := machine.RemoteSettings{133				kind:     machine.kind_s3134				endpoint: endpoint135				bucket:   bucket136				region:   region137			}138			return save_after_check(settings, args, access_key, secret_key)139		}140		else {141			return usage_error('remote takes "folder", "s3", or a Kepr host')142		}143	}144}145146// set_kepr_remote signs in to a Kepr: its connect page gives a one-time code, /api/connect swaps the code147// for S3 settings, and save_after_check tests them before they are kept.148fn set_kepr_remote(args Args) int {149	if args.positional.len != 1 {150		return usage_error('koh remote <host> accepts one Kepr host')151	}152	host := remote.kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) }153	connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}'154	println('Open ${connect_url} and sign in')155	mut code := args.value('code')156	if code == '' {157		if !interactive() {158			return fail('setting up a Kepr remote needs a terminal, or use --code <code>')159		}160		// Only a person about to type the code gets a browser opened for them: with `--code` the161		// page has been read already, and a script must never take over the desktop. `open` or162		// `xdg-open` is launched with an argument vector rather than a shell — neither the host nor163		// this machine's name is ours to quote for one — and a browser that will not open is not164		// an error.165		mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' })166		browser.set_args([connect_url])167		browser.run()168		print('paste the code: ')169		code = os.get_line().trim_space()170	}171	// Redirects are refused: a 3xx would hand the one-time code to whatever it points at.172	response := http.fetch(173		url:            '${host}/api/connect'174		validate:       true175		verify:         remote.tls_roots('${host}/api/connect') or { return refuse(args.json(), err.msg()) }176		method:         .post177		header:         http.new_header(178			key:   .content_type179			value: 'application/x-www-form-urlencoded'180		)181		data:           'code=${urllib.query_escape(code)}'182		enable_http2:   false183		allow_redirect: false184	) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }185	if response.status_code == 404 {186		// One 404 covers a wrong code, an expired one, and a Kepr that hands out nothing. The two187		// are told apart by asking for the connect page: 302 means the endpoint is there.188		probe := http.fetch(189			url:            connect_url190			validate:       true191			verify:         remote.tls_roots(connect_url) or { return refuse(args.json(), err.msg()) }192			method:         .get193			enable_http2:   false194			allow_redirect: false195		) or {196			return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')197		}198		if probe.status_code == 404 {199			return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')200		}201		if probe.status_code != 302 {202			return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed')203		}204		return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')205	}206	if response.status_code != 200 {207		return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed')208	}209	reply := json2.decode[KeprReply](response.body) or {210		return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')211	}212	if reply.endpoint == '' || reply.bucket == '' || reply.region == ''213		|| reply.access_key_id == '' || reply.secret_access_key == '' {214		return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed')215	}216	settings := machine.RemoteSettings{217		kind:     machine.kind_s3218		endpoint: reply.endpoint219		bucket:   reply.bucket220		region:   reply.region221		kepr:     host222	}223	return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)224}225226// save_after_check is the whole point: nothing is written to ~/.config/koh227// until the remote has passed the test.228fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {229	mut backend := build_backend(settings, access_key, secret_key) or {230		return refuse(args.json(), err.msg())231	}232	remote.check(mut backend) or {233		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')234	}235	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }236	if settings.kind == machine.kind_s3 {237		machine.save_credentials(machine.Credentials{238			access_key: access_key239			secret_key: secret_key240		}) or { return refuse(args.json(), err.msg()) }241	}242	if args.json() {243		emit_json(json2.encode(RemoteJson{244			configured: true245			kind:       settings.kind246			target:     describe(settings)247		}))248		return 0249	}250	ok('remote set to ${describe(settings)}')251	note('every repo on this machine now offers to it')252	return 0253}254255fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {256	if settings.kind == machine.kind_folder {257		return remote.new_folder(settings.path)!258	}259	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,260		secret_key)!261}262263fn setup_wizard() int {264	if !interactive() {265		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')266	}267	println('  choose a remote:')268	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')269	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')270	print('  which? [1] ')271	if os.get_line().trim_space() == '2' {272		return wizard_s3()273	}274	print('  folder path: ')275	path := os.get_line().trim_space()276	if path == '' {277		return fail('no path given — nothing was saved')278	}279	settings := machine.RemoteSettings{280		kind: machine.kind_folder281		path: os.real_path(path)282	}283	return save_after_check(settings, Args{}, '', '')284}285286fn wizard_s3() int {287	print('  endpoint [https://s3.fr-par.scw.cloud]: ')288	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')289	print('  bucket: ')290	bucket := os.get_line().trim_space()291	print('  region [fr-par]: ')292	region := or_default(os.get_line().trim_space(), 'fr-par')293	if bucket == '' {294		return fail('no bucket given — nothing was saved')295	}296	access_key, secret_key := s3_keys() or { return fail(err.msg()) }297	settings := machine.RemoteSettings{298		kind:     machine.kind_s3299		endpoint: endpoint300		bucket:   bucket301		region:   region302	}303	return save_after_check(settings, Args{}, access_key, secret_key)304}305306// s3_keys takes the keys from the environment when a script supplies them, and307// otherwise asks, so they never have to be typed into a shell history.308fn s3_keys() !(string, string) {309	access_key := os.getenv('KOH_ACCESS_KEY')310	secret_key := os.getenv('KOH_SECRET_KEY')311	if access_key != '' && secret_key != '' {312		return access_key, secret_key313	}314	if !interactive() {315		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')316	}317	print('  access key: ')318	access_key_typed := os.get_line().trim_space()319	// Hidden input needs a terminal on both ends; with output redirected, the320	// key is read like any other line.321	mut secret_key_typed := ''322	mut saved := termios.Termios{}323	if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {324		// V's hidden read returns on end of input without turning echo back325		// on, so the terminal's state is kept here and put back. A failed326		// read is an empty key, which is refused below.327		secret_key_typed = os.input_password('  secret key: ') or {328			println('')329			if termios.set_state(0, saved) != 0 {330				warn('the terminal may no longer echo — run "stty sane"')331			}332			''333		}334	} else {335		print('  secret key: ')336		secret_key_typed = os.get_line()337	}338	secret_key_typed = secret_key_typed.trim_space()339	if access_key_typed == '' || secret_key_typed == '' {340		return error('both keys are needed — nothing was saved')341	}342	return access_key_typed, secret_key_typed343}344345fn describe(settings machine.RemoteSettings) string {346	if settings.kind == machine.kind_folder {347		return 'folder ${settings.path}'348	}349	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'350}351352fn or_default(value string, fallback string) string {353	return if value == '' { fallback } else { value }354}355356struct RemoteJson {357	configured bool358	kind       string359	target     string360}361362struct KeprReply {363	endpoint          string364	bucket            string365	region            string366	access_key_id     string367	secret_access_key string368}