Repository navigation
Expand file tree
/
Copy pathconfig.go
More file actions
1636 lines (1522 loc) · 78.1 KB
/
Copy pathconfig.go
File metadata and controls
1636 lines (1522 loc) · 78.1 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
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Package config loads and validates runtime configuration from the
// environment.
//
// Two properties matter more than the mechanics.
//
// Validation is aggregated rather than fail-on-first. An operator bringing up a
// self-hosted instance for the first time should see every problem in one run,
// not discover them one restart at a time.
//
// Secrets are typed as Secret, which refuses to print itself through fmt, slog
// or JSON. A config dump or a formatted panic cannot leak the database password
// or the API-key pepper.
package config
import (
"errors"
"fmt"
"net"
"net/mail"
"net/netip"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/caarlos0/env/v11"
"github.com/joho/godotenv"
)
// EnvPrefix is prepended to every variable name. POSTGRES_* variables consumed
// by the Postgres container itself are deliberately outside this prefix.
const EnvPrefix = "LINKCTRL_"
type Environment string
const (
Development Environment = "development"
Production Environment = "production"
)
func (e Environment) IsProduction() bool { return e == Production }
type Config struct {
AppEnv Environment `env:"APP_ENV" envDefault:"production"`
BaseURL string `env:"BASE_URL,required"`
// AppBaseURL and LinkBaseURL split the instance across two hostnames: the
// dashboard and API on one, short links on the other. Both default to
// BaseURL, so leaving them unset is the single-host deployment unchanged.
//
// After Load they are always populated, so callers use them rather than
// deciding for themselves whether the split is configured.
AppBaseURL string `env:"APP_BASE_URL"`
LinkBaseURL string `env:"LINK_BASE_URL"`
HTTP HTTPConfig
Log LogConfig
DB DBConfig
Redis RedisConfig
Redirect RedirectConfig
Domains DomainsConfig
Alias AliasConfig
Auth AuthConfig
Ingest IngestConfig
Analytics AnalyticsConfig
Audit AuditConfig
SMTP SMTPConfig
Feed FeedConfig
Webhooks WebhooksConfig
Addons AddonsConfig
Shutdown ShutdownConfig
APIKeyPepper Secret `env:"API_KEY_PEPPER,required,unset"`
// MFASecretKey encrypts the TOTP secret at rest (M53).
//
// **Its own variable, never the pepper**, which m53.md refuses by name. The
// pepper is bound to retained API-key rows and rotating it silently
// invalidates every issued key; sharing it would mean rotating an API-key
// secret also locks every account out of its second factor, coupling two
// credential lifecycles that have nothing to do with each other.
//
// **Optional, unlike the pepper, and the asymmetry is deliberate.** Unset is
// an instance with no second factor available, which is exactly what every
// deployment was before this milestone — making it required would refuse to
// boot every existing instance on upgrade to buy a feature nobody had asked
// for. Losing it after accounts have enrolled locks those accounts out of the
// second factor and no further: recovery codes are SHA-256 and do not involve
// this key, so an enrolled account signs in with one, disables the factor with
// another, and enrols again. docs/configuration.md states that chain beside
// the variable, in the same terms the pepper's consequence is stated in.
MFASecretKey Secret `env:"MFA_SECRET_KEY,unset"`
// UpdateCheck is whether this instance may ask, once a day, whether a newer
// LinkCtrl has been published (M55).
//
// **The deployment's half of a two-part switch, and it only ever says no.**
// The other half is `instance_settings.update_check_enabled`, which is the
// answer an operator gave when they were asked (D149, D164). The check runs
// when both allow it: this variable is what an air-gapped or egress-restricted
// deployment sets to `false` in the place such a deployment configures
// everything else, and setting it there cannot be undone from a browser by
// somebody who does not know why the box has no egress.
//
// **Default true, and true is permission rather than instruction.** The owner
// overruled a recommendation of off-by-default on the grounds that the
// operator is asked and therefore chooses knowingly (D149) — so what this
// default buys is that the question gets asked, not that the request gets
// made. The other half starts unanswered and reads as off (D164), which is
// where an instance upgrading into 0.3.0 sits until an administrator signs in.
// What the request carries is enumerated in docs/configuration.md beside this
// variable, and in internal/update's package comment, where a test holds the
// enumeration to the wire.
UpdateCheck bool `env:"UPDATE_CHECK" envDefault:"true"`
DocsEnabled bool `env:"DOCS_ENABLED" envDefault:"true"`
SecureCookies bool `env:"SECURE_COOKIES" envDefault:"true"`
MigrateOnStart bool `env:"MIGRATE_ON_START" envDefault:"true"`
// TrustedProxies must stay empty unless the app really is behind a proxy.
// A non-empty value makes the app believe X-Forwarded-For, which is how
// rate limiting and analytics get spoofed when it is set carelessly.
TrustedProxies []netip.Prefix `env:"TRUSTED_PROXIES" envSeparator:","`
// baseURL is the parsed form of BaseURL, computed once during Load.
baseURL *url.URL
// appBaseURL and linkBaseURL are the parsed effective origins. Never nil
// after Load; both fall back to baseURL.
appBaseURL *url.URL
// addonRouteDeadlineClamped records that Parse lowered a defaulted route
// deadline to nest inside the operator's request timeout. Read by main to say
// so once at startup.
addonRouteDeadlineClamped bool
linkBaseURL *url.URL
}
type HTTPConfig struct {
Addr string `env:"HTTP_ADDR" envDefault:":8080"`
MetricsAddr string `env:"METRICS_ADDR" envDefault:":9090"`
ReadHeaderTimeout time.Duration `env:"HTTP_READ_HEADER_TIMEOUT" envDefault:"5s"`
WriteTimeout time.Duration `env:"HTTP_WRITE_TIMEOUT" envDefault:"30s"`
RequestTimeout time.Duration `env:"HTTP_REQUEST_TIMEOUT" envDefault:"15s"`
ServerTiming bool `env:"SERVER_TIMING" envDefault:"false"`
}
type LogConfig struct {
Level string `env:"LOG_LEVEL" envDefault:"info"`
Format string `env:"LOG_FORMAT" envDefault:"json"`
}
type DBConfig struct {
URL Secret `env:"DATABASE_URL,required,unset"`
// Two pools. The redirect pool is small, separate, and exists so that a
// slow analytics query on the application pool cannot starve the hot path
// of connections. M13 asserts empirically that it does not.
MaxConns int32 `env:"DB_MAX_CONNS" envDefault:"20"`
MinConns int32 `env:"DB_MIN_CONNS" envDefault:"2"`
RedirectMaxConns int32 `env:"DB_REDIRECT_MAX_CONNS" envDefault:"6"`
MaxConnLifetime time.Duration `env:"DB_MAX_CONN_LIFETIME" envDefault:"1h"`
MaxConnIdleTime time.Duration `env:"DB_MAX_CONN_IDLE_TIME" envDefault:"15m"`
ConnectTimeout time.Duration `env:"DB_CONNECT_TIMEOUT" envDefault:"10s"`
}
type RedisConfig struct {
URL string `env:"REDIS_URL" envDefault:"redis://redis:6379/0"`
DialTimeout time.Duration `env:"REDIS_DIAL_TIMEOUT" envDefault:"1s"`
ReadTimeout time.Duration `env:"REDIS_READ_TIMEOUT" envDefault:"50ms"`
// InvalidateBudget is the total an edit will wait for the cache to be
// invalidated, across every retry rather than per attempt. The retry loop
// used to spend ReadTimeout three times over, so raising ReadTimeout
// tripled the worst case an operator saw on a form submission; this is the
// one number that bounds it. D26.
InvalidateBudget time.Duration `env:"REDIS_INVALIDATE_BUDGET" envDefault:"250ms"`
// SubscriberReadTimeout is how long the cache-invalidation subscriber will
// sit in one read before it makes Redis prove the subscription is still
// delivering. It is not ReadTimeout and cannot be: on the hot path a
// timeout means the cache failed, while here it usually means nobody has
// edited a link, which is the ordinary state of a healthy instance. F30,
// D42.
SubscriberReadTimeout time.Duration `env:"REDIS_SUBSCRIBER_READ_TIMEOUT" envDefault:"30s"`
PoolSize int `env:"REDIS_POOL_SIZE" envDefault:"50"`
CacheEnabled bool `env:"CACHE_ENABLED" envDefault:"true"`
}
type RedirectConfig struct {
TTL time.Duration `env:"REDIRECT_TTL" envDefault:"24h"`
NegativeTTL time.Duration `env:"REDIRECT_NEGATIVE_TTL" envDefault:"60s"`
Timeout time.Duration `env:"REDIRECT_TIMEOUT" envDefault:"250ms"`
DefaultStatus int `env:"REDIRECT_DEFAULT_STATUS" envDefault:"302"`
LogSample int `env:"REDIRECT_LOG_SAMPLE" envDefault:"0"`
NotFoundLimit int `env:"REDIRECT_404_RATE_LIMIT" envDefault:"60"`
// PasswordLimit caps guesses at a password link, per minute, per address
// *and* per alias (M35, D54). Twenty rather than the login limit's number:
// a person who has been handed a link and its password types it once, and a
// legitimate visitor never approaches this. Zero disables it, which on a
// public instance means a link password is only as strong as the wordlist
// somebody is willing to run.
PasswordLimit int `env:"LINK_PASSWORD_RATE_LIMIT" envDefault:"20"`
}
// DomainsConfig is custom-domain verification (M40).
//
// **Every value here is operator-visible on purpose, and decision D70 is why.**
// The grace window decides how long an instance keeps serving a hostname whose
// DNS its owner may no longer control, and a number with that consequence
// belongs in configuration and in the deployment runbook rather than in a
// constant somebody has to read the source to find.
type DomainsConfig struct {
// VerifyInterval is how often the leader re-checks every registered
// hostname. One hour: the point of the cadence is to make a single failure
// weak evidence and a sustained one strong, and at this rate a domain must
// fail twenty-four consecutive checks before serving stops. Zero disables
// the job entirely, which leaves verification on-demand only.
VerifyInterval time.Duration `env:"DOMAIN_VERIFY_INTERVAL" envDefault:"1h"`
// VerifyGrace is how long a *serving* hostname keeps serving after its first
// failed check. Twenty-four hours: long enough that somebody woken by the
// notification has a working day to fix their DNS, short enough that the
// window is stated in the runbook as "one day" rather than as a calculation.
// It is never zero — an unset or zero value takes the default, because a
// zero window would turn one resolver hiccup into an outage.
VerifyGrace time.Duration `env:"DOMAIN_VERIFY_GRACE" envDefault:"24h"`
// VerifyDNSTimeout bounds one TXT lookup. A nameserver that accepts a query
// and never answers must cost this and not the whole pass.
VerifyDNSTimeout time.Duration `env:"DOMAIN_VERIFY_DNS_TIMEOUT" envDefault:"5s"`
// VerifyBatch caps how many hostnames one pass checks, oldest check first.
// A bound rather than a limit anybody is expected to reach: it is what keeps
// an instance with ten thousand registrations from turning one job run into
// ten thousand DNS queries.
VerifyBatch int `env:"DOMAIN_VERIFY_BATCH" envDefault:"500"`
}
type AliasConfig struct {
Length int `env:"ALIAS_LENGTH" envDefault:"7"`
MinUserLength int `env:"ALIAS_MIN_USER_LENGTH" envDefault:"3"`
ReservedExtra []string `env:"ALIAS_RESERVED_EXTRA" envSeparator:","`
ProfanityFilter bool `env:"ALIAS_PROFANITY_FILTER" envDefault:"true"`
// DestSchemes may narrow the scheme allowlist and may never widen it:
// Validate refuses anything outside {http, https}. Non-http(s) schemes are
// the unappealable tier (M30), and a variable that could add "javascript"
// back would be an override switch on a tier documented as having none.
DestSchemes []string `env:"DESTINATION_SCHEMES" envSeparator:"," envDefault:"http,https"`
DestMaxLength int `env:"DESTINATION_MAX_LENGTH" envDefault:"2048"`
// DestBlocklist is the operator's own host list. Since M30 it seeds the
// runtime Postgres blocklist at boot rather than being consulted in memory,
// and it is reconciled on every boot — an entry removed from here is
// retired, and nothing the owner added through review is touched.
DestBlocklist []string `env:"DESTINATION_BLOCKLIST" envSeparator:","`
}
type SignupMode string
const (
SignupClosed SignupMode = "closed"
SignupInvite SignupMode = "invite"
SignupOpen SignupMode = "open"
)
type AuthConfig struct {
SignupMode SignupMode `env:"SIGNUP_MODE" envDefault:"closed"`
SessionAbsoluteTTL time.Duration `env:"SESSION_ABSOLUTE_TTL" envDefault:"720h"`
SessionIdleTTL time.Duration `env:"SESSION_IDLE_TTL" envDefault:"168h"`
// InviteTTL is how long an invitation stays redeemable, measured from when
// it was created (decision D29).
//
// A knob rather than a constant, for the reason D5 refused a constant for
// audit retention: time is the one thing an operator cannot work around
// without a rebuild. The clock starts at creation and not at delivery,
// because mail leaves through the outbox on the scheduler's tick (D23) and
// there is no send moment to start it from — so a slow relay spends the
// operator's TTL, which is exactly why it is tunable.
InviteTTL time.Duration `env:"INVITE_TTL" envDefault:"168h"`
// RFC 9106 recommends at least 19 MiB for the memory-constrained profile;
// 64 MiB is the comfortable default. Validate enforces the floor, because
// lowering this is the easiest way to silently weaken password storage.
Argon2MemoryKiB uint32 `env:"ARGON2_MEMORY_KIB" envDefault:"65536"`
Argon2Iterations uint32 `env:"ARGON2_ITERATIONS" envDefault:"3"`
Argon2Parallelism uint8 `env:"ARGON2_PARALLELISM" envDefault:"2"`
LoginRatePerMin int `env:"LOGIN_RATE_PER_MIN" envDefault:"10"`
LockoutThreshold int `env:"LOGIN_LOCKOUT_THRESHOLD" envDefault:"5"`
APIRatePerMin int `env:"API_RATE_PER_MIN" envDefault:"600"`
// UploadRatePerMin bounds how often one address may upload a file (M50.5).
//
// **A bucket of its own because an upload is not an API call.** Every other
// request under `/api/v1` carries a body this product caps at 256 KiB and
// parses as JSON; an upload carries a file, which is where a request's cost
// is set by its content rather than by its shape. `API_RATE_PER_MIN` defaults
// to 600, and 600 megabyte uploads a minute is a bandwidth and decoder budget
// nobody chose by setting a number about JSON.
//
// **Two endpoints charge it since M67**, and they are not the same size: a
// logo is `qr.MaxLogoUploadBytes` and is decoded, an add-on install is
// `addon.MaxUploadBytes` and is compiled. One bucket for both, because what
// the bucket is about is true of both and a second number would be a second
// thing to tune. See docs/configuration.md's row for what that costs.
//
// Thirty is what somebody restyling a poster does — upload, look, upload
// again — with room to spare. It charges the *address* like every other
// limit here rather than the workspace: the resource being protected is this
// instance's, and an attacker with one account has as many addresses as they
// have hosts either way.
UploadRatePerMin int `env:"UPLOAD_RATE_PER_MIN" envDefault:"30"`
}
// IngestConfig tunes the click pipeline.
//
// There is deliberately no worker count. One consumer is what makes batch
// coalescing work — a second would split every batch and interleave the writes —
// so the knob that used to be here was removed rather than implemented. See
// Removed.
type IngestConfig struct {
QueueSize int `env:"INGEST_QUEUE_SIZE" envDefault:"16384"`
BatchSize int `env:"INGEST_BATCH_SIZE" envDefault:"500"`
FlushInterval time.Duration `env:"INGEST_FLUSH_INTERVAL" envDefault:"250ms"`
}
// AnalyticsConfig tunes analytics storage and enrichment.
//
// Salt rotation and bot classification are not configurable, and that is a
// design decision rather than an omission: the daily rotation is what the purge
// window de-identifies against, and bots are always classified because the
// control that matters — keeping them out of headline figures — is in the
// queries. See Removed.
type AnalyticsConfig struct {
RetentionDays int `env:"ANALYTICS_RETENTION_DAYS" envDefault:"395"`
GeoIPPath string `env:"GEOIP_MMDB_PATH"`
}
// AuditConfig is the audit log's retention policy, which is deliberately its
// own setting rather than a share of the analytics window.
//
// The default is 0 — keep forever — and it is different from the analytics
// default on purpose. Both choices are a data-loss policy, and they fail in
// opposite directions: a finite window means an upgrade silently starts
// deleting history an operator assumed permanent, while keep-forever means
// unbounded growth. The first failure is invisible and irreversible; the second
// is visible and recoverable, and linkctrl_audit_log_bytes plus the alert recipe
// in docs/operations.md are what make it visible. See decisions.md, D5.
type AuditConfig struct {
RetentionDays int `env:"AUDIT_RETENTION_DAYS" envDefault:"0"`
// SizeWarnBytes raises an owner notification once the audit partitions pass
// it. 5 GB, and **on by default** — which is the asymmetry with
// RetentionDays above, not an inconsistency (D19).
//
// The two defaults protect against opposite failures. Retention defaults to
// inaction because acting unasked destroys data. The warning defaults to
// acting because inaction is what leaves the operator uninformed, and
// keep-forever is only a safe default on an instance nobody configured if
// that instance is the one being warned. A threshold that had to be
// switched on would be no threshold at all for exactly the operators who
// need it.
//
// 0 disables it, for an operator who has decided and does not want reminding.
SizeWarnBytes int64 `env:"AUDIT_SIZE_WARN_BYTES" envDefault:"5368709120"`
}
// TLS modes for the mailer. Three, and no more: the honest set is "the two ways
// a modern submission server listens, plus a local relay that does not".
const (
// SMTPStartTLS is submission on 587: connect in clear, then upgrade. The
// default, because it is what almost every provider documents.
SMTPStartTLS = "starttls"
// SMTPImplicit is SMTPS on 465: TLS from the first byte.
SMTPImplicit = "tls"
// SMTPNone is no encryption at all, for a relay on the same host or the same
// private network. Credentials are refused in this mode.
SMTPNone = "none"
)
// SMTPConfig is the optional mailer. Off unless Host is set.
//
// The surface is deliberately small. TLS modes and auth mechanisms are where a
// mail configuration turns into a compatibility matrix, so this ships the set it
// can honestly claim — STARTTLS, implicit TLS, or nothing, with PLAIN auth over
// an encrypted connection — and documents the rest as unsupported rather than
// implying it works and failing at the first send.
type SMTPConfig struct {
// Host is the switch. Empty means no mailer, which is the default and the
// state every consumer must degrade to.
Host string `env:"SMTP_HOST"`
Port int `env:"SMTP_PORT" envDefault:"587"`
// Username and Password authenticate with PLAIN. Both or neither.
Username string `env:"SMTP_USERNAME"`
Password Secret `env:"SMTP_PASSWORD,unset"`
// From is the envelope sender and the From header. Required once Host is
// set: a message with no sender is refused by most receivers, and finding
// that out from a bounce is worse than finding it out at boot.
From string `env:"SMTP_FROM"`
TLS string `env:"SMTP_TLS" envDefault:"starttls"`
// Timeout bounds one delivery attempt end to end — dial, handshake, DATA.
// A hung relay must not hold the scheduler.
Timeout time.Duration `env:"SMTP_TIMEOUT" envDefault:"10s"`
}
// Enabled reports whether a mailer is configured. The one question every
// consumer asks, so it is a method rather than a comparison repeated five times.
func (s SMTPConfig) Enabled() bool { return s.Host != "" }
// Addr is the host:port to dial.
func (s SMTPConfig) Addr() string { return net.JoinHostPort(s.Host, strconv.Itoa(s.Port)) }
// FeedConfig is the optional third-party reputation feed (M32). Off unless URL
// is set, and off is the default.
//
// This is the only setting in this file whose default is chosen by a promise
// rather than by an engineering trade. Every other blocking decision this
// product makes is local — a compiled host list, a Postgres table, heuristics
// that read a URL's own text. Answering *is this destination malicious* means
// sending the destination to somebody else's server, which is a deliberate
// exception to Plan.md's "no destination leaves the box uninvited" and is why
// switching it on costs an operator a named feed rather than a boolean.
//
// FeedName is required alongside FeedURL for the same reason: the disclosure
// this feature ships names the third party, and a disclosure that cannot is not
// one. See docs/build-notes/decisions.md, D40.
type FeedConfig struct {
// URL is the endpoint, and the switch. Empty means no feed, no client, and
// no code path that sends a destination anywhere.
URL string `env:"FEED_URL"`
// Name is the third party in words — "Google Safe Browsing", "urlscan.io" —
// as the disclosure page and the docs print it.
Name string `env:"FEED_NAME"`
// Method is GET or POST. POST by default, which is what most reputation
// APIs take and which keeps the destination out of the feed's access log
// query string.
Method string `env:"FEED_METHOD" envDefault:"POST"`
// Param names the field carrying the destination: a query parameter on GET,
// a JSON key on POST.
Param string `env:"FEED_PARAM" envDefault:"url"`
// VerdictField is the dotted path into the JSON response holding the
// answer, e.g. "data.malicious".
VerdictField string `env:"FEED_VERDICT_FIELD" envDefault:"blocked"`
// AuthHeader and AuthToken authenticate to the feed. The header is only
// sent when the token is set.
AuthHeader string `env:"FEED_AUTH_HEADER" envDefault:"Authorization"`
AuthToken Secret `env:"FEED_AUTH_TOKEN,unset"`
// Timeout bounds one check. Spent inside a link creation somebody is
// waiting on, so it is small: two seconds is long enough for a healthy API
// on another continent and short enough that a sick one is not felt as the
// dashboard being broken.
Timeout time.Duration `env:"FEED_TIMEOUT" envDefault:"2s"`
}
// Enabled reports whether a feed is configured. The one question every consumer
// asks, so it is a method rather than a comparison repeated in four places.
func (f FeedConfig) Enabled() bool { return f.URL != "" }
// WebhooksConfig is outbound webhook delivery (M42).
//
// Two numbers, both operator-visible for the reason D70 made the domain
// verification numbers visible: each has a consequence somebody deploying this
// has to be able to see and change. The timeout decides how long one unresponsive
// receiver holds a delivery slot, and the retention window decides how long the
// delivery log — one row per link write per enabled webhook — is kept before it
// is pruned.
//
// The **attempt count is not here**, and that is deliberate rather than an
// omission. It is `webhook.MaxAttempts`, six, and it is documented in
// docs/usage.md: unlike the two below, changing it changes what a *receiver*
// experiences — how long a delivery can arrive late — which is a contract with
// somebody who does not read this instance's environment. An operator who wants a
// different one is asking for a different contract, and should say so in a
// release rather than in a variable.
type WebhooksConfig struct {
// Timeout bounds one delivery attempt end to end: connect, write, read. Ten
// seconds is long enough for a receiver that does real work before answering
// and short enough that a batch of twenty slow ones fits well inside the
// job's own bound.
Timeout time.Duration `env:"WEBHOOK_TIMEOUT" envDefault:"10s"`
// RetentionDays is how long a delivered or abandoned delivery row is kept.
// Thirty days, matching the mail outbox, because the two are the same kind
// of record: what was attempted and what happened, not an archive.
//
// Never zero. Zero means "keep forever" elsewhere in this file (audit
// retention, D5), and a table that grows by one row per link write per
// webhook with no window is the growth problem that convention exists to
// make visible rather than to permit here. Validate refuses it.
RetentionDays int `env:"WEBHOOK_RETENTION_DAYS" envDefault:"30"`
}
// AddonsConfig is where the WASM host looks for add-ons (M60).
type AddonsConfig struct {
// Dir is an operator-owned directory holding one subdirectory per add-on,
// each with an addon.json and the .wasm it describes.
//
// **Unset is the shipped default and it means there is no host**: no WASM
// runtime is constructed, no goroutine started, no route mounted, no table
// created and no metric series published. That is asserted by tests in
// internal/addon rather than promised here, because "off costs nothing" is
// the kind of claim that stops being true one milestone after somebody writes
// it down.
//
// Operator-owned rather than a path an add-on or a tenant can influence: a
// module in this directory is code this instance executes, so who may write
// to it is the whole of the trust boundary. docs/SECURITY.md states that in
// the same terms.
Dir string `env:"ADDONS_DIR"`
// InlineDeadline is how long an add-on holding `redirect.inline` may keep a
// redirect open before the host stops waiting for it, kills the invocation and
// answers the visitor without it (M66).
//
// **One knob for the instance, with no per-add-on override**, which was fixed
// as the shape of this answer a phase before the number existed: a second
// number per add-on would be an operator choosing a latency budget per module
// with no more information than they had for the first, and the case that
// argues for one has not arrived.
//
// The default is measured rather than chosen — see addon.DefaultInlineDeadline
// and the runs in docs/slo.md — and it is deliberately larger than the 20 ms
// cached-redirect target. The target is core's, measured with nothing on the
// path; this is the point at which the host stops waiting for somebody else's
// code, and setting it under the target would kill add-ons that were working.
//
// It bounds the **guest call and nothing else**. Starting the module is this
// host's own cost on this host's machine, so it is bounded separately by
// [AddonsConfig.InstantiateDeadline] below. The two shipped as one number and
// that was F326: on a machine slower than the one 25 ms was measured on, every
// invocation was killed before the add-on's code ran, and the counter blamed
// the add-on.
InlineDeadline time.Duration `env:"ADDON_INLINE_DEADLINE" envDefault:"25ms"`
// InstantiateDeadline is how long this instance will spend starting an add-on's
// module for a redirect — inline or observing — before it gives up and serves
// the redirect without it (M66, reopened; D327).
//
// **Separate from the deadline above because it is somebody else's cost.** What
// a module does is the add-on's and is bounded by a number an operator sets
// against their tolerance for latency; what instantiating costs is a property
// of this machine, this load and this build, none of which the add-on chose.
// Charging it to the add-on's budget made the add-on's budget a function of how
// fast the hardware is, which is what F326 found on a CI runner.
//
// **Wider, and not borrowed from either number that already exists.**
// addon.DefaultLoadTimeout bounds a module that hangs at boot at 30 seconds and
// no redirect may wait that; the inline deadline is the number that proved too
// small. That first bound is a **constant**, not a variable: this sentence
// named a `LINKCTRL_ADDON_LOAD_TIMEOUT` until 0.4.0 and no such variable has
// ever existed — there is no struct field, no `env` tag, no `.env.example`
// line and no row in docs/configuration.md, and `Options.LoadTimeout` is set by
// tests and by nothing else (F327). An operator who read the reasoning and
// acted on it set nothing, and the module that hangs at boot still held the
// boot for thirty seconds. Whether that bound *should* be an operator's is a
// question this correction deliberately does not answer, because adding a
// configuration surface is not a comment's to decide. 500 ms is eight times instantiation measured under contention on
// the machine the figure was taken on, and it is what a module hanging in
// package initialization costs the one redirect it arrived on — see
// addon.DefaultInstantiateDeadline for the measurement and the arithmetic. It
// leans wide on purpose: a bound that is too small stops add-ons running and
// blames them for it, which is the defect this variable exists because of.
InstantiateDeadline time.Duration `env:"ADDON_INSTANTIATE_DEADLINE" envDefault:"500ms"`
// PoolSize is how many add-on instances this instance keeps ready, across every
// add-on, when nothing is using them (M66.5).
//
// **It is not a concurrency bound**, and the difference is the whole reason it
// is its own variable. How many add-on invocations run at once is fixed at
// sixteen in the build and no variable moves it; this is how many of the
// instances they run in are kept afterwards instead of being destroyed. An
// invocation that finds the pool empty makes an instance, exactly as every
// invocation did before this existed.
//
// What it buys is the startup: a redirect through a pooled instance skips
// allocating the module's linear memory and running its package initialization,
// which for the fixture measured in docs/slo.md was almost the whole of an
// 11.05 ms invocation — 451 µs once it was pooled. What it costs is memory held at rest — up to this many instances
// of 8 MiB, on top of the sixteen in flight — which is why the default is small
// rather than generous. See addon.DefaultPoolSize for the arithmetic.
PoolSize int `env:"ADDON_POOL_SIZE" envDefault:"8"`
// PoolTTL is how long an unused add-on instance is kept before it is closed
// (M66.5).
//
// It is what makes the idle cost proportional to traffic rather than to the
// busiest minute since the process started. On an instance with a redirect an
// hour it means the pool holds nothing; on one with a redirect a second it means
// the sweep never finds an idle entry at all.
PoolTTL time.Duration `env:"ADDON_POOL_TTL" envDefault:"1m"`
// RouteDeadline is how long one request to an add-on's own page may take
// (M68.5), start to finish: instantiating the module, running its handler and
// every host call inside it, including an outbound fetch.
//
// **It is a bound inside HTTP_REQUEST_TIMEOUT, not the first bound a route
// handler ever had.** A route runs under the application tree's request
// context, which [HTTPConfig.RequestTimeout] already cancels at fifteen
// seconds — so this only ever fires if it is shorter, which [Config.Validate]
// enforces. Ten seconds leaves the host five to turn a killed guest into a page
// and a counter instead of dying with it, holds three fetches at FetchTimeout,
// and is the only bound at all on an instance that has set HTTP_REQUEST_TIMEOUT
// to zero. See addon.DefaultRouteDeadline for what the earlier fifteen got
// wrong and how it was measured.
RouteDeadline time.Duration `env:"ADDON_ROUTE_DEADLINE" envDefault:"10s"`
// FetchTimeout bounds one outbound request an add-on makes (M68.5), connect
// through the last byte of the body.
//
// It is a ceiling and not a reservation: a fetch ends at this or at whatever is
// left of RouteDeadline, whichever comes first, so an add-on cannot buy time by
// reaching outward. Three seconds is an order of magnitude over what this
// capability is for and is what lets three of them fit inside RouteDeadline —
// see addon.DefaultFetchTimeout for the documents that were measured.
FetchTimeout time.Duration `env:"ADDON_FETCH_TIMEOUT" envDefault:"3s"`
// FetchMaxBytes is the largest response body an add-on's fetch will carry back
// (M68.5). A response over it is refused whole rather than truncated, because a
// truncated JSON document is a parse error blamed on the add-on's author.
FetchMaxBytes int64 `env:"ADDON_FETCH_MAX_BYTES" envDefault:"262144"`
}
// Enabled reports whether this instance has an add-on host at all.
func (a AddonsConfig) Enabled() bool { return a.Dir != "" }
// AddonEnvPrefix is where an add-on's configured settings are read from:
// LINKCTRL_ADDON_<NAME>_<SETTING>, both halves upper-cased.
//
// The same environment every other value in this file comes from, deliberately —
// m64.md's "config reaches an add-on the way it reaches the product". An add-on's
// settings cannot be struct fields, because which of them exist is decided by a
// manifest an operator dropped in a directory rather than by this build, so
// [AddonSettings] reads them by name instead of by tag. That is the whole of the
// difference, and it costs two things worth knowing:
//
// - `.env.example` cannot enumerate them, so the reference documents the shape
// and surface_test.go carves the prefix out by name rather than by accident;
// - the `unset` treatment the env library gives this file's own secrets does not
// reach them. A value read here stays in the process environment, because the
// add-on host may be opened more than once in one process and a variable
// consumed by the first open would be missing from the second. What does reach
// them is the [Secret] type: every value comes back wrapped, whatever the
// manifest called it, so no value an operator configured can print itself
// through fmt, slog or json.
const AddonEnvPrefix = EnvPrefix + "ADDON_"
// AddonSettingVar is the variable one setting of one add-on is read from.
func AddonSettingVar(addon, setting string) string {
return AddonEnvPrefix + strings.ToUpper(addon) + "_" + strings.ToUpper(setting)
}
// AddonOverrideNames are the two per-add-on variables that are **not** settings:
// they are answers an operator gives about an add-on rather than values an add-on
// reads, and no add-on may declare a setting by either name.
//
// They live in the same LINKCTRL_ADDON_<NAME>_<X> namespace deliberately — an
// operator configuring an add-on should not have to learn a second prefix — which
// is why the collision has to be closed rather than tolerated: without the
// reservation, LINKCTRL_ADDON_OIDC_FAILURE_CLASS would be the operator's answer
// and a declared setting called `failure_class` at the same time, and no lookup
// could tell which was meant. internal/addon's manifest validation refuses a
// manifest declaring either name, so the ambiguity does not exist rather than
// being resolved.
//
// - `failure_class` overrides what the manifest declared. It is the escape hatch
// m65.md requires: an add-on holding `session.mint` is treated as `required`
// whatever its manifest says, and this is how an operator says otherwise,
// knowing that external sign-in then disappears on a failed load while local
// sign-in continues.
// - `mfa_satisfied` says the provider behind this add-on already met a second
// factor. False is the default and the safe reading: an account with TOTP
// enrolled meets its factor after an add-on's assertion rather than instead of
// it.
var AddonOverrideNames = []string{"failure_class", "mfa_satisfied"}
// AddonReservedNames are add-on names no manifest may take, because this file
// already spells a variable that a setting of that add-on would spell too.
//
// Five entries. `LINKCTRL_ADDON_INLINE_DEADLINE`
// ([AddonsConfig.InlineDeadline]) and `LINKCTRL_ADDON_INSTANTIATE_DEADLINE`
// ([AddonsConfig.InstantiateDeadline]) are instance-wide, and each is also exactly
// what a setting called `deadline` on an add-on called `inline` or `instantiate`
// would be read from. `pool` covers two at once —
// `LINKCTRL_ADDON_POOL_SIZE` and `LINKCTRL_ADDON_POOL_TTL`
// ([AddonsConfig.PoolSize], [AddonsConfig.PoolTTL]) — which is why both variables
// keep their second half to one word: `LINKCTRL_ADDON_POOL_IDLE_TIMEOUT` would
// also be an add-on called `pool_idle` with a setting called `timeout`, and one
// reserved name could not close both readings. M68.5 added the last two the same
// way: `route` for `LINKCTRL_ADDON_ROUTE_DEADLINE`
// ([AddonsConfig.RouteDeadline]), and `fetch` for `LINKCTRL_ADDON_FETCH_TIMEOUT`
// and `LINKCTRL_ADDON_FETCH_MAX_BYTES` ([AddonsConfig.FetchTimeout],
// [AddonsConfig.FetchMaxBytes]) — which is one reserved name covering two
// variables again, and is why the second of them is `max_bytes` and not
// `maximum_response_bytes`.
// The collision is the same one [AddonOverrideNames] closes and it is closed the
// same way — the ambiguity is made not to exist rather than resolved, because a
// concatenation offers nothing to resolve it with.
//
// It is a reserved **name** rather than a reserved setting because the variable
// is not per-add-on: there is no add-on it belongs to, so refusing the setting
// `deadline` on every add-on would be a far wider reservation bought for the same
// collision. internal/addon's manifest validation is where it is refused, for the
// reason the override names are refused there — this package is imported by that
// one and not the other way round.
var AddonReservedNames = []string{"fetch", "inline", "instantiate", "pool", "route"}
// AddonOverrides reads the operator's answers about one add-on.
//
// Read by name, exactly like [AddonSettings] and never by scanning the
// environment for a prefix, and for the same reason: a scan would hand an add-on
// every variable under its name including a neighbour's. A variable that is set
// and empty is treated as unset, which is what an operator leaving a line in their
// .env with nothing after the `=` means.
func AddonOverrides(addon string) map[string]string {
out := make(map[string]string, len(AddonOverrideNames))
for _, name := range AddonOverrideNames {
if v := os.Getenv(AddonSettingVar(addon, name)); v != "" {
out[name] = v
}
}
return out
}
// AddonSettings reads the values an operator configured for one add-on.
//
// Asked for the settings the manifest **declares**, and it reads exactly those —
// never a scan of the environment for a matching prefix. That is not tidiness: a
// prefix scan would hand an add-on every variable under its name, including the
// ones a neighbour's name reaches into, and would have to guess who meant what.
// An add-on that declared nothing reads nothing.
//
// Declaring does not make the *variable* unambiguous, and this comment used to
// say it did. `LINKCTRL_ADDON_OIDC_X_KEY` is `x_key` of `oidc` and `key` of
// `oidc_x` — both legal names — whichever way it is looked up, because the
// variable is a concatenation and the name is one half of it. What resolves it is
// that the two add-ons cannot both be loaded: names standing in a `name + "_"`
// prefix relation are refused at load, in nameCollisions (internal/addon), where
// the same relation closes the cookie namespace. So no two *loaded* add-ons
// produce one variable, which is the property this function needs and the only one
// it has.
//
// A variable that is set and empty is treated as unset, which is what an operator
// leaving a line in their .env with nothing after the `=` means. The add-on then
// gets its declared default, or ErrNotFound.
func AddonSettings(addon string, declared []string) map[string]Secret {
out := make(map[string]Secret, len(declared))
for _, name := range declared {
if v := os.Getenv(AddonSettingVar(addon, name)); v != "" {
out[name] = Secret(v)
}
}
return out
}
type ShutdownConfig struct {
DrainDelay time.Duration `env:"SHUTDOWN_DRAIN_DELAY" envDefault:"5s"`
Timeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
}
// Load reads configuration from the environment and validates it.
//
// A .env file is honoured only in development, and only when APP_ENV says so
// before the file is read. A stray .env on a production host must not be able
// to change how the service runs.
func Load() (Config, error) {
if Environment(os.Getenv(EnvPrefix+"APP_ENV")) == Development {
if _, err := os.Stat(".env"); err == nil {
if err := godotenv.Load(); err != nil {
return Config{}, fmt.Errorf("load .env: %w", err)
}
}
}
return Parse()
}
// FileSecretVars are the variables that additionally support a _FILE suffix,
// for Docker and Swarm secrets mounted under /run/secrets.
var FileSecretVars = []string{
"API_KEY_PEPPER",
"MFA_SECRET_KEY",
"DATABASE_URL",
"SMTP_PASSWORD",
"FEED_AUTH_TOKEN",
}
// resolveFileSecrets implements the LINKCTRL_X_FILE convention: when set, the
// file at that path is read and its contents become LINKCTRL_X.
//
// This is hand-rolled rather than delegated to the env library's "file" option,
// which has different semantics — there, the variable's own value is the path,
// so there is no way to supply a secret inline. Both forms need to work: inline
// for a plain .env, and _FILE for orchestrators that mount secrets as files.
//
// The trailing newline is trimmed deliberately. `echo secret > secret.txt` adds
// one, and a password with a trailing newline fails authentication for exactly
// the same invisible-character reason a CRLF in .env does.
func resolveFileSecrets() error {
var errs []error
for _, name := range FileSecretVars {
fileVar := EnvPrefix + name + "_FILE"
path := os.Getenv(fileVar)
if path == "" {
continue
}
direct := EnvPrefix + name
if os.Getenv(direct) != "" {
errs = append(errs, fmt.Errorf(
"%s and %s are both set; supply the secret one way or the other", direct, fileVar))
continue
}
// The path is the whole point: an operator sets LINKCTRL_X_FILE to say
// which file holds the secret, so reading a variable path is the
// feature. Anyone who can set the process environment can already read
// what the process reads.
b, err := os.ReadFile(path) //nolint:gosec // G304: operator-supplied path, by design
if err != nil {
errs = append(errs, fmt.Errorf("%s: cannot read %q: %w", fileVar, path, err))
continue
}
if err := os.Setenv(direct, strings.TrimRight(string(b), "\r\n")); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", direct, err))
}
}
return errors.Join(errs...)
}
// Removed names variables that once existed and no longer do, each with the
// behaviour that is now fixed.
//
// Kept as data rather than deleted quietly, because silent removal reproduces
// the defect it is fixing from the other side: the operator still has the line
// in their .env and still believes it does something. Startup reports these as
// warnings rather than errors — an upgrade must not refuse to boot over a stale
// line in a file.
var Removed = map[string]string{
"SECRET_KEY": "nothing was keyed by it. Sessions use random 32-byte tokens " +
"stored as SHA-256, CSRF is origin-based, and API keys use API_KEY_PEPPER; " +
"rotating this changed nothing, which is the opposite of what a variable " +
"with this name promises",
// SMTP_PASSWORD was here, and is not any more. It was removed in Phase 1
// because there was no mail feature to authenticate to; M26 built one, so
// the variable is read again and a warning that it does nothing would now be
// the lie the Removed list exists to prevent. Entries leave this map when
// the behaviour comes back, which is the only reason one ever should.
"INGEST_WORKERS": "the ingester runs a single consumer, which is what makes " +
"batch coalescing work; a worker count would break it",
"VISITOR_SALT_ROTATION": "visitor salts rotate once per UTC day, which is the " +
"period the purge window de-identifies against",
"BOT_FILTER_ENABLED": "bots are always classified and recorded; headline " +
"figures exclude them in the queries instead",
"DESTINATION_BLOCK_PRIVATE_IPS": "private, loopback, link-local, carrier-NAT " +
"and cloud-metadata addresses are refused unconditionally since M30. It " +
"was an off switch on the one tier that must not have one: the person it " +
"protects is the visitor whose browser would do the fetching, and they " +
"are not the person who would be turning it off. Point links at an " +
"intranet with a hostname that resolves there, not with a literal address",
}
// RemovedInUse reports removed variables that are still set, ready to log.
//
// Sorted, so the output is stable across runs and diffable in a log.
func RemovedInUse() []string {
var out []string
for name, why := range Removed {
if os.Getenv(EnvPrefix+name) != "" {
out = append(out, fmt.Sprintf("%s%s is set but no longer read: %s",
EnvPrefix, name, why))
}
}
sort.Strings(out)
return out
}
// Parse reads configuration from the current environment without consulting a
// .env file. Tests use it directly.
func Parse() (Config, error) {
if err := resolveFileSecrets(); err != nil {
return Config{}, err
}
var c Config
if err := env.ParseWithOptions(&c, env.Options{Prefix: EnvPrefix}); err != nil {
return Config{}, err
}
// **A defaulted route deadline yields to an operator's request timeout rather
// than refusing the boot** (review finding 14).
//
// The check in Validate says a route deadline at or above HTTP_REQUEST_TIMEOUT
// never fires, which is true, and its own comment already names the problem
// with refusing over it: *an operator who deliberately set a short
// HTTP_REQUEST_TIMEOUT is being refused over a number they never chose*. Both
// defaults are ten seconds, so `HTTP_REQUEST_TIMEOUT=10s` — a value this file
// documents as valid — stopped an instance with add-ons from starting at all,
// over a knob the operator had not touched.
//
// So the unset one gives way: it becomes the largest value that still nests,
// and the operator's setting is honoured. An **explicitly set** route deadline
// is still refused by Validate, because then two numbers were chosen and only
// the operator can say which was meant.
// An empty value is not a setting: env applies the envDefault to it, so
// reading it as "the operator chose ten seconds" would bring the refusal back
// for anyone whose orchestrator passes the variable through blank.
routeDeadlineSet := os.Getenv(EnvPrefix+"ADDON_ROUTE_DEADLINE") != ""
if c.Addons.Enabled() && c.HTTP.RequestTimeout > 0 && !routeDeadlineSet &&
c.Addons.RouteDeadline == defaultAddonRouteDeadline &&
c.Addons.RouteDeadline >= c.HTTP.RequestTimeout {
c.Addons.RouteDeadline = c.HTTP.RequestTimeout - time.Second
if c.Addons.RouteDeadline <= 0 {
// A request timeout of a second or less leaves nothing to nest inside.
// Validate refuses that, with both numbers named.
c.Addons.RouteDeadline = defaultAddonRouteDeadline
}
c.addonRouteDeadlineClamped = true
}
if err := c.Validate(); err != nil {
return Config{}, err
}
// Parsed during Validate; store it for BaseURLParsed.
u, _ := url.Parse(strings.TrimRight(c.BaseURL, "/"))
c.baseURL = u
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
// Unset means "the same host as everything else", which is the deployment
// this product shipped with and must keep working untouched.
if c.AppBaseURL == "" {
c.AppBaseURL = c.BaseURL
}
if c.LinkBaseURL == "" {
c.LinkBaseURL = c.BaseURL
}
c.AppBaseURL = strings.TrimRight(c.AppBaseURL, "/")
c.LinkBaseURL = strings.TrimRight(c.LinkBaseURL, "/")
c.appBaseURL, _ = url.Parse(c.AppBaseURL)
c.linkBaseURL, _ = url.Parse(c.LinkBaseURL)
return c, nil
}
// BaseURLParsed returns the parsed canonical origin.
func (c Config) BaseURLParsed() *url.URL { return c.baseURL }
// AppOrigin returns the origin serving the dashboard and the API.
//
// Falls back to BaseURL, so a Config assembled by hand — every test does this
// rather than going through Load — behaves as a single-host deployment instead
// of as one with no dashboard origin at all.
func (c Config) AppOrigin() string {
if c.AppBaseURL != "" {
return c.AppBaseURL
}
return c.BaseURL
}
// LinkOrigin returns the origin short links are published under.
func (c Config) LinkOrigin() string {
if c.LinkBaseURL != "" {
return c.LinkBaseURL
}
return c.BaseURL
}
// AppBaseURLParsed returns the origin serving the dashboard and the API.
func (c Config) AppBaseURLParsed() *url.URL { return c.appBaseURL }
// LinkBaseURLParsed returns the origin serving short links.
func (c Config) LinkBaseURLParsed() *url.URL { return c.linkBaseURL }
// Host returns the host short links are served on, which is the default domain
// when resolving an alias.
func (c Config) Host() string {
if c.linkBaseURL != nil {
return c.linkBaseURL.Host
}
if c.baseURL == nil {
return ""
}
return c.baseURL.Host
}
// SplitHosts reports whether the dashboard and short links are served on
// different hostnames.
//
// Compared on host rather than on the whole origin: the routing decision and
// the cookie boundary are both about the host, and an instance configured with
// two schemes on one host has neither.
func (c Config) SplitHosts() bool {
if c.appBaseURL == nil || c.linkBaseURL == nil {
return false
}
return CanonicalHost(c.appBaseURL.Host) != CanonicalHost(c.linkBaseURL.Host)
}
// CanonicalHost normalizes a Host header or a URL host for comparison:
// lowercased, with the DNS root dot folded and an explicit default HTTP(S) port
// removed.