cmd_remote.v

11.9 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.28	if _ := storage.find_root(os.getwd()) {29		store := open_store() or { return refuse(args.json(), err.msg()) }30		defer {31			store.close()32		}33		source := exchange.source_of(store)34		if source != '' {35			println('  ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')36		}37	}38	if args.value('code') != '' && args.positional.len == 0 {39		return usage_error('--code belongs to "koh remote <host>"')40	}41	if args.flag('forget') {42		if !machine.has_remote() {43			return refuse(args.json(), 'this machine has no remote to forget')44		}45		machine.forget_remote() or { return refuse(args.json(), err.msg()) }46		if args.json() {47			emit_json(json2.encode(RemoteJson{48				configured: false49			}))50			return 051		}52		ok("forgot this machine's remote")53		return 054	}55	if args.positional.len == 0 {56		if !machine.has_remote() {57			if args.json() {58				emit_json(json2.encode(RemoteJson{59					configured: false60				}))61				return 062			}63			return setup_wizard()64		}65		settings := machine.load_remote() or {66			// Otherwise the message to run "koh remote" again would loop.67			if !args.json() && interactive() {68				warn(err.msg())69				return setup_wizard()70			}71			return refuse(args.json(), err.msg())72		}73		if args.json() {74			emit_json(json2.encode(RemoteJson{75				configured: true76				kind:       settings.kind77				target:     describe(settings)78			}))79			return 080		}81		ok('remote: ${describe(settings)}')82		if !interactive() {83			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')84			return 085		}86		print('  replace it? [y/N] ')87		if os.get_line().trim_right('\r') != 'y' {88			return 089		}90		// The old remote stays until the new one passes its test.91		return setup_wizard()92	}93	// A non-interactive form, still tested before it is saved.94	return set_remote(args)95}9697fn set_remote(args Args) int {98	if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {99		return set_kepr_remote(args)100	}101	if args.value('code') != '' {102		return usage_error('--code belongs to "koh remote <host>", not to folder or s3')103	}104	kind := args.positional_at(0)105	match kind {106		machine.kind_folder {107			path := args.positional_at(1)108			if path == '' {109				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')110			}111			settings := machine.RemoteSettings{112				kind: machine.kind_folder113				path: os.real_path(path)114			}115			return save_after_check(settings, args, '', '')116		}117		machine.kind_s3 {118			endpoint := args.positional_at(1)119			bucket := args.positional_at(2)120			region := args.positional_at(3)121			if endpoint == '' || bucket == '' || region == '' {122				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')123			}124			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }125			settings := machine.RemoteSettings{126				kind:     machine.kind_s3127				endpoint: endpoint128				bucket:   bucket129				region:   region130			}131			return save_after_check(settings, args, access_key, secret_key)132		}133		else {134			return usage_error('remote takes "folder", "s3", or a Kepr host')135		}136	}137}138139// kepr_host normalises a Kepr address: https unless the scheme says otherwise, and scheme, host140// and port only. A path, a query, a fragment or credentials would send the code somewhere else.141fn kepr_host(value string) !string {142	return remote.kepr_host(value)143}144145fn set_kepr_remote(args Args) int {146	if args.positional.len != 1 {147		return usage_error('koh remote <host> accepts one Kepr host')148	}149	host := kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) }150	connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}'151	println('Open ${connect_url} and sign in')152	mut code := args.value('code')153	if code == '' {154		if !interactive() {155			return fail('setting up a Kepr remote needs a terminal, or use --code <code>')156		}157		// Only a person about to type the code gets a browser opened for them: with `--code` the158		// page has been read already, and a script must never take over the desktop. `open` or159		// `xdg-open` is launched with an argument vector rather than a shell — neither the host nor160		// this machine's name is ours to quote for one — and a browser that will not open is not161		// an error.162		mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' })163		browser.set_args([connect_url])164		browser.run()165		print('paste the code: ')166		code = os.get_line().trim_space()167	}168	// Redirects are refused: a 3xx would hand the one-time code to whatever it points at.169	response := http.fetch(170		url:            '${host}/api/connect'171		method:         .post172		header:         http.new_header(173			key:   .content_type174			value: 'application/x-www-form-urlencoded'175		)176		data:           'code=${urllib.query_escape(code)}'177		enable_http2:   false178		allow_redirect: false179	) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }180	if response.status_code == 404 {181		// One 404 covers a wrong code, an expired one, and a Kepr that hands out nothing. The two182		// are told apart by asking for the connect page: 302 means the endpoint is there.183		probe := http.fetch(184			url:            connect_url185			method:         .get186			enable_http2:   false187			allow_redirect: false188		) or {189			return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')190		}191		if probe.status_code == 404 {192			return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')193		}194		if probe.status_code != 302 {195			return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed')196		}197		return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')198	}199	if response.status_code != 200 {200		return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed')201	}202	reply := json2.decode[KeprReply](response.body) or {203		return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')204	}205	if reply.endpoint == '' || reply.bucket == '' || reply.region == ''206		|| reply.access_key_id == '' || reply.secret_access_key == '' {207		return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed')208	}209	settings := machine.RemoteSettings{210		kind:     machine.kind_s3211		endpoint: reply.endpoint212		bucket:   reply.bucket213		region:   reply.region214		kepr:     host215	}216	return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)217}218219// save_after_check is the whole point: nothing is written to ~/.config/koh220// until the remote has passed the test.221fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {222	mut backend := build_backend(settings, access_key, secret_key) or {223		return refuse(args.json(), err.msg())224	}225	remote.check(mut backend) or {226		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')227	}228	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }229	if settings.kind == machine.kind_s3 {230		machine.save_credentials(machine.Credentials{231			access_key: access_key232			secret_key: secret_key233		}) or { return refuse(args.json(), err.msg()) }234	}235	if args.json() {236		emit_json(json2.encode(RemoteJson{237			configured: true238			kind:       settings.kind239			target:     describe(settings)240		}))241		return 0242	}243	ok('remote set to ${describe(settings)}')244	note('every repo on this machine now offers to it')245	return 0246}247248fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {249	if settings.kind == machine.kind_folder {250		return remote.new_folder(settings.path)!251	}252	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,253		secret_key)!254}255256fn setup_wizard() int {257	if !interactive() {258		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')259	}260	println('  choose a remote:')261	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')262	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')263	print('  which? [1] ')264	if os.get_line().trim_space() == '2' {265		return wizard_s3()266	}267	print('  folder path: ')268	path := os.get_line().trim_space()269	if path == '' {270		return fail('no path given — nothing was saved')271	}272	settings := machine.RemoteSettings{273		kind: machine.kind_folder274		path: os.real_path(path)275	}276	return save_after_check(settings, Args{}, '', '')277}278279fn wizard_s3() int {280	print('  endpoint [https://s3.fr-par.scw.cloud]: ')281	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')282	print('  bucket: ')283	bucket := os.get_line().trim_space()284	print('  region [fr-par]: ')285	region := or_default(os.get_line().trim_space(), 'fr-par')286	if bucket == '' {287		return fail('no bucket given — nothing was saved')288	}289	access_key, secret_key := s3_keys() or { return fail(err.msg()) }290	settings := machine.RemoteSettings{291		kind:     machine.kind_s3292		endpoint: endpoint293		bucket:   bucket294		region:   region295	}296	return save_after_check(settings, Args{}, access_key, secret_key)297}298299// s3_keys takes the keys from the environment when a script supplies them, and300// otherwise asks, so they never have to be typed into a shell history.301fn s3_keys() !(string, string) {302	access_key := os.getenv('KOH_ACCESS_KEY')303	secret_key := os.getenv('KOH_SECRET_KEY')304	if access_key != '' && secret_key != '' {305		return access_key, secret_key306	}307	if !interactive() {308		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')309	}310	print('  access key: ')311	access_key_typed := os.get_line().trim_space()312	// Hidden input needs a terminal on both ends; with output redirected, the313	// key is read like any other line.314	mut secret_key_typed := ''315	mut saved := termios.Termios{}316	if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {317		// V's hidden read returns on end of input without turning echo back318		// on, so the terminal's state is kept here and put back. A failed319		// read is an empty key, which is refused below.320		secret_key_typed = os.input_password('  secret key: ') or {321			println('')322			if termios.set_state(0, saved) != 0 {323				warn('the terminal may no longer echo — run "stty sane"')324			}325			''326		}327	} else {328		print('  secret key: ')329		secret_key_typed = os.get_line()330	}331	secret_key_typed = secret_key_typed.trim_space()332	if access_key_typed == '' || secret_key_typed == '' {333		return error('both keys are needed — nothing was saved')334	}335	return access_key_typed, secret_key_typed336}337338fn describe(settings machine.RemoteSettings) string {339	if settings.kind == machine.kind_folder {340		return 'folder ${settings.path}'341	}342	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'343}344345fn or_default(value string, fallback string) string {346	return if value == '' { fallback } else { value }347}348349struct RemoteJson {350	configured bool351	kind       string352	target     string353}354355struct KeprReply {356	endpoint          string357	bucket            string358	region            string359	access_key_id     string360	secret_access_key string361}