cmd_remote.v

10.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.fmt16import src.machine17import src.remote18import term.termios1920pub fn cmd_remote(raw []string) int {21	args := parse('remote', raw, Options{22		values: ['code']23		flags:  ['json', 'forget']24	}) or { return usage_error(err.msg()) }25	if args.flag('forget') {26		if !machine.has_remote() {27			return refuse(args.json(), 'this machine has no remote to forget')28		}29		machine.forget_remote() or { return refuse(args.json(), err.msg()) }30		if args.json() {31			emit_json(json2.encode(RemoteJson{32				configured: false33			}))34			return 035		}36		ok("forgot this machine's remote")37		return 038	}39	if args.positional.len == 0 {40		if !machine.has_remote() {41			if args.json() {42				emit_json(json2.encode(RemoteJson{43					configured: false44				}))45				return 046			}47			return setup_wizard()48		}49		settings := machine.load_remote() or {50			// Otherwise the message to run "koh remote" again would loop.51			if !args.json() && interactive() {52				warn(err.msg())53				return setup_wizard()54			}55			return refuse(args.json(), err.msg())56		}57		if args.json() {58			emit_json(json2.encode(RemoteJson{59				configured: true60				kind:       settings.kind61				target:     describe(settings)62			}))63			return 064		}65		ok('remote: ${describe(settings)}')66		if !interactive() {67			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')68			return 069		}70		print('  replace it? [y/N] ')71		if os.get_line().trim_right('\r') != 'y' {72			return 073		}74		// The old remote stays until the new one passes its test.75		return setup_wizard()76	}77	// A non-interactive form, still tested before it is saved.78	return set_remote(args)79}8081fn set_remote(args Args) int {82	if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {83		return set_kepr_remote(args)84	}85	kind := args.positional_at(0)86	match kind {87		machine.kind_folder {88			path := args.positional_at(1)89			if path == '' {90				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')91			}92			settings := machine.RemoteSettings{93				kind: machine.kind_folder94				path: os.real_path(path)95			}96			return save_after_check(settings, args, '', '')97		}98		machine.kind_s3 {99			endpoint := args.positional_at(1)100			bucket := args.positional_at(2)101			region := args.positional_at(3)102			if endpoint == '' || bucket == '' || region == '' {103				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')104			}105			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }106			settings := machine.RemoteSettings{107				kind:     machine.kind_s3108				endpoint: endpoint109				bucket:   bucket110				region:   region111			}112			return save_after_check(settings, args, access_key, secret_key)113		}114		else {115			return usage_error('remote takes "folder", "s3", or a Kepr host')116		}117	}118}119120fn set_kepr_remote(args Args) int {121	if args.positional.len != 1 {122		return usage_error('koh remote <host> accepts one Kepr host')123	}124	mut host := args.positional_at(0)125	if !host.starts_with('http://') && !host.starts_with('https://') {126		host = 'https://${host}'127	}128	host = host.trim_right('/')129	connect_url := '${host}/connect?machine=${machine.name()}'130	println('Open ${connect_url} and sign in')131	open_command := if os.user_os() == 'macos' { 'open' } else { 'xdg-open' }132	// Quoted: a machine name can hold anything, and this line goes to the shell.133	os.execute('${open_command} "${connect_url}" >/dev/null 2>&1 &')134	mut code := args.value('code')135	if code == '' {136		if !interactive() {137			return fail('setting up a Kepr remote needs a terminal, or use --code <code>')138		}139		print('paste the code: ')140		code = os.get_line().trim_space()141	}142	response := http.fetch(143		url:          '${host}/api/connect'144		method:       .post145		header:       http.new_header(146			key:   .content_type147			value: 'application/x-www-form-urlencoded'148		)149		data:         'code=${urllib.query_escape(code)}'150		enable_http2: false151	) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }152	if response.status_code == 404 {153		probe := http.fetch(url: connect_url, method: .get, enable_http2: false) or {154			return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')155		}156		if probe.status_code == 404 {157			return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')158		}159		return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')160	}161	if response.status_code < 200 || response.status_code >= 300 {162		return refuse(args.json(), 'Kepr refused the code — nothing was changed')163	}164	reply := json2.decode[KeprReply](response.body) or {165		return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')166	}167	settings := machine.RemoteSettings{168		kind:     machine.kind_s3169		endpoint: reply.endpoint170		bucket:   reply.bucket171		region:   reply.region172		kepr:     host173	}174	return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)175}176177// save_after_check is the whole point: nothing is written to ~/.config/koh178// until the remote has passed the test.179fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {180	mut backend := build_backend(settings, access_key, secret_key) or {181		return refuse(args.json(), err.msg())182	}183	remote.check(mut backend) or {184		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')185	}186	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }187	if settings.kind == machine.kind_s3 {188		machine.save_credentials(machine.Credentials{189			access_key: access_key190			secret_key: secret_key191		}) or { return refuse(args.json(), err.msg()) }192	}193	if args.json() {194		emit_json(json2.encode(RemoteJson{195			configured: true196			kind:       settings.kind197			target:     describe(settings)198		}))199		return 0200	}201	ok('remote set to ${describe(settings)}')202	note('every repo on this machine now offers to it')203	return 0204}205206fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {207	if settings.kind == machine.kind_folder {208		return remote.new_folder(settings.path)!209	}210	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,211		secret_key)!212}213214fn setup_wizard() int {215	if !interactive() {216		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')217	}218	println('  choose a remote:')219	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')220	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')221	print('  which? [1] ')222	if os.get_line().trim_space() == '2' {223		return wizard_s3()224	}225	print('  folder path: ')226	path := os.get_line().trim_space()227	if path == '' {228		return fail('no path given — nothing was saved')229	}230	settings := machine.RemoteSettings{231		kind: machine.kind_folder232		path: os.real_path(path)233	}234	return save_after_check(settings, Args{}, '', '')235}236237fn wizard_s3() int {238	print('  endpoint [https://s3.fr-par.scw.cloud]: ')239	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')240	print('  bucket: ')241	bucket := os.get_line().trim_space()242	print('  region [fr-par]: ')243	region := or_default(os.get_line().trim_space(), 'fr-par')244	if bucket == '' {245		return fail('no bucket given — nothing was saved')246	}247	access_key, secret_key := s3_keys() or { return fail(err.msg()) }248	settings := machine.RemoteSettings{249		kind:     machine.kind_s3250		endpoint: endpoint251		bucket:   bucket252		region:   region253	}254	return save_after_check(settings, Args{}, access_key, secret_key)255}256257// s3_keys takes the keys from the environment when a script supplies them, and258// otherwise asks, so they never have to be typed into a shell history.259fn s3_keys() !(string, string) {260	access_key := os.getenv('KOH_ACCESS_KEY')261	secret_key := os.getenv('KOH_SECRET_KEY')262	if access_key != '' && secret_key != '' {263		return access_key, secret_key264	}265	if !interactive() {266		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')267	}268	print('  access key: ')269	access_key_typed := os.get_line().trim_space()270	// Hidden input needs a terminal on both ends; with output redirected, the271	// key is read like any other line.272	mut secret_key_typed := ''273	mut saved := termios.Termios{}274	if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {275		// V's hidden read returns on end of input without turning echo back276		// on, so the terminal's state is kept here and put back. A failed277		// read is an empty key, which is refused below.278		secret_key_typed = os.input_password('  secret key: ') or {279			println('')280			if termios.set_state(0, saved) != 0 {281				warn('the terminal may no longer echo — run "stty sane"')282			}283			''284		}285	} else {286		print('  secret key: ')287		secret_key_typed = os.get_line()288	}289	secret_key_typed = secret_key_typed.trim_space()290	if access_key_typed == '' || secret_key_typed == '' {291		return error('both keys are needed — nothing was saved')292	}293	return access_key_typed, secret_key_typed294}295296fn describe(settings machine.RemoteSettings) string {297	if settings.kind == machine.kind_folder {298		return 'folder ${settings.path}'299	}300	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'301}302303fn or_default(value string, fallback string) string {304	return if value == '' { fallback } else { value }305}306307struct RemoteJson {308	configured bool309	kind       string310	target     string311}312313struct KeprReply {314	endpoint          string315	bucket            string316	region            string317	access_key_id     string318	secret_access_key string319}