cmd_remote.v

12.0 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.termios2122pub fn cmd_remote(raw []string) int {23	args := parse('remote', raw, Options{24		values: ['code']25		flags:  ['json', 'forget']26	}) or { return usage_error(err.msg()) }27	// A repo stolen from a Kepr has an address of its own, and it belongs above the machine's — as28	// a line for a person: `--json` stays one document.29	if !args.json() {30		if _ := storage.find_root(os.getwd()) {31			store := open_store() or { return refuse(args.json(), err.msg()) }32			defer {33				store.close()34			}35			source := exchange.source_of(store)36			if source != '' {37				println('  ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')38			}39		}40	}41	if args.value('code') != '' && args.positional.len == 0 {42		return usage_error('--code belongs to "koh remote <host>"')43	}44	if args.flag('forget') {45		if !machine.has_remote() {46			return refuse(args.json(), 'this machine has no remote to forget')47		}48		machine.forget_remote() or { return refuse(args.json(), err.msg()) }49		if args.json() {50			emit_json(json2.encode(RemoteJson{51				configured: false52			}))53			return 054		}55		ok("forgot this machine's remote")56		return 057	}58	if args.positional.len == 0 {59		if !machine.has_remote() {60			if args.json() {61				emit_json(json2.encode(RemoteJson{62					configured: false63				}))64				return 065			}66			return setup_wizard()67		}68		settings := machine.load_remote() or {69			// Otherwise the message to run "koh remote" again would loop.70			if !args.json() && interactive() {71				warn(err.msg())72				return setup_wizard()73			}74			return refuse(args.json(), err.msg())75		}76		if args.json() {77			emit_json(json2.encode(RemoteJson{78				configured: true79				kind:       settings.kind80				target:     describe(settings)81			}))82			return 083		}84		ok('remote: ${describe(settings)}')85		if !interactive() {86			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')87			return 088		}89		print('  replace it? [y/N] ')90		if os.get_line().trim_right('\r') != 'y' {91			return 092		}93		// The old remote stays until the new one passes its test.94		return setup_wizard()95	}96	// A non-interactive form, still tested before it is saved.97	return set_remote(args)98}99100fn set_remote(args Args) int {101	if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {102		return set_kepr_remote(args)103	}104	if args.value('code') != '' {105		return usage_error('--code belongs to "koh remote <host>", not to folder or s3')106	}107	kind := args.positional_at(0)108	match kind {109		machine.kind_folder {110			path := args.positional_at(1)111			if path == '' {112				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')113			}114			settings := machine.RemoteSettings{115				kind: machine.kind_folder116				path: os.real_path(path)117			}118			return save_after_check(settings, args, '', '')119		}120		machine.kind_s3 {121			endpoint := args.positional_at(1)122			bucket := args.positional_at(2)123			region := args.positional_at(3)124			if endpoint == '' || bucket == '' || region == '' {125				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')126			}127			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }128			settings := machine.RemoteSettings{129				kind:     machine.kind_s3130				endpoint: endpoint131				bucket:   bucket132				region:   region133			}134			return save_after_check(settings, args, access_key, secret_key)135		}136		else {137			return usage_error('remote takes "folder", "s3", or a Kepr host')138		}139	}140}141142// kepr_host normalises a Kepr address: https unless the scheme says otherwise, and scheme, host143// and port only. A path, a query, a fragment or credentials would send the code somewhere else.144fn kepr_host(value string) !string {145	return remote.kepr_host(value)146}147148fn 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 := 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		method:         .post175		header:         http.new_header(176			key:   .content_type177			value: 'application/x-www-form-urlencoded'178		)179		data:           'code=${urllib.query_escape(code)}'180		enable_http2:   false181		allow_redirect: false182	) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }183	if response.status_code == 404 {184		// One 404 covers a wrong code, an expired one, and a Kepr that hands out nothing. The two185		// are told apart by asking for the connect page: 302 means the endpoint is there.186		probe := http.fetch(187			url:            connect_url188			method:         .get189			enable_http2:   false190			allow_redirect: false191		) or {192			return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')193		}194		if probe.status_code == 404 {195			return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')196		}197		if probe.status_code != 302 {198			return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed')199		}200		return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')201	}202	if response.status_code != 200 {203		return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed')204	}205	reply := json2.decode[KeprReply](response.body) or {206		return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')207	}208	if reply.endpoint == '' || reply.bucket == '' || reply.region == ''209		|| reply.access_key_id == '' || reply.secret_access_key == '' {210		return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed')211	}212	settings := machine.RemoteSettings{213		kind:     machine.kind_s3214		endpoint: reply.endpoint215		bucket:   reply.bucket216		region:   reply.region217		kepr:     host218	}219	return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)220}221222// save_after_check is the whole point: nothing is written to ~/.config/koh223// until the remote has passed the test.224fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {225	mut backend := build_backend(settings, access_key, secret_key) or {226		return refuse(args.json(), err.msg())227	}228	remote.check(mut backend) or {229		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')230	}231	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }232	if settings.kind == machine.kind_s3 {233		machine.save_credentials(machine.Credentials{234			access_key: access_key235			secret_key: secret_key236		}) or { return refuse(args.json(), err.msg()) }237	}238	if args.json() {239		emit_json(json2.encode(RemoteJson{240			configured: true241			kind:       settings.kind242			target:     describe(settings)243		}))244		return 0245	}246	ok('remote set to ${describe(settings)}')247	note('every repo on this machine now offers to it')248	return 0249}250251fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {252	if settings.kind == machine.kind_folder {253		return remote.new_folder(settings.path)!254	}255	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,256		secret_key)!257}258259fn setup_wizard() int {260	if !interactive() {261		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')262	}263	println('  choose a remote:')264	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')265	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')266	print('  which? [1] ')267	if os.get_line().trim_space() == '2' {268		return wizard_s3()269	}270	print('  folder path: ')271	path := os.get_line().trim_space()272	if path == '' {273		return fail('no path given — nothing was saved')274	}275	settings := machine.RemoteSettings{276		kind: machine.kind_folder277		path: os.real_path(path)278	}279	return save_after_check(settings, Args{}, '', '')280}281282fn wizard_s3() int {283	print('  endpoint [https://s3.fr-par.scw.cloud]: ')284	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')285	print('  bucket: ')286	bucket := os.get_line().trim_space()287	print('  region [fr-par]: ')288	region := or_default(os.get_line().trim_space(), 'fr-par')289	if bucket == '' {290		return fail('no bucket given — nothing was saved')291	}292	access_key, secret_key := s3_keys() or { return fail(err.msg()) }293	settings := machine.RemoteSettings{294		kind:     machine.kind_s3295		endpoint: endpoint296		bucket:   bucket297		region:   region298	}299	return save_after_check(settings, Args{}, access_key, secret_key)300}301302// s3_keys takes the keys from the environment when a script supplies them, and303// otherwise asks, so they never have to be typed into a shell history.304fn s3_keys() !(string, string) {305	access_key := os.getenv('KOH_ACCESS_KEY')306	secret_key := os.getenv('KOH_SECRET_KEY')307	if access_key != '' && secret_key != '' {308		return access_key, secret_key309	}310	if !interactive() {311		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')312	}313	print('  access key: ')314	access_key_typed := os.get_line().trim_space()315	// Hidden input needs a terminal on both ends; with output redirected, the316	// key is read like any other line.317	mut secret_key_typed := ''318	mut saved := termios.Termios{}319	if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {320		// V's hidden read returns on end of input without turning echo back321		// on, so the terminal's state is kept here and put back. A failed322		// read is an empty key, which is refused below.323		secret_key_typed = os.input_password('  secret key: ') or {324			println('')325			if termios.set_state(0, saved) != 0 {326				warn('the terminal may no longer echo — run "stty sane"')327			}328			''329		}330	} else {331		print('  secret key: ')332		secret_key_typed = os.get_line()333	}334	secret_key_typed = secret_key_typed.trim_space()335	if access_key_typed == '' || secret_key_typed == '' {336		return error('both keys are needed — nothing was saved')337	}338	return access_key_typed, secret_key_typed339}340341fn describe(settings machine.RemoteSettings) string {342	if settings.kind == machine.kind_folder {343		return 'folder ${settings.path}'344	}345	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'346}347348fn or_default(value string, fallback string) string {349	return if value == '' { fallback } else { value }350}351352struct RemoteJson {353	configured bool354	kind       string355	target     string356}357358struct KeprReply {359	endpoint          string360	bucket            string361	region            string362	access_key_id     string363	secret_access_key string364}