123456789module 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 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 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 91 return setup_wizard()92 }93 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}138139140141fn 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 158 159 160 161 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 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 182 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}218219220221fn 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}298299300301fn 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 313 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 318 319 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}