1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package client is the Connect client of the remote CLI: it turns the
4// ERMITE_URL and ERMITE_TOKEN settings into authenticated service clients for
5// the ermite-api contract, maps Connect errors to one-line messages and keeps
6// a Watch stream alive across reconnections.
7//
8// It is a driving adapter: it never imports another adapter and never reads
9// the process environment itself, the caller passes a lookup function.
10package client
11
12import (
13 "errors"
14 "fmt"
15 "net"
16 "net/url"
17 "strings"
18 "time"
19)
20
21// Variable names.
22const (
23 // EnvURL is the base URL of the ermite server.
24 EnvURL = "ERMITE_URL"
25 // EnvToken is the bearer token sent with every request. Optional: without
26 // it the calls are anonymous.
27 EnvToken = "ERMITE_TOKEN"
28)
29
30const (
31 // APIPath is where the server mounts the Connect handlers.
32 APIPath = "/-/api"
33 // DefaultTimeout bounds one unary call.
34 DefaultTimeout = 30 * time.Second
35)
36
37// Config configures a Client.
38type Config struct {
39 // URL is the server address, http or https, without credentials. A path
40 // prefix is kept, for a server behind a sub-path proxy.
41 URL string
42 // Token is the bearer token. Empty means anonymous.
43 Token string
44 // Timeout bounds each unary call. Streams are not bounded. Zero means
45 // DefaultTimeout.
46 Timeout time.Duration
47 // UserAgent identifies the CLI, for example "ermite/v0.1.0 (cli)".
48 UserAgent string
49}
50
51// NewConfig validates the server address and the token, however they were
52// found (environment, config file or token command). The token is trimmed and
53// may be empty (anonymous). Timeout and UserAgent are left for the caller.
54func NewConfig(rawURL, token string) (Config, error) {
55 raw := strings.TrimSpace(rawURL)
56 if raw == "" {
57 return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL)
58 }
59 base, err := ParseURL(raw)
60 if err != nil {
61 return Config{}, fmt.Errorf("invalid server URL: %w", err)
62 }
63 return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil
64}
65
66// ParseURL validates a server address: an absolute http(s) URL with a host,
67// without credentials, query or fragment. The result has no trailing slash.
68// Errors never echo a URL that may carry credentials.
69func ParseURL(raw string) (*url.URL, error) {
70 u, err := url.Parse(raw)
71 if err != nil {
72 return nil, errors.New("not a valid URL")
73 }
74 switch {
75 case u.User != nil:
76 return nil, errors.New("must not contain credentials: use ERMITE_TOKEN for the token")
77 case u.Scheme != "http" && u.Scheme != "https":
78 return nil, errors.New("not an http(s) URL: it must start with http:// or https://")
79 case u.Hostname() == "":
80 return nil, errors.New("the URL has no host")
81 case u.RawQuery != "" || u.ForceQuery || u.Fragment != "":
82 return nil, errors.New("the URL must not contain a query or a fragment")
83 }
84 u.Scheme = strings.ToLower(u.Scheme)
85 u.Path = strings.TrimRight(u.Path, "/")
86 u.RawPath = ""
87 return u, nil
88}
89
90// InsecureTokenWarning returns a warning when the token would travel over
91// plain http to a host that is not on the loopback interface, and "" otherwise.
92//
93// It warns instead of refusing: ermite does not terminate TLS itself and is
94// commonly reached over a VPN or a private network, where http is a legitimate
95// deployment; a refusal would make those setups unusable. The warning keeps
96// the risk visible on every call.
97func (c Config) InsecureTokenWarning() string {
98 if c.Token == "" {
99 return ""
100 }
101 u, err := url.Parse(c.URL)
102 if err != nil || u.Scheme != "http" || isLoopback(u.Hostname()) {
103 return ""
104 }
105 return fmt.Sprintf("%s is sent over plain http to %s: anyone on the network path can read it; use an https:// URL unless the network is trusted", EnvToken, u.Hostname())
106}
107
108func isLoopback(host string) bool {
109 if host == "localhost" || strings.HasSuffix(host, ".localhost") {
110 return true
111 }
112 ip := net.ParseIP(host)
113 return ip != nil && ip.IsLoopback()
114}