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// ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates
52// them. Timeout and UserAgent are left for the caller to set.
53func ConfigFromEnv(getenv func(string) string) (Config, error) {
54 raw := strings.TrimSpace(getenv(EnvURL))
55 if raw == "" {
56 return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL)
57 }
58 base, err := ParseURL(raw)
59 if err != nil {
60 return Config{}, fmt.Errorf("%s: %w", EnvURL, err)
61 }
62 return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil
63}
64
65// ParseURL validates a server address: an absolute http(s) URL with a host,
66// without credentials, query or fragment. The result has no trailing slash.
67// Errors never echo a URL that may carry credentials.
68func ParseURL(raw string) (*url.URL, error) {
69 u, err := url.Parse(raw)
70 if err != nil {
71 return nil, errors.New("not a valid URL")
72 }
73 switch {
74 case u.User != nil:
75 return nil, errors.New("must not contain credentials: use ERMITE_TOKEN for the token")
76 case u.Scheme != "http" && u.Scheme != "https":
77 return nil, errors.New("not an http(s) URL: it must start with http:// or https://")
78 case u.Hostname() == "":
79 return nil, errors.New("the URL has no host")
80 case u.RawQuery != "" || u.ForceQuery || u.Fragment != "":
81 return nil, errors.New("the URL must not contain a query or a fragment")
82 }
83 u.Scheme = strings.ToLower(u.Scheme)
84 u.Path = strings.TrimRight(u.Path, "/")
85 u.RawPath = ""
86 return u, nil
87}
88
89// InsecureTokenWarning returns a warning when the token would travel over
90// plain http to a host that is not on the loopback interface, and "" otherwise.
91//
92// It warns instead of refusing: ermite does not terminate TLS itself and is
93// commonly reached over a VPN or a private network, where http is a legitimate
94// deployment; a refusal would make those setups unusable. The warning keeps
95// the risk visible on every call.
96func (c Config) InsecureTokenWarning() string {
97 if c.Token == "" {
98 return ""
99 }
100 u, err := url.Parse(c.URL)
101 if err != nil || u.Scheme != "http" || isLoopback(u.Hostname()) {
102 return ""
103 }
104 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())
105}
106
107func isLoopback(host string) bool {
108 if host == "localhost" || strings.HasSuffix(host, ".localhost") {
109 return true
110 }
111 ip := net.ParseIP(host)
112 return ip != nil && ip.IsLoopback()
113}