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 29 if !args.json() {30 if _ := storage.find_root(os.getwd()) {31 store := open_store() or { return refuse(args.json(), err.msg()) }32 defer {33 store.close()34 }35 source := exchange.source_of(store)36 if source != '' {37 println(' ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')38 }39 }40 }41 if args.value('code') != '' && args.positional.len == 0 {42 return usage_error('--code belongs to "koh remote <host>"')43 }44 if args.flag('forget') {45 if !machine.has_remote() {46 return refuse(args.json(), 'this machine has no remote to forget')47 }48 machine.forget_remote() or { return refuse(args.json(), err.msg()) }49 if args.json() {50 emit_json(json2.encode(RemoteJson{51 configured: false52 }))53 return 054 }55 ok("forgot this machine's remote")56 return 057 }58 if args.positional.len == 0 {59 if !machine.has_remote() {60 if args.json() {61 emit_json(json2.encode(RemoteJson{62 configured: false63 }))64 return 065 }66 return setup_wizard()67 }68 settings := machine.load_remote() or {69 70 if !args.json() && interactive() {71 warn(err.msg())72 return setup_wizard()73 }74 return refuse(args.json(), err.msg())75 }76 if args.json() {77 emit_json(json2.encode(RemoteJson{78 configured: true79 kind: settings.kind80 target: describe(settings)81 }))82 return 083 }84 ok('remote: ${describe(settings)}')85 if !interactive() {86 note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')87 return 088 }89 print(' replace it? [y/N] ')90 if os.get_line().trim_right('\r') != 'y' {91 return 092 }93 94 return setup_wizard()95 }96 97 return set_remote(args)98}99100fn set_remote(args Args) int {101 if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {102 return set_kepr_remote(args)103 }104 if args.value('code') != '' {105 return usage_error('--code belongs to "koh remote <host>", not to folder or s3')106 }107 kind := args.positional_at(0)108 match kind {109 machine.kind_folder {110 path := args.positional_at(1)111 if path == '' {112 return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')113 }114 settings := machine.RemoteSettings{115 kind: machine.kind_folder116 path: os.real_path(path)117 }118 return save_after_check(settings, args, '', '')119 }120 machine.kind_s3 {121 endpoint := args.positional_at(1)122 bucket := args.positional_at(2)123 region := args.positional_at(3)124 if endpoint == '' || bucket == '' || region == '' {125 return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')126 }127 access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }128 settings := machine.RemoteSettings{129 kind: machine.kind_s3130 endpoint: endpoint131 bucket: bucket132 region: region133 }134 return save_after_check(settings, args, access_key, secret_key)135 }136 else {137 return usage_error('remote takes "folder", "s3", or a Kepr host')138 }139 }140}141142fn set_kepr_remote(args Args) int {143 if args.positional.len != 1 {144 return usage_error('koh remote <host> accepts one Kepr host')145 }146 host := remote.kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) }147 connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}'148 println('Open ${connect_url} and sign in')149 mut code := args.value('code')150 if code == '' {151 if !interactive() {152 return fail('setting up a Kepr remote needs a terminal, or use --code <code>')153 }154 155 156 157 158 159 mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' })160 browser.set_args([connect_url])161 browser.run()162 print('paste the code: ')163 code = os.get_line().trim_space()164 }165 166 response := http.fetch(167 url: '${host}/api/connect'168 method: .post169 header: http.new_header(170 key: .content_type171 value: 'application/x-www-form-urlencoded'172 )173 data: 'code=${urllib.query_escape(code)}'174 enable_http2: false175 allow_redirect: false176 ) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }177 if response.status_code == 404 {178 179 180 probe := http.fetch(181 url: connect_url182 method: .get183 enable_http2: false184 allow_redirect: false185 ) or {186 return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')187 }188 if probe.status_code == 404 {189 return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')190 }191 if probe.status_code != 302 {192 return refuse(args.json(), 'this Kepr answered ${probe.status_code} for its connect page — nothing was changed')193 }194 return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')195 }196 if response.status_code != 200 {197 return refuse(args.json(), 'Kepr refused the code (${response.status_code}) — nothing was changed')198 }199 reply := json2.decode[KeprReply](response.body) or {200 return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')201 }202 if reply.endpoint == '' || reply.bucket == '' || reply.region == ''203 || reply.access_key_id == '' || reply.secret_access_key == '' {204 return refuse(args.json(), 'Kepr returned incomplete settings — nothing was changed')205 }206 settings := machine.RemoteSettings{207 kind: machine.kind_s3208 endpoint: reply.endpoint209 bucket: reply.bucket210 region: reply.region211 kepr: host212 }213 return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)214}215216217218fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {219 mut backend := build_backend(settings, access_key, secret_key) or {220 return refuse(args.json(), err.msg())221 }222 remote.check(mut backend) or {223 return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')224 }225 machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }226 if settings.kind == machine.kind_s3 {227 machine.save_credentials(machine.Credentials{228 access_key: access_key229 secret_key: secret_key230 }) or { return refuse(args.json(), err.msg()) }231 }232 if args.json() {233 emit_json(json2.encode(RemoteJson{234 configured: true235 kind: settings.kind236 target: describe(settings)237 }))238 return 0239 }240 ok('remote set to ${describe(settings)}')241 note('every repo on this machine now offers to it')242 return 0243}244245fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {246 if settings.kind == machine.kind_folder {247 return remote.new_folder(settings.path)!248 }249 return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,250 secret_key)!251}252253fn setup_wizard() int {254 if !interactive() {255 return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')256 }257 println(' choose a remote:')258 println(' ${fmt.amber('1')} folder a directory, on a disk or a NAS')259 println(' ${fmt.amber('2')} s3 an S3-compatible bucket')260 print(' which? [1] ')261 if os.get_line().trim_space() == '2' {262 return wizard_s3()263 }264 print(' folder path: ')265 path := os.get_line().trim_space()266 if path == '' {267 return fail('no path given — nothing was saved')268 }269 settings := machine.RemoteSettings{270 kind: machine.kind_folder271 path: os.real_path(path)272 }273 return save_after_check(settings, Args{}, '', '')274}275276fn wizard_s3() int {277 print(' endpoint [https://s3.fr-par.scw.cloud]: ')278 endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')279 print(' bucket: ')280 bucket := os.get_line().trim_space()281 print(' region [fr-par]: ')282 region := or_default(os.get_line().trim_space(), 'fr-par')283 if bucket == '' {284 return fail('no bucket given — nothing was saved')285 }286 access_key, secret_key := s3_keys() or { return fail(err.msg()) }287 settings := machine.RemoteSettings{288 kind: machine.kind_s3289 endpoint: endpoint290 bucket: bucket291 region: region292 }293 return save_after_check(settings, Args{}, access_key, secret_key)294}295296297298fn s3_keys() !(string, string) {299 access_key := os.getenv('KOH_ACCESS_KEY')300 secret_key := os.getenv('KOH_SECRET_KEY')301 if access_key != '' && secret_key != '' {302 return access_key, secret_key303 }304 if !interactive() {305 return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')306 }307 print(' access key: ')308 access_key_typed := os.get_line().trim_space()309 310 311 mut secret_key_typed := ''312 mut saved := termios.Termios{}313 if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {314 315 316 317 secret_key_typed = os.input_password(' secret key: ') or {318 println('')319 if termios.set_state(0, saved) != 0 {320 warn('the terminal may no longer echo — run "stty sane"')321 }322 ''323 }324 } else {325 print(' secret key: ')326 secret_key_typed = os.get_line()327 }328 secret_key_typed = secret_key_typed.trim_space()329 if access_key_typed == '' || secret_key_typed == '' {330 return error('both keys are needed — nothing was saved')331 }332 return access_key_typed, secret_key_typed333}334335fn describe(settings machine.RemoteSettings) string {336 if settings.kind == machine.kind_folder {337 return 'folder ${settings.path}'338 }339 return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'340}341342fn or_default(value string, fallback string) string {343 return if value == '' { fallback } else { value }344}345346struct RemoteJson {347 configured bool348 kind string349 target string350}351352struct KeprReply {353 endpoint string354 bucket string355 region string356 access_key_id string357 secret_access_key string358}