123456789module cli1011import json212import net.http13import net.urllib14import os15import src.exchange16import src.fmt17import src.machine18import src.remote19import src.storage20import term.termios21222324pub 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 30 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 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 96 return setup_wizard()97 }98 99 return set_remote(args)100}101102103104fn 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}145146147148fn 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 161 162 163 164 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 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 185 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}221222223224fn 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}301302303304fn 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 316 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 321 322 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}