123456789module cli1011import json212import net.http13import net.urllib14import os15import src.exchange16import src.fmt17import src.machine18import src.snapshot19import src.remote20import src.storage21import term.termios22232425pub 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 47 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 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 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 145 return set_remote(args)146}147148149150fn 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}191192193194fn 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 207 208 209 210 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 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 233 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}271272273274fn 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}351352353354fn 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 366 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 371 372 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}