make CLI configurable

This commit is contained in:
m5r 2024-07-26 12:16:23 +02:00
parent c3220327e1
commit c7989fa736
Signed by: mokhtar
GPG Key ID: 1509B54946D08A95
15 changed files with 468 additions and 251 deletions

View File

@ -4,24 +4,20 @@ WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o /app/local-ip
RUN go build
FROM gcr.io/distroless/base-debian12:latest
WORKDIR /local-ip
COPY --from=build /app/local-ip /local-ip/local-ip
COPY --from=build /app/local-ip.sh /local-ip/local-ip.sh
COPY --from=build /app/http/static /local-ip/http/static
VOLUME /local-ip/.lego
# DNS
EXPOSE 53/udp
# HTTP
EXPOSE 80/tcp
# HTTPS
EXPOSE 443/tcp
# DNS HTTP HTTPS
EXPOSE 53/udp 80/tcp 443/tcp
USER root
CMD ["/local-ip/local-ip"]
CMD ["/local-ip/local-ip.sh"]

View File

@ -3,48 +3,51 @@
[local-ip.sh](https://local-ip.sh) is a magic domain name that provides wildcard DNS for any IP address.
It is heavily inspired by [local-ip.co](http://local-ip.co), [sslip.io](https://sslip.io), and [xip.io](https://xip.io)
## Usage
```sh
go run ./main.go # binds to :53 by default but you can override it by using the `-port` parameter
dig @localhost 10-0-1-29.my.local-ip.sh +short
# 10.0.1.29
dig @localhost app.10-0-1-29.my.local-ip.sh +short
# 10.0.1.29
dig @localhost foo.bar.10.0.1.29.my.local-ip.sh +short
# 10.0.1.29
dig @localhost 127.0.0.1.my.local-ip.sh +short
# 127.0.0.1
```
## How it works
local-ip.sh packs up:
- an authoritative DNS server that answers queries for the zone `local-ip.sh`
- a Let's Encrypt client that takes care of obtaining and renewing the wildcard certificate for `*.local-ip.sh` and the root certificate for `local-ip.sh` using the [DNS-01 challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)
- an HTTP server that serves static files, including the certificate files
- an HTTP server that serves the website and the wildcard certificate files
It answers queries with the IPv4 address it may find in the subdomain by pattern matching the FQDN.
It registers an account to Let's Encrypt's ACME server to obtain the wildcard certificate on the first run and then renew
it about a month before it expires. The account file and the associated key used to request a certificate under the `./.lego/accounts`
directory and the certificate's files are stored in `./.lego/certs`.
It registers an account to Let's Encrypt's ACME server to obtain the wildcard certificate on the first run and then renew it about a month before it expires. The account file and the associated key used to request a certificate under the `./.lego/accounts` directory and the certificate's files are stored in `./.lego/certs`.
It also obtains a separate certificate for the root domain to serve the website through HTTPS. It initially serves the website through HTTP and when the root domain certificate is ready, it redirects all HTTP requests to HTTPS.
The certificate files are served by an HTTP server on the arbitrary port `:9229` that is intentionally not exposed to
the internet. [The website](https://local-ip.sh) is connected to the same private network as the service and serves
as a proxy to access the files securely.
## Usage
```sh
go run ./main.go --staging --dns-port 9053 --http-port 9080 --https-port 9443 --domain local-ip.sh --email admin@fake.sh --nameservers 137.66.40.11,137.66.40.12
dig @localhost -p 9053 10-0-1-29.local-ip.sh +short
# 10.0.1.29
dig @localhost -p 9053 app.10-0-1-29.local-ip.sh +short
# 10.0.1.29
dig @localhost -p 9053 foo.bar.10.0.1.29.local-ip.sh +short
# 10.0.1.29
dig @localhost -p 9053 127.0.0.1.local-ip.sh +short
# 127.0.0.1
```
### Configuration
local-ip.sh can be configured through environment variables or CLI flags
- `XIP_DNS_PORT` or `--dns-port` optional, port for the DNS server, defaults to `53`.
- `XIP_HTTP_PORT` or `--http-port` optional, port for the HTTP server, defaults to `80`.
- `XIP_HTTPS_PORT` or `--https-port` optional, port for the HTTPS server, defaults to `443`.
- `XIP_STAGING` or `--staging` optional, enable to use Let's Encrypt staging environment to obtain certificates, defaults to `false`.
- `XIP_DOMAIN` or `--domain` required, domain name of the server hosting this. It will be used as the zone to answer dns queries for.
- `XIP_EMAIL` or `--email` required, administrator's email address, used to create the ACME account to request certificates from Let's Encrypt and as the `RNAME` value of the SOA record representing the domain administrator's email address.
- `XIP_NAMESERVERS` or `--nameservers` required, comma-separated IPv4 addresses used to answer `A` queries for `nsX.{domain}` where `X` is the index of the address in this list. For example setting `--domain example.com --nameservers 1.2.3.4,9.8.7.6` will answer `1.2.3.4` for `ns1.example.com` and `9.8.7.6` for `ns2.example.com`. All `nsX.{domain}` nameservers will be in the answer for NS queries to the zone.
A [reference docker compose file](./compose.yml) is available for deployments using Docker.
## Self-hosting
I'm currently hosting [local-ip.sh](https://local-ip.sh) at [Fly.io](https://fly.io) but you can host the service yourself
if you're into that kind of thing. Note that you will need to edit your domain's glue records so make sure your registrar allows it.
I'm currently hosting [local-ip.sh](https://local-ip.sh) at [Fly.io](https://fly.io) but you can host the service yourself if you're into that kind of thing. Note that you will need to edit your domain's glue records so make sure your registrar allows it.
You will essentially need to:
- replace any occurrence of `local-ip.sh` in `.go` files with your domain
- replace the hardcoded IP addresses in the `hardcodedRecords` map declared in [`xip.go:37`](./xip/xip.go#L37), the important records to keep are:
- `A ns.local-ip.sh.` holds both IP addresses pointing to `ns1.` and `ns2.`
- `A ns1.local-ip.sh.` holds the first IP address pointing to the server hosting local-ip.sh
- `A ns2.local-ip.sh.` holds the second IP address pointing to the server, exists for redundancy
- `TXT _acme-challenge.local-ip.sh.` will temporarily hold the value to solve the DNS-01 challenge
- set your domain's glue records to point to the IP addresses you set for `ns1.` and `ns2.`
- retrieve the certificate files once the program is up and running
- set your domain's glue records to point to the IP addresses you will set for `XIP_NAMESERVERS` / `--nameservers`
- configure `local-ip.sh` with the domain, admin email address, and nameservers
- ensure you have some sort of persistent storage for the `./.lego` directory, this is where the ACME account and certificate files are stored, you don't want to lose this between deployments

View File

@ -36,7 +36,8 @@ func (u *Account) GetPrivateKey() crypto.PrivateKey {
}
func LoadAccount() *Account {
jsonBytes, err := os.ReadFile(accountFilePath)
config := utils.GetConfig()
jsonBytes, err := os.ReadFile(config.AccountFilePath)
if err != nil {
if strings.Contains(err.Error(), "no such file or directory") {
RegisterAccount()
@ -51,7 +52,7 @@ func LoadAccount() *Account {
utils.Logger.Fatal().Err(err).Msg("Failed to unmarshal account JSON file")
}
privKey, err := os.ReadFile(keyFilePath)
privKey, err := os.ReadFile(config.KeyFilePath)
if err != nil {
utils.Logger.Fatal().Err(err).Msg("Failed to read account's private key file")
}
@ -61,41 +62,42 @@ func LoadAccount() *Account {
}
func RegisterAccount() {
config := utils.GetConfig()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
utils.Logger.Fatal().Err(err).Msg("Failed to generate account key")
}
account := &Account{
Email: email,
Email: config.Email,
key: privateKey,
}
config := lego.NewConfig(account)
config.CADirURL = caDirUrl
legoClient, err := lego.NewClient(config)
legoConfig := lego.NewConfig(account)
legoConfig.CADirURL = config.CADirURL
legoClient, err := lego.NewClient(legoConfig)
if err != nil {
utils.Logger.Fatal().Err(err).Str("CA Directory URL", config.CADirURL).Msg("Failed to initialize lego client")
utils.Logger.Fatal().Err(err).Str("CA Directory URL", legoConfig.CADirURL).Msg("Failed to initialize lego client")
}
reg, err := legoClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
utils.Logger.Fatal().Err(err).Str("CA Directory URL", config.CADirURL).Msg("Failed to register account to ACME server")
utils.Logger.Fatal().Err(err).Str("CA Directory URL", legoConfig.CADirURL).Msg("Failed to register account to ACME server")
}
if reg.Body.Status != "valid" {
utils.Logger.Fatal().Err(err).Str("CA Directory URL", config.CADirURL).Msgf("Registration failed with status %s", reg.Body.Status)
utils.Logger.Fatal().Err(err).Str("CA Directory URL", legoConfig.CADirURL).Msgf("Registration failed with status %s", reg.Body.Status)
}
utils.Logger.Debug().
Str("CA Directory URL", config.CADirURL).
Str("CA Directory URL", legoConfig.CADirURL).
Bool("TermsOfServiceAgreed", reg.Body.TermsOfServiceAgreed).
Msg("Successfully registered account to ACME server")
account.Registration = reg
os.MkdirAll(filepath.Dir(keyFilePath), os.ModePerm)
os.MkdirAll(filepath.Dir(config.KeyFilePath), os.ModePerm)
privKey := encode(privateKey)
err = os.WriteFile(keyFilePath, []byte(privKey), 0o644)
err = os.WriteFile(config.KeyFilePath, []byte(privKey), 0o644)
if err != nil {
utils.Logger.Fatal().Err(err).Str("path", keyFilePath).Msg("Failed to write account's private key file")
utils.Logger.Fatal().Err(err).Str("path", config.KeyFilePath).Msg("Failed to write account's private key file")
}
jsonBytes, err := json.MarshalIndent(account, "", "\t")
@ -103,10 +105,10 @@ func RegisterAccount() {
utils.Logger.Fatal().Err(err).Msg("Failed to marshal account JSON file")
}
os.MkdirAll(filepath.Dir(accountFilePath), os.ModePerm)
err = os.WriteFile(accountFilePath, jsonBytes, 0o600)
os.MkdirAll(filepath.Dir(config.AccountFilePath), os.ModePerm)
err = os.WriteFile(config.AccountFilePath, jsonBytes, 0o600)
if err != nil {
utils.Logger.Fatal().Err(err).Str("path", accountFilePath).Msg("Failed to write account's JSON file")
utils.Logger.Fatal().Err(err).Str("path", config.AccountFilePath).Msg("Failed to write account's JSON file")
}
}

View File

@ -27,14 +27,15 @@ func (c *certsClient) RequestCertificates() {
}
func (c *certsClient) requestCertificate(certType string) {
config := utils.GetConfig()
var lastCertificate *certificate.Resource
var domains []string
if certType == "wildcard" {
lastCertificate = c.lastWildcardCertificate
domains = []string{"*.local-ip.sh"}
domains = []string{fmt.Sprintf("*.%s", config.Domain)}
} else if certType == "root" {
lastCertificate = c.lastRootCertificate
domains = []string{"local-ip.sh"}
domains = []string{config.Domain}
} else {
utils.Logger.Fatal().Msgf("Unexpected certType %s. Only \"wildcard\" and \"root\" are supported", certType)
}
@ -119,9 +120,10 @@ func persistFiles(certificates *certificate.Resource, certType string) {
}
func NewCertsClient(xip *xip.Xip, user *Account) *certsClient {
config := lego.NewConfig(user)
config.CADirURL = caDirUrl
legoClient, err := lego.NewClient(config)
config := utils.GetConfig()
legoConfig := lego.NewConfig(user)
legoConfig.CADirURL = config.CADirURL
legoClient, err := lego.NewClient(legoConfig)
if err != nil {
utils.Logger.Fatal().Err(err).Msg("Failed to initialize lego client")
}

View File

@ -1,21 +0,0 @@
package certs
import (
"fmt"
"net/url"
"github.com/go-acme/lego/v4/lego"
)
const (
email = "admin@local-ip.sh"
caDirUrl = lego.LEDirectoryProduction
// caDirUrl = lego.LEDirectoryStaging
)
var (
parsedCaDirUrl, _ = url.Parse(caDirUrl)
caDirHostname = parsedCaDirUrl.Hostname()
accountFilePath = fmt.Sprintf("./.lego/accounts/%s/%s/account.json", caDirHostname, email)
keyFilePath = fmt.Sprintf("./.lego/accounts/%s/%s/keys/%s.key", caDirHostname, email, email)
)

111
cmd/root.go Normal file
View File

@ -0,0 +1,111 @@
package cmd
import (
"fmt"
"net/mail"
"net/url"
"strings"
"time"
"github.com/asaskevich/govalidator"
"github.com/go-acme/lego/v4/lego"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"local-ip.sh/certs"
"local-ip.sh/http"
"local-ip.sh/utils"
"local-ip.sh/xip"
)
var command = &cobra.Command{
Use: "local-ip.sh",
PreRun: func(cmd *cobra.Command, args []string) {
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.SetEnvPrefix("XIP")
viper.AutomaticEnv()
email := viper.GetString("Email")
_, err := mail.ParseAddress(email)
if err != nil {
utils.Logger.Fatal().Err(err).Msg("Invalid email address")
}
domain := viper.GetString("Domain")
if !govalidator.IsDNSName(domain) {
utils.Logger.Fatal().Err(err).Msg("Invalid domain")
}
nameservers := strings.Split(viper.GetString("nameservers"), ",")
for _, ns := range nameservers {
if !govalidator.IsIPv4(ns) {
utils.Logger.Fatal().Err(err).Str("ns", ns).Msg("Invalid name server")
}
}
viper.Set("NameServers", nameservers)
staging := viper.GetBool("staging")
var caDir string
if staging {
caDir = lego.LEDirectoryStaging
} else {
caDir = lego.LEDirectoryProduction
}
viper.Set("CADirURL", caDir)
parsedCaDirUrl, _ := url.Parse(caDir)
caDirHostname := parsedCaDirUrl.Hostname()
viper.Set("AccountFilePath", fmt.Sprintf("./.lego/accounts/%s/%s/account.json", caDirHostname, email))
viper.Set("KeyFilePath", fmt.Sprintf("./.lego/accounts/%s/%s/keys/%s.key", caDirHostname, email, email))
utils.InitConfig()
},
Run: func(cmd *cobra.Command, args []string) {
n := xip.NewXip()
go func() {
// try to obtain certificates once the DNS server is accepting requests
account := certs.LoadAccount()
certsClient := certs.NewCertsClient(n, account)
time.Sleep(5 * time.Second)
certsClient.RequestCertificates()
for {
// afterwards, try to renew certificates once a day
time.Sleep(24 * time.Hour)
certsClient.RequestCertificates()
}
}()
go http.ServeHttp()
n.StartServer()
},
}
func Execute() {
command.Flags().Uint("dns-port", 53, "Port for the DNS server")
viper.BindPFlag("dns-port", command.Flags().Lookup("dns-port"))
command.Flags().Uint("http-port", 80, "Port for the HTTP server")
viper.BindPFlag("http-port", command.Flags().Lookup("http-port"))
command.Flags().Uint("https-port", 443, "Port for the HTTPS server")
viper.BindPFlag("https-port", command.Flags().Lookup("https-port"))
command.Flags().Bool("staging", false, "Enable to use the Let's Encrypt staging environment to obtain certificates")
viper.BindPFlag("staging", command.Flags().Lookup("staging"))
command.Flags().String("domain", "", "Root domain (required)")
viper.BindPFlag("domain", command.Flags().Lookup("domain"))
command.Flags().String("email", "", "ACME account email address (required)")
viper.BindPFlag("email", command.Flags().Lookup("email"))
command.Flags().String("nameservers", "", "List of nameservers separated by commas (required)")
viper.BindPFlag("nameservers", command.Flags().Lookup("nameservers"))
if err := command.Execute(); err != nil {
utils.Logger.Fatal().Err(err).Msg("Failed to run local-ip.sh")
}
}

View File

@ -1,14 +1,19 @@
services:
local-ip.sh:
image: local-ip.sh
build: .
volumes:
- lego:/local-ip/.lego
restart: unless-stopped
ports:
- 53:53/udp
- 80:80/tcp
- 443:443/tcp
local-ip.sh:
image: local-ip.sh
build: .
volumes:
- lego:/local-ip/.lego
restart: unless-stopped
environment:
XIP_DOMAIN: "local-ip.sh"
XIP_EMAIL: "admin@local-ip.sh"
XIP_NAMESERVERS: "137.66.40.11,137.66.40.12"
# XIP_STAGING: true
ports:
- 53:53/udp
- 80:80/tcp
- 443:443/tcp
volumes:
lego:
lego:

View File

@ -1,8 +1,3 @@
# fly.toml app configuration file generated for local-ip-ancient-glade-4376 on 2023-11-29T11:43:10+01:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "local-ip"
primary_region = "ams"
kill_signal = "SIGINT"
@ -14,7 +9,9 @@ auto_rollback = true
[build]
[env]
PORT = "53"
XIP_DOMAIN = "local-ip.sh"
XIP_EMAIL = "admin@local-ip.sh"
XIP_NAMESERVERS = "137.66.40.11,137.66.40.12" # fly.io edge-only ip addresses, see https://community.fly.io/t/custom-domains-certificate-is-stuck-on-awaiting-configuration/8329
[mounts]
source = "lego"

27
go.mod
View File

@ -3,21 +3,42 @@ module local-ip.sh
go 1.22
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/go-acme/lego/v4 v4.10.1
github.com/miekg/dns v1.1.57
github.com/rs/zerolog v1.33.0
golang.org/x/net v0.17.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
golang.org/x/net v0.23.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/cenkalti/backoff/v4 v4.2.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/crypto v0.14.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

88
go.sum
View File

@ -1,16 +1,35 @@
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-acme/lego/v4 v4.10.1 h1:MiJvoBXNdmAwEK/SImyhwZ8ZL4IR0jtWDD1wST+N138=
github.com/go-acme/lego/v4 v4.10.1/go.mod h1:EMbf0Jmqwv94nJ5WL9qWnSXIBZnvsS9gNypansHGc6U=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -19,27 +38,66 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -48,12 +106,16 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -2,8 +2,11 @@ package http
import (
"context"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
@ -45,8 +48,9 @@ func registerHandlers() {
}
func serveHttp() *http.Server {
utils.Logger.Info().Msg("Starting up HTTP server on :80")
httpServer := &http.Server{Addr: ":http"}
config := utils.GetConfig()
httpServer := &http.Server{Addr: fmt.Sprintf(":%d", config.HttpPort)}
utils.Logger.Info().Str("http_address", httpServer.Addr).Msg("Starting up HTTP server")
go func() {
err := httpServer.ListenAndServe()
if err != http.ErrServerClosed {
@ -84,22 +88,40 @@ func killServer(httpServer *http.Server) {
}
func redirectHttpToHttps() {
utils.Logger.Info().Msg("Redirecting HTTP traffic from :80 to HTTPS :443")
config := utils.GetConfig()
httpServer := &http.Server{
Addr: ":http",
Addr: fmt.Sprintf(":%d", config.HttpPort),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
url := r.URL
host := r.Host
// Strip the port from the host if present
if strings.Contains(host, ":") {
hostWithoutPort, _, err := net.SplitHostPort(host)
if err != nil {
utils.Logger.Error().Err(err).Msg("Failed to split host and port")
} else {
host = hostWithoutPort
}
}
// Add the HTTPS port only if it's not 443
if config.HttpsPort != 443 {
host = net.JoinHostPort(host, strconv.FormatUint(uint64(config.HttpsPort), 10))
}
url.Host = r.Host
url.Scheme = "https"
http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
}),
}
utils.Logger.Info().Str("http_address", httpServer.Addr).Msg("Redirecting HTTP traffic to HTTPS")
go httpServer.ListenAndServe()
}
func serveHttps() {
utils.Logger.Info().Msg("Starting up HTTPS server on :443")
httpsServer := &http.Server{Addr: ":https"}
config := utils.GetConfig()
httpsServer := &http.Server{Addr: fmt.Sprintf(":%d", config.HttpsPort)}
utils.Logger.Info().Str("https_address", httpsServer.Addr).Msg("Starting up HTTPS server")
go httpsServer.ListenAndServeTLS("./.lego/certs/root/server.pem", "./.lego/certs/root/server.key")
}

30
main.go
View File

@ -1,35 +1,9 @@
package main
import (
"flag"
"time"
"local-ip.sh/certs"
"local-ip.sh/http"
"local-ip.sh/xip"
"local-ip.sh/cmd"
)
func main() {
port := flag.Int("port", 53, "port the DNS server should bind to")
flag.Parse()
n := xip.NewXip(*port)
go func() {
account := certs.LoadAccount()
certsClient := certs.NewCertsClient(n, account)
time.Sleep(5 * time.Second)
certsClient.RequestCertificates()
for {
// try to renew certificate every day
time.Sleep(24 * time.Hour)
certsClient.RequestCertificates()
}
}()
go http.ServeHttp()
n.StartServer()
cmd.Execute()
}

29
utils/config.go Normal file
View File

@ -0,0 +1,29 @@
package utils
import (
"github.com/spf13/viper"
)
type config struct {
DnsPort uint `mapstructure:"dns-port"`
HttpPort uint `mapstructure:"http-port"`
HttpsPort uint `mapstructure:"https-port"`
Domain string
Email string
NameServers []string
CADirURL string
AccountFilePath string
KeyFilePath string
}
var conf = &config{}
func InitConfig() *config {
viper.Unmarshal(conf)
return conf
}
func GetConfig() *config {
return conf
}

52
xip/records.go Normal file
View File

@ -0,0 +1,52 @@
package xip
import (
"net"
"github.com/miekg/dns"
)
type hardcodedRecord struct {
A []net.IP // => dns.A
AAAA []net.IP // => dns.AAAA
TXT []string // => dns.TXT
MX []*dns.MX
CNAME []string // => dns.CNAME
SRV *dns.SRV
}
var hardcodedRecords = map[string]hardcodedRecord{
// additional records I set up to host emails, feel free to change or remove them for your own needs
"local-ip.sh.": {
TXT: []string{"v=spf1 include:capsulecorp.dev ~all"},
MX: []*dns.MX{
{Preference: 10, Mx: "email.capsulecorp.dev."},
},
},
"autodiscover.local-ip.sh.": {
CNAME: []string{
"email.capsulecorp.dev.",
},
},
"_autodiscover._tcp.local-ip.sh.": {
SRV: &dns.SRV{
Priority: 0,
Weight: 0,
Port: 443,
Target: "email.capsulecorp.dev.",
},
},
"autoconfig.local-ip.sh.": {
CNAME: []string{
"email.capsulecorp.dev.",
},
},
"_dmarc.local-ip.sh.": {
TXT: []string{"v=DMARC1; p=none; rua=mailto:postmaster@local-ip.sh; ruf=mailto:admin@local-ip.sh"},
},
"dkim._domainkey.local-ip.sh.": {
TXT: []string{
"v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMW6NFo34qzKRPbzK41GwbWncB8IDg1i2eA2VWznIVDmTzzsqILaBOGv2xokVpzZm0QRF9wSbeVUmvwEeQ7Z6wkfMjawenDEc3XxsNSvQUVBP6LU/xcm1zsR8wtD8r5J+Jm45pNFaateiM/kb/Eypp2ntdtd8CPsEgCEDpNb62LWdy0yzRdZ/M/fNn51UMN8hVFp4YfZngAt3bQwa6kPtgvTeqEbpNf5xanpDysNJt2S8zfqJMVGvnr8JaJiTv7ZlKMMp94aC5Ndcir1WbMyfmgSnGgemuCTVMWDGPJnXDi+8BQMH1b1hmTpWDiVdVlehyyWx5AfPrsWG9cEuDIfXwIDAQAB",
},
},
}

View File

@ -14,120 +14,50 @@ import (
type Xip struct {
server dns.Server
nameServers []*dns.NS
nameServers []string
}
type HardcodedRecord struct {
A []net.IP // => dns.A
AAAA []net.IP // => dns.AAAA
TXT []string // => dns.TXT
MX []*dns.MX
CNAME []string // => dns.CNAME
SRV *dns.SRV
}
const (
zone = "local-ip.sh."
nameservers = "ns1.local-ip.sh.,ns2.local-ip.sh."
)
var (
flyRegion = os.Getenv("FLY_REGION")
dottedIpV4Regex = regexp.MustCompile(`(?:^|(?:[\w\d])+\.)(((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4})($|[.-])`)
dashedIpV4Regex = regexp.MustCompile(`(?:^|(?:[\w\d])+\.)(((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\-?\b){4})($|[.-])`)
hardcodedRecords = map[string]HardcodedRecord{
"ns.local-ip.sh.": {
// record holding ip addresses of ns1 and ns2
A: []net.IP{
net.IPv4(137, 66, 40, 11),
net.IPv4(137, 66, 40, 12),
},
},
"ns1.local-ip.sh.": {
A: []net.IP{
net.IPv4(137, 66, 40, 11), // fly.io edge-only ip address, see https://community.fly.io/t/custom-domains-certificate-is-stuck-on-awaiting-configuration/8329
},
},
"ns2.local-ip.sh.": {
A: []net.IP{
net.IPv4(137, 66, 40, 12), // fly.io edge-only ip address #2
},
},
"local-ip.sh.": {
A: []net.IP{
net.IPv4(137, 66, 40, 11), // fly.io edge-only ip address
},
TXT: []string{"v=spf1 include:capsulecorp.dev ~all"},
MX: []*dns.MX{
{Preference: 10, Mx: "email.capsulecorp.dev."},
},
},
"autodiscover.local-ip.sh.": {
CNAME: []string{
"email.capsulecorp.dev.",
},
},
"_autodiscover._tcp.local-ip.sh.": {
SRV: &dns.SRV{
Priority: 0,
Weight: 0,
Port: 443,
Target: "email.capsulecorp.dev.",
},
},
"autoconfig.local-ip.sh.": {
CNAME: []string{
"email.capsulecorp.dev.",
},
},
"_dmarc.local-ip.sh.": {
TXT: []string{"v=DMARC1; p=none; rua=mailto:postmaster@local-ip.sh; ruf=mailto:admin@local-ip.sh"},
},
"dkim._domainkey.local-ip.sh.": {
TXT: []string{
"v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMW6NFo34qzKRPbzK41GwbWncB8IDg1i2eA2VWznIVDmTzzsqILaBOGv2xokVpzZm0QRF9wSbeVUmvwEeQ7Z6wkfMjawenDEc3XxsNSvQUVBP6LU/xcm1zsR8wtD8r5J+Jm45pNFaateiM/kb/Eypp2ntdtd8CPsEgCEDpNb62LWdy0yzRdZ/M/fNn51UMN8hVFp4YfZngAt3bQwa6kPtgvTeqEbpNf5xanpDysNJt2S8zfqJMVGvnr8JaJiTv7ZlKMMp94aC5Ndcir1WbMyfmgSnGgemuCTVMWDGPJnXDi+8BQMH1b1hmTpWDiVdVlehyyWx5AfPrsWG9cEuDIfXwIDAQAB",
},
},
"_acme-challenge.local-ip.sh.": {
// will be filled in later when requesting the wildcard certificate
TXT: []string{},
},
}
flyRegion = os.Getenv("FLY_REGION")
dottedIpV4Regex = regexp.MustCompile(`(?:^|(?:[\w\d])+\.)(((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4})($|[.-])`)
dashedIpV4Regex = regexp.MustCompile(`(?:^|(?:[\w\d])+\.)(((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\-?\b){4})($|[.-])`)
)
func (xip *Xip) SetTXTRecord(fqdn string, value string) {
utils.Logger.Debug().Str("fqdn", fqdn).Str("value", value).Msg("Trying to set TXT record")
if fqdn != "_acme-challenge.local-ip.sh." {
config := utils.GetConfig()
if fqdn != fmt.Sprintf("_acme-challenge.%s.", config.Domain) {
utils.Logger.Debug().Msg("Not allowed, abort")
return
}
if records, ok := hardcodedRecords[fqdn]; ok {
records.TXT = []string{value}
hardcodedRecords["_acme-challenge.local-ip.sh."] = records
if rootRecords, ok := hardcodedRecords[fqdn]; ok {
rootRecords.TXT = []string{value}
hardcodedRecords[fmt.Sprintf("_acme-challenge.%s.", config.Domain)] = rootRecords
}
}
func (xip *Xip) UnsetTXTRecord(fqdn string) {
utils.Logger.Debug().Str("fqdn", fqdn).Msg("Trying to set TXT record")
if fqdn != "_acme-challenge.local-ip.sh." {
config := utils.GetConfig()
if fqdn != fmt.Sprintf("_acme-challenge.%s.", config.Domain) {
utils.Logger.Debug().Msg("Not allowed, abort")
return
}
if records, ok := hardcodedRecords[fqdn]; ok {
records.TXT = []string{}
hardcodedRecords["_acme-challenge.local-ip.sh."] = records
if rootRecords, ok := hardcodedRecords[fqdn]; ok {
rootRecords.TXT = []string{}
hardcodedRecords[fmt.Sprintf("_acme-challenge.%s.", config.Domain)] = rootRecords
}
}
func (xip *Xip) fqdnToA(fqdn string) []*dns.A {
normalizedFqdn := strings.ToLower(fqdn)
if hardcodedRecords[normalizedFqdn].A != nil {
var records []*dns.A
var aRecords []*dns.A
for _, record := range hardcodedRecords[normalizedFqdn].A {
records = append(records, &dns.A{
aRecords = append(aRecords, &dns.A{
Hdr: dns.RR_Header{
Ttl: uint32((time.Minute * 5).Seconds()),
Name: fqdn,
@ -138,7 +68,7 @@ func (xip *Xip) fqdnToA(fqdn string) []*dns.A {
})
}
return records
return aRecords
}
for _, ipV4RE := range []*regexp.Regexp{dashedIpV4Regex, dottedIpV4Regex} {
@ -171,15 +101,15 @@ func (xip *Xip) answerWithAuthority(question dns.Question, message *dns.Msg) {
func (xip *Xip) handleA(question dns.Question, message *dns.Msg) {
fqdn := question.Name
records := xip.fqdnToA(fqdn)
aRecords := xip.fqdnToA(fqdn)
if len(records) == 0 {
if len(aRecords) == 0 {
message.Rcode = dns.RcodeNameError
xip.answerWithAuthority(question, message)
return
}
for _, record := range records {
for _, record := range aRecords {
message.Answer = append(message.Answer, record)
}
}
@ -217,10 +147,10 @@ func (xip *Xip) handleNS(question dns.Question, message *dns.Msg) {
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
},
Ns: ns.Ns,
Ns: ns,
})
additionals = append(additionals, xip.fqdnToA(ns.Ns)...)
additionals = append(additionals, xip.fqdnToA(ns)...)
}
for _, record := range nameServers {
@ -329,7 +259,15 @@ func (xip *Xip) handleSOA(question dns.Question, message *dns.Msg) {
message.Answer = append(message.Answer, xip.soaRecord(question))
}
func emailToRname(email string) string {
parts := strings.SplitN(email, "@", 2)
localPart := strings.ReplaceAll(parts[0], ".", "\\.")
domain := parts[1]
return localPart + "." + domain + "."
}
func (xip *Xip) soaRecord(question dns.Question) *dns.SOA {
config := utils.GetConfig()
soa := new(dns.SOA)
soa.Hdr = dns.RR_Header{
Name: question.Name,
@ -338,9 +276,9 @@ func (xip *Xip) soaRecord(question dns.Question) *dns.SOA {
Ttl: uint32((time.Minute * 5).Seconds()),
Rdlength: 0,
}
soa.Ns = "ns1.local-ip.sh."
soa.Mbox = "admin.local-ip.sh."
soa.Serial = 2022102800
soa.Ns = xip.nameServers[0]
soa.Mbox = emailToRname(config.Email)
soa.Serial = 2024072600
soa.Refresh = uint32((time.Minute * 15).Seconds())
soa.Retry = uint32((time.Minute * 15).Seconds())
soa.Expire = uint32((time.Minute * 30).Seconds())
@ -393,6 +331,7 @@ func (xip *Xip) handleDnsRequest(response dns.ResponseWriter, request *dns.Msg)
error := response.WriteMsg(message)
if error != nil {
utils.Logger.Debug().Msg(message.String())
utils.Logger.Error().Err(error).Str("message", message.String()).Msg("Error responding to query")
}
}()
@ -407,6 +346,7 @@ func (xip *Xip) StartServer() {
err := xip.server.ListenAndServe()
defer xip.server.Shutdown()
if err != nil {
utils.Logger.Fatal().Err(err).Msg("Failed to start DNS server")
if strings.Contains(err.Error(), "fly-global-services: no such host") {
// we're not running on fly, bind to 0.0.0.0 instead
port := strings.Split(xip.server.Addr, ":")[1]
@ -421,21 +361,43 @@ func (xip *Xip) StartServer() {
utils.Logger.Fatal().Err(err).Msg("Failed to start DNS server")
}
utils.Logger.Info().Str("dns_address", xip.server.Addr).Msg("DNS server listening")
utils.Logger.Info().Str("dns_address", xip.server.Addr).Msg("Starting up DNS server")
}
func NewXip(port int) (xip *Xip) {
xip = &Xip{}
func (xip *Xip) initHardcodedRecords() {
config := utils.GetConfig()
rootDomainARecords := []net.IP{}
for _, ns := range strings.Split(nameservers, ",") {
xip.nameServers = append(xip.nameServers, &dns.NS{Ns: ns})
for i, ns := range config.NameServers {
name := fmt.Sprintf("ns%d.%s.", i+1, config.Domain)
ip := net.ParseIP(ns)
rootDomainARecords = append(rootDomainARecords, ip)
entry := hardcodedRecords[name]
entry.A = append(hardcodedRecords[name].A, ip)
hardcodedRecords[name] = entry
xip.nameServers = append(xip.nameServers, name)
}
hardcodedRecords[fmt.Sprintf("%s.", config.Domain)] = hardcodedRecord{A: rootDomainARecords}
// will be filled in later when requesting certificates
hardcodedRecords[fmt.Sprintf("_acme-challenge.%s.", config.Domain)] = hardcodedRecord{TXT: []string{}}
}
func NewXip() (xip *Xip) {
config := utils.GetConfig()
xip = &Xip{}
xip.initHardcodedRecords()
xip.server = dns.Server{
Addr: fmt.Sprintf(":%d", port),
Addr: fmt.Sprintf(":%d", config.DnsPort),
Net: "udp",
}
zone := fmt.Sprintf("%s.", config.Domain)
dns.HandleFunc(zone, xip.handleDnsRequest)
return xip