Repository navigation
Expand file tree
/
Copy pathinit.go
More file actions
366 lines (330 loc) · 11.3 KB
/
Copy pathinit.go
File metadata and controls
366 lines (330
8000
loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
package cmd
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/Devehab/linko/cloudflare"
"github.com/Devehab/linko/config"
"github.com/Devehab/linko/internal/cloudflared"
"github.com/Devehab/linko/internal/naming"
"github.com/Devehab/linko/internal/ui"
)
type initOptions struct {
token string
domain string
base string
tunnelName string
force bool
yes bool
skipDL bool
}
func newInitCmd() *cobra.Command {
opts := &initOptions{}
cmd := &cobra.Command{
Use: "init",
Short: "One-time setup: connect your Cloudflare account and create the tunnel",
Long: `init walks through the one-time setup:
1. store a Cloudflare API token
2. find the DNS zone for your domain
3. create (or reuse) a Zero Trust tunnel
4. save everything to ` + config.Path(),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := signalContext(cmd.Context())
defer cancel()
return runInit(ctx, opts)
},
}
f := cmd.Flags()
f.StringVar(&opts.token, "token", "", "Cloudflare API token (or set LINKO_API_TOKEN)")
f.StringVar(&opts.domain, "domain", "", "domain to use (default: pick from a list)")
f.StringVar(&opts.base, "base", "", "publish under a deeper subdomain — needs a paid certificate")
f.StringVar(&opts.tunnelName, "tunnel", "", "tunnel name (default <domain>-linko-tunnel)")
f.BoolVar(&opts.force, "force", false, "overwrite an existing configuration")
f.BoolVarP(&opts.yes, "yes", "y", false, "non-interactive: fail instead of prompting")
f.BoolVar(&opts.skipDL, "skip-download", false, "do not download cloudflared")
return cmd
}
func runInit(ctx context.Context, opts *initOptions) error {
p := ui.NewPrompter()
existing, _ := config.Load()
if config.Exists() && !opts.force {
ui.Warn("A configuration already exists at %s", config.Path())
if opts.yes {
return fmt.Errorf("refusing to overwrite an existing config (use --force)")
}
if !p.Confirm("Reconfigure linko?", false) {
ui.Info("Nothing changed.")
return nil
}
}
cfg := &config.Config{}
if existing != nil {
// Keep the routes we already published.
cfg.Routes = existing.Routes
}
ui.Header("Cloudflare credentials")
// 1. API token: --token, then $LINKO_API_TOKEN, then whatever is stored.
token := resolveToken(opts.token, existing)
if token == "" {
if opts.yes {
return fmt.Errorf("no API token: pass --token or set %s", config.EnvToken)
}
ui.Blank()
printTokenSteps()
var err error
token, err = p.AskSecret("Cloudflare API token:")
if err != nil {
return err
}
}
if token == "" {
return fmt.Errorf("an API token is required")
}
cfg.APIToken = token
client := cloudflare.New(token)
if _, err := client.VerifyToken(ctx); err != nil {
ui.Fail("Cloudflare rejected the token")
return err
}
ui.Success("Cloudflare connected")
// 2. Domain — picked from a numbered list of what the token can see.
ui.Header("Domain")
domain := strings.ToLower(strings.TrimSpace(opts.domain))
if domain == "" && existing != nil {
domain = existing.Domain
}
zone, err := chooseZone(ctx, client, p, domain, opts.yes)
if err != nil {
return err
}
cfg.Domain = zone.Name
cfg.ZoneID = zone.ID
cfg.AccountID = zone.Account.ID
cfg.AccountName = zone.Account.Name
client.ZoneID = zone.ID
client.AccountID = zone.Account.ID
if cfg.AccountID == "" {
accounts, aerr := client.ListAccounts(ctx)
if aerr != nil || len(accounts) == 0 {
return fmt.Errorf("could not determine the Cloudflare account id — make sure the token has Account → Cloudflare Tunnel → Edit")
}
cfg.AccountID = accounts[0].ID
cfg.AccountName = accounts[0].Name
client.AccountID = accounts[0].ID
}
// 3. Base. The domain you picked is the base — asking again was a
// question with one sensible answer, and the wrong answer produced URLs
// two labels deep that no free certificate covers. --base is still there
// for anyone who has Advanced Certificate Manager and wants the depth.
base := expandBase(strings.ToLower(strings.TrimSpace(opts.base)), cfg.Domain)
if err := validateBase(base, cfg.Domain); err != nil {
return err
}
cfg.BaseDomain = base
ui.Success("URLs will look like https://abc12.%s", base)
warnCertificateDepth(base, cfg.Domain)
// 4. Tunnel
ui.Header("Tunnel")
tunnelName := strings.TrimSpace(opts.tunnelName)
if tunnelName == "" && existing != nil {
tunnelName = existing.TunnelName
}
if tunnelName == "" {
tunnelName = defaultTunnelName(cfg.Domain)
if !opts.yes {
tunnelName, err = p.AskRequired("Tunnel name:", tunnelName, nil)
if err != nil {
return err
}
}
}
tunnel, err := client.FindTunnel(ctx, tunnelName)
if err != nil {
ui.Fail("Could not list the tunnels on this account")
explainTunnelFailure(err)
return err
}
if tunnel == nil {
tunnel, err = client.CreateTunnel(ctx, tunnelName)
if err != nil {
ui.Fail("Could not create the tunnel")
explainTunnelFailure(err)
return err
}
ui.Success("Tunnel created (%s)", tunnel.Name)
} else {
ui.Success("Tunnel reused (%s)", tunnel.Name)
}
cfg.TunnelID = tunnel.ID
cfg.TunnelName = tunnel.Name
tunnelToken, err := client.TunnelToken(ctx, tunnel.ID)
if err != nil {
return fmt.Errorf("fetching the tunnel token: %w", err)
}
cfg.TunnelToken = tunnelToken
// Make sure the remote config has a valid catch-all rule.
tunnelCfg, err := client.GetTunnelConfig(ctx, tunnel.ID)
if err != nil {
return fmt.Errorf("reading the tunnel configuration: %w", err)
}
if err := client.PutTunnelConfig(ctx, tunnel.ID, tunnelCfg); err != nil {
return fmt.Errorf("initialising the tunnel configuration: %w", err)
}
ui.Success("Tunnel configuration ready")
// 5. Save
if err := cfg.Save(); err != nil {
return err
}
ui.Success("Configuration saved to %s", config.Path())
// 6. cloudflared
if !opts.skipDL {
mgr := cloudflared.New(config.BinDir())
if path, lerr := mgr.Locate(); lerr == nil {
ui.Success("cloudflared found (%s)", path)
} else {
dctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
path, derr := mgr.Ensure(dctx, nil)
cancel()
if derr != nil {
ui.Warn("cloudflared could not be downloaded: %v", derr)
ui.Info("linko will try again the first time you run it.")
} else {
ui.Success("cloudflared installed (%s)", path)
}
}
}
ui.Blank()
ui.Line("%s", ui.Bold("You're ready."))
ui.Line(" %s publish localhost:3000 on a random subdomain", ui.Cyan("linko 3000"))
ui.Line(" %s publish it on crm.%s", ui.Cyan("linko 3000 -n crm"), cfg.BaseDomain)
ui.Blank()
ui.Line(" %s %s", ui.Dim("Guide:"), ui.Link(DocsURL, ui.Cyan(DocsURL)))
ui.Line(" %s", ui.Dim("or run: linko docs"))
ui.Blank()
return nil
}
// explainZoneFailure turns "no zone named X" into something actionable by
// showing what the token can actually see.
func explainZoneFailure(ctx context.Context, client *cloudflare.Client, wanted string) {
zones, err := client.ListZones(ctx)
if err != nil {
ui.Info("This token cannot list zones at all (%v).", err)
ui.Info("It is missing the Zone permission. Edit the token and add:")
ui.Info(" Zone → DNS → Edit, with Zone Resources including %s", wanted)
return
}
if len(zones) == 0 {
ui.Info("This token can authenticate but sees zero zones.")
ui.Info("Its Zone Resources are empty — edit the token and include %s.", wanted)
return
}
names := make([]string, 0, len(zones))
for _, z := range zones {
names = append(names, z.Name)
}
ui.Info("Zones this token can see: %s", strings.Join(names, ", "))
// A very common mistake: passing a subdomain where the zone apex is wanted.
if z, ok := cloudflare.ZoneNameFor(wanted, zones); ok {
ui.Info("%q sits inside the zone %q — pass --domain %s instead.", wanted, z.Name, z.Name)
}
}
// extraLabels counts how many labels base adds on top of the zone apex.
//
// extraLabels("example.com", "example.com") = 0
// extraLabels("demo.example.com", "example.com") = 1
func extraLabels(base, domain string) int {
base = strings.ToLower(strings.Trim(strings.TrimSpace(base), "."))
domain = strings.ToLower(strings.Trim(strings.TrimSpace(domain), "."))
if base == "" || domain == "" || base == domain {
return 0
}
prefix := strings.TrimSuffix(base, "."+domain)
if prefix == base {
return 0 // not inside the zone at all
}
return strings.Count(prefix, ".") + 1
}
// warnCertificateDepth catches the failure that costs the most time to
// diagnose. Cloudflare's free Universal SSL covers exactly example.com and
// *.example.com — one level. A base of demo.example.com therefore produces
// abc.demo.example.com, which no free certificate matches, and the browser
// fails the handshake outright with ERR_SSL_VERSION_OR_CIPHER_MISMATCH. DNS
// and the tunnel look perfectly healthy while this happens.
func warnCertificateDepth(base, domain string) {
if extraLabels(base, domain) == 0 {
return
}
ui.Blank()
ui.Warn("Your URLs will be two labels deep: %s", ui.Bold("<name>."+base))
ui.Info("Cloudflare's free Universal SSL only covers %s and *.%s,", domain, domain)
ui.Info("so HTTPS will fail with a certificate error even though DNS and")
ui.Info("the tunnel are fine.")
ui.Info("Fix it either way:")
ui.Info(" · re-run with %s (URLs become <name>.%s)", ui.Bold("--base "+domain), domain)
ui.Info(" · or enable Total TLS / Advanced Certificate Manager on %s", domain)
ui.Blank()
}
// explainTunnelFailure covers the other half of the permission story: a token
// scoped only to DNS gets through zone lookup and then fails here, again with
// a bare "Authentication error (code 10000)".
func explainTunnelFailure(err error) {
var apiErr *cloudflare.Error
if !errors.As(err, &apiErr) || !apiErr.IsAuth() {
return
}
ui.Blank()
ui.Info("This token can reach your DNS but not Zero Trust tunnels.")
ui.Info("linko needs BOTH permissions on the SAME token:")
ui.Info(" Zone → DNS → Edit")
ui.Info(" Account → Cloudflare Tunnel → Edit")
ui.Info("Edit it at https://dash.cloudflare.com/profile/api-tokens.")
ui.Blank()
}
// resolveToken picks the Cloudflare API token from, in order: the --token
// flag, the LINKO_API_TOKEN environment variable, and the stored config.
//
// Reading the environment here matters: config.Load applies the same override,
// but only when a config file already exists — so without this, `linko init
// --yes` on a fresh machine would reject a perfectly good LINKO_API_TOKEN.
func resolveToken(flagValue string, existing *config.Config) string {
if v := strings.TrimSpace(flagValue); v != "" {
return v
}
if v := strings.TrimSpace(os.Getenv(config.EnvToken)); v != "" {
return v
}
if existing != nil {
return strings.TrimSpace(existing.APIToken)
}
return ""
}
// expandBase turns "demo" into "demo.example.com".
func expandBase(base, domain string) string {
base = strings.ToLower(strings.Trim(strings.TrimSpace(base), "."))
if base == "" || base == "@" {
return domain
}
if base == domain || strings.HasSuffix(base, "."+domain) {
return base
}
return base + "." + domain
}
func validateBase(base, domain string) error {
base = expandBase(base, domain)
if base != domain && !strings.HasSuffix(base, "."+domain) {
return fmt.Errorf("%q must be inside %s", base, domain)
}
return naming.ValidateHostname(base)
}
func defaultTunnelName(domain string) string {
label := strings.SplitN(domain, ".", 2)[0]
if label == "" {
label = "linko"
}
return label + "-linko-tunnel"
}