123456789module 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.value('code') != '' && args.positional.len == 0 {26 return usage_error('--code belongs to "koh remote <host>"')27 }28 if args.flag('forget') {29 if !machine.has_remote() {30 return refuse(args.json(), 'this machine has no remote to forget')31 }32 machine.forget_remote() or { return refuse(args.json(), err.msg()) }33 if args.json() {34 emit_json(json2.encode(RemoteJson{35 configured: false36 }))37 return 038 }39 ok("forgot this machine's remote")40 return 041 }42 if args.positional.len == 0 {43 if !machine.has_remote() {44 if args.json() {45 emit_json(json2.encode(RemoteJson{46 configured: false47 }))48 return 049 }50 return setup_wizard()51 }52 settings := machine.load_remote() or {53 54 if !args.json() && interactive() {55 warn(err.msg())56 return setup_wizard()57 }58 return refuse(args.json(), err.msg())59 }60 if args.json() {61 emit_json(json2.encode(RemoteJson{62 configured: true63 kind: settings.kind64 target: describe(settings)65 }))66 return 067 }68 ok('remote: ${describe(settings)}')69 if !interactive() {70 note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')71 return 072 }73 print(' replace it? [y/N] ')74 if os.get_line().trim_right('\r') != 'y' {75 return 076 }77 78 return setup_wizard()79 }80 81 return set_remote(args)82}8384fn set_remote(args Args) int {85 if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {86 return set_kepr_remote(args)87 }88 if args.value('code') != '' {89 return usage_error('--code belongs to "koh remote <host>", not to folder or s3')90 }91 kind := args.positional_at(0)92 match kind {93 machine.kind_folder {94 path := args.positional_at(1)95 if path == '' {96 return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')97 }98 settings := machine.RemoteSettings{99 kind: machine.kind_folder100 path: os.real_path(path)101 }102 return save_after_check(settings, args, '', '')103 }104 machine.kind_s3 {105 endpoint := args.positional_at(1)106 bucket := args.positional_at(2)107 region := args.positional_at(3)108 if endpoint == '' || bucket == '' || region == '' {109 return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')110 }111 access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }112 settings := machine.RemoteSettings{113 kind: machine.kind_s3114 endpoint: endpoint115 bucket: bucket116 region: region117 }118 return save_after_check(settings, args, access_key, secret_key)119 }120 else {121 return usage_error('remote takes "folder", "s3", or a Kepr host')122 }123 }124}125126127128fn kepr_host(value string) !string {129 for letter in value {130 if letter <= ` ` {131 return error('"${value}" is not a Kepr address — try kepr.uk')132 }133 }134 text := if value.starts_with('http://') || value.starts_with('https://') {135 value136 } else {137 'https://${value}'138 }139 parsed := urllib.parse(text) or { return error('"${value}" is not a Kepr address — try kepr.uk') }140 if parsed.scheme !in ['http', 'https'] || parsed.host == '' || parsed.path.trim_right('/') != ''141 || parsed.raw_query != '' || parsed.fragment != '' {142 return error('"${value}" is not a Kepr address — try kepr.uk')143 }144 145 146 if text.all_after('//').contains('@') {147 return error('"${value}" is not a Kepr address — try kepr.uk')148 }149 return '${parsed.scheme}://${parsed.host}'150}151152fn set_kepr_remote(args Args) int {153 if args.positional.len != 1 {154 return usage_error('koh remote <host> accepts one Kepr host')155 }156 host := kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) }157 connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}'158 println('Open ${connect_url} and sign in')159 mut code := args.value('code')160 if code == '' {161 if !interactive() {162 return fail('setting up a Kepr remote needs a terminal, or use --code <code>')163 }164 165 166 167 168 169 mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' })170 browser.set_args([connect_url])171 browser.run()172 print('paste the code: ')173 code = os.get_line().trim_space()174 }175 176 response := http.fetch(177 url: '${host}/api/connect'178 method: .post179 header: http.new_header(180 key: .content_type181 value: 'application/x-www-form-urlencoded'182 )183 data: 'code=${urllib.query_escape(code)}'184 enable_http2: false185 allow_redirect: false186 ) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }187 if response.status_code == 404 {188 189 190 probe := http.fetch(191 url: connect_url192 method: .get193 enable_http2: false194 allow_redirect: false195 ) or {196 return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')197 }198 if probe.status_code == 404 {199 return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')200 }201 if probe.status_code != 302 {202 return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed')203 }204 return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')205 }206 if response.status_code != 200 {207 return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed')208 }209 reply := json2.decode[KeprReply](response.body) or {210 return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')211 }212 if reply.endpoint == '' || reply.bucket == '' || reply.region == ''213 || reply.access_key_id == '' || reply.secret_access_key == '' {214 return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed')215 }216 settings := machine.RemoteSettings{217 kind: machine.kind_s3218 endpoint: reply.endpoint219 bucket: reply.bucket220 region: reply.region221 kepr: host222 }223 return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)224}225226227228fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {229 mut backend := build_backend(settings, access_key, secret_key) or {230 return refuse(args.json(), err.msg())231 }232 remote.check(mut backend) or {233 return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')234 }235 machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }236 if settings.kind == machine.kind_s3 {237 machine.save_credentials(machine.Credentials{238 access_key: access_key239 secret_key: secret_key240 }) or { return refuse(args.json(), err.msg()) }241 }242 if args.json() {243 emit_json(json2.encode(RemoteJson{244 configured: true245 kind: settings.kind246 target: describe(settings)247 }))248 return 0249 }250 ok('remote set to ${describe(settings)}')251 note('every repo on this machine now offers to it')252 return 0253}254255fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {256 if settings.kind == machine.kind_folder {257 return remote.new_folder(settings.path)!258 }259 return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,260 secret_key)!261}262263fn setup_wizard() int {264 if !interactive() {265 return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')266 }267 println(' choose a remote:')268 println(' ${fmt.amber('1')} folder a directory, on a disk or a NAS')269 println(' ${fmt.amber('2')} s3 an S3-compatible bucket')270 print(' which? [1] ')271 if os.get_line().trim_space() == '2' {272 return wizard_s3()273 }274 print(' folder path: ')275 path := os.get_line().trim_space()276 if path == '' {277 return fail('no path given — nothing was saved')278 }279 settings := machine.RemoteSettings{280 kind: machine.kind_folder281 path: os.real_path(path)282 }283 return save_after_check(settings, Args{}, '', '')284}285286fn wizard_s3() int {287 print(' endpoint [https://s3.fr-par.scw.cloud]: ')288 endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')289 print(' bucket: ')290 bucket := os.get_line().trim_space()291 print(' region [fr-par]: ')292 region := or_default(os.get_line().trim_space(), 'fr-par')293 if bucket == '' {294 return fail('no bucket given — nothing was saved')295 }296 access_key, secret_key := s3_keys() or { return fail(err.msg()) }297 settings := machine.RemoteSettings{298 kind: machine.kind_s3299 endpoint: endpoint300 bucket: bucket301 region: region302 }303 return save_after_check(settings, Args{}, access_key, secret_key)304}305306307308fn s3_keys() !(string, string) {309 access_key := os.getenv('KOH_ACCESS_KEY')310 secret_key := os.getenv('KOH_SECRET_KEY')311 if access_key != '' && secret_key != '' {312 return access_key, secret_key313 }314 if !interactive() {315 return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')316 }317 print(' access key: ')318 access_key_typed := os.get_line().trim_space()319 320 321 mut secret_key_typed := ''322 mut saved := termios.Termios{}323 if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {324 325 326 327 secret_key_typed = os.input_password(' secret key: ') or {328 println('')329 if termios.set_state(0, saved) != 0 {330 warn('the terminal may no longer echo — run "stty sane"')331 }332 ''333 }334 } else {335 print(' secret key: ')336 secret_key_typed = os.get_line()337 }338 secret_key_typed = secret_key_typed.trim_space()339 if access_key_typed == '' || secret_key_typed == '' {340 return error('both keys are needed — nothing was saved')341 }342 return access_key_typed, secret_key_typed343}344345fn describe(settings machine.RemoteSettings) string {346 if settings.kind == machine.kind_folder {347 return 'folder ${settings.path}'348 }349 return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'350}351352fn or_default(value string, fallback string) string {353 return if value == '' { fallback } else { value }354}355356struct RemoteJson {357 configured bool358 kind string359 target string360}361362struct KeprReply {363 endpoint string364 bucket string365 region string366 access_key_id string367 secret_access_key string368}