Nuked: procuring a wildcard certificate

The documentation on how to procure a wildcard certificate had gotten
overly-complicated and stale, the Docker image, old, and the code, even
older.

Perhaps more importantly I couldn't bring myself to care whether people
could procure a wildcard certificate.

Signed-off-by: Brian Cunnie <brian.cunnie@gmail.com>
This commit is contained in:
Brian Cunnie
2025-08-12 19:38:50 -07:00
parent 726bf8707c
commit 16944e6adf
8 changed files with 1 additions and 521 deletions

View File

@@ -127,9 +127,7 @@ as ARM64 (AWS Graviton, Apple M1/M2).
`go run main.go -nameservers ns1.example.com,ns2.example.com`). If you're
running your own nameservers, you probably want to set this. Don't forget to
set address records for the new name servers with the `-addresses` flag (see
below). Exception: `_acme-challenge` records are handled differently to
accommodate the procurement of Let's Encrypt wildcard certificates; you can
read more about that procedure [here](docs/wildcard.md)
below).
- `-addresses` overrides the default A/AAAA (IPv4/IPv6) address records. For
example, here's how we set the IPv4 record & IPv6 record for our nameserver
(in the `-nameservers` example above), ns1.example.com: `-addresses

View File

@@ -1,194 +0,0 @@
## Procuring a Wildcard Certificate
### Using a White Label Domain
Let's say you have a domain that is hosted on Amazon Route53, lets call it
`example.com`. You have a few DNS entries set up like `foo.example.com`, and then
you have `xip.example.com` which is an NS record to `ns-ovh.sslip.io`. So you
are able to use both regular DNS records that are hardcoded, and then when you
need to use sslip you simply use your xip subdomain.
To get a wildcard certificate for `*.xip.example.com`, simply go through the regular
Let's Encrypt DNS-01 challenge process.
Let's Encrypt will query your name servers for the TXT record
`_acme-challenge.xip.example.com`, then your DNS server will respond with the
TXT record _that should have been created on Route53 as part of the challenge_,
otherwise it'll return the delegated nameservers (ns-ovh.sslip.io and so on).
### Using the sslip.io domain
You can procure a [wildcard](https://en.wikipedia.org/wiki/Wildcard_certificate)
certificate (e.g. `*.52-0-56-137.sslip.io`) from a certificate authority (e.g.
Let's Encrypt) using the [DNS-01
challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge).
You'll need the following:
- An internet-accessible DNS server that's authoritative for its `sslip.io`
subdomain For example, if the DNS server's IP address is `52.187.42.158`, the
DNS server would need to be authoritative for the domain
`52-187-42-158.sslip.io`. Pro-tip: it only needs to be authoritative for the
`_acme-challenge` subdomain, e.g. `_acme-challenge.52-187-42-158.sslip.io`;
furthermore, it only needs to return TXT records.
How to test that your DNS server is working properly (assuming you've set a
TXT record, "I love my dog"):
```
dig _acme-challenge.52-187-42-158.sslip.io txt
...
_acme-challenge.52-187-42-158.sslip.io 604800 IN TXT "I love my dog"
...
```
- An [ACME
v2](https://en.wikipedia.org/wiki/Automated_Certificate_Management_Environment)
protocol client; I use [acme.sh](https://github.com/acmesh-official/acme.sh).
The ACME client must be able to update the TXT records of your DNS server.
### Using the Wildcard Certificate
Once you've procured the wildcard certificate, you can install it on your
internal webservers for URLS of the following format:
https://*internal-ip.external-ip*.sslip.io (e.g.
<https://www-192-168-0-10.52-187-42-158.sslip.io>). Note that the _internal-ip_
portion of the URL _must_ be dash-separated, not dot-separated, for the wildcard
certificate to work properly.
Tech note: wildcard certificates can be used for development for machines behind
a firewall using non-routable IP addresses (10/8, 172.16/12, 192.168/16) by
taking advantage of the manner which `sslip.io` parses hostnames with embedded
IP addresses: left-to-right. The internal IP address is parsed first and
returned as the IP address of the hostname.
### How Do I Set Up an External DNS Server?
The external IP might be from your local network (forward port 53 at your
router), or from a cloud provider (GCP, AWS, etc.). It might even be from a
public DNS service (e.g. [Cloudflare](https://www.cloudflare.com/), [AWS Route
53](https://aws.amazon.com/route53/), my perennial favorite
[easyDNS](https://easydns.com/), etc.). If not using a public DNS service, you
need to run your own DNS server (e.g.
[acme-dns](https://github.com/joohoi/acme-dns), the venerable
[BIND](https://en.wikipedia.org/wiki/BIND), the opinionated
[djbdns](https://cr.yp.to/djbdns.html), or my personal
[wildcard-dns-http-server](https://github.com/cunnie/sslip.io/tree/main/src/wildcard-dns-http-server),
etc.). You can use any ACME client
([acme.sh](https://github.com/acmesh-official/acme.sh),
[Certbot](https://certbot.eff.org/), etc.), but you must configure it to request
a wildcard certificate for \*._external-ip_.sslip.io, which requires configuring
the DNS-01 challenge to use DNS server chosen.
#### Example
In the following example, we create a webserver on Google Cloud Platform (GCP)
to acquire a wildcard certificate. We use the ACME client acme.sh and the
DNS server wildcard-dns-http-server:
```bash
gcloud auth login
# set your project; mine is "blabbertabber"
gcloud config set project blabbertabber
# create your VM
gcloud compute instances create \
--image-project "ubuntu-os-cloud" \
--image-family "ubuntu-2004-lts" \
--machine-type f1-micro \
--boot-disk-size 40 \
--boot-disk-type pd-ssd \
--zone "us-west1-a" \
sslip
# get the IP, e.g. 35.199.174.9
export NAT_IP=$(gcloud compute instances list --filter="name=('sslip')" --format=json | \
jq -r '.[0].networkInterfaces[0].accessConfigs[0].natIP')
echo $NAT_IP
# get the fully-qualified domain name, e.g. 35-199-174-9.sslip.io
export FQDN=${NAT_IP//./-}.sslip.io
echo $FQDN
# set IP & FQDN on the VM because we'll need them later
gcloud compute ssh --command="echo export FQDN=$FQDN IP=$IP >> ~/.bashrc" --zone=us-west1-a sslip
# create the rules to allow DNS (and ICMP/ping) inbound
gcloud compute firewall-rules create sslip-io-allow-dns \
--allow udp:53,icmp \
--network=default \
--source-ranges 0.0.0.0/0 \
# ssh onto the VM
gcloud compute ssh sslip -- -A
# install docker
sudo apt update && sudo apt upgrade -y && sudo apt install -y docker.io jq
# add us to the docker group
sudo addgroup $USER docker
newgrp docker
# Create the necessary directories
mkdir -p tls/
# disable systemd-resolved to fix "Error starting userland proxy: listen tcp 0.0.0.0:53: bind: address already in use."
# thanks https://askubuntu.com/questions/907246/how-to-disable-systemd-resolved-in-ubuntu
sudo systemctl disable systemd-resolved
sudo systemctl stop systemd-resolved
echo nameserver 8.8.8.8 | sudo tee /etc/resolv.conf
# Let's start it up:
docker run -it --rm --name wildcard \
-p 53:53/udp \
-p 80:80 \
cunnie/wildcard-dns-http-server &
dig +short TXT does.not.matter.example.com @localhost
# You should see `"Set this TXT record ..."`
export ACMEDNS_UPDATE_URL="http://localhost/update"
docker run --rm -it \
-v $PWD/tls:/acme.sh \
-e ACMEDNS_UPDATE_URL \
--net=host \
neilpang/acme.sh \
--issue \
--debug \
-d $FQDN \
-d *.$FQDN \
--dns dns_acmedns
ls tls/$FQDN # you'll see the new cert, key, certificate
openssl x509 -in tls/$FQDN/$FQDN.cer -noout -text # read the cert info
```
Save the cert, key, certificate, intermediate ca, fullchain cert. They are in
`tls/$FQDN/`.
Clean-up:
```
gcloud compute firewall-rules delete sslip-io-allow-dns
gcloud compute instances delete sslip
```
#### Troubleshooting / Debugging
Run the server in one window so you can see the output, and then ssh into
another window and watch the log output in realtime.
```
gcloud compute ssh sslip -- -A
docker run -it --rm --name wildcard \
-p 53:53/udp \
-p 80:80 \
cunnie/wildcard-dns-http-server
```
Notes about the logging output: any line that has the string "`TypeTXT →`" is
output from the DNS server; everything else is output from the HTTP server which
is used to create TXT records which the DNS server serves.
Use `acme.sh`'s `--staging` flag to make sure it works (so you don't run into
Let's Encrypt's [rate limits](https://letsencrypt.org/docs/rate-limits/) with
failed attempts).
```
docker run --rm -it \
-v $PWD/tls:/acme.sh \
-e ACMEDNS_UPDATE_URL \
--net=host \
neilpang/acme.sh \
--issue \
--staging \
--debug \
-d *.$FQDN \
--dns dns_acmedns
```

View File

@@ -1,51 +0,0 @@
# cunnie/wildcard-dns-http-server: sslip.io wildcard DNS/HTTP server Dockerfile
# This DNS/HTTP server enables the procurement of wildcard certs for sslip.io
# subdomains. It's meant to be run on the server whose IP address is the
# subdomain. e.g. if the subdomain was '207-44-147-10.sslip.io', then this
# should be run on the server whose IP address is 207.44.147.10, and this will
# procure a wildcard cert for *.207-44-147-10.sslip.io
# This won't work for private addresses such as 10.0.1.10 or 192.168.0.1.
# Dockerfile of a (Golang-based) DNS/HTTP server.
# - the DNS server only responds to TXT queries, and always responds to TXT queries,
# and always responds with the same TXT record
# - the HTTP server allows you to update the TXT record by POST'ing to the /update
# endpoint with a JSON body of `{"txt":"the-new-TXT-record"}`. The endpoint
# is compatible with acme-dns.
# - acme.sh can be configured to update the DNS TXT record via HTTPS.
# To build:
# DOCKER_BUILD_DIR=$PWD
# pushd ../src/wildcard-dns-http-server/
# GOOS=linux GOARCH=amd64 go build -o $DOCKER_BUILD_DIR/wildcard-dns-http-server
# popd
# docker build . -f Dockerfile-wildcard-dns-http-server -t cunnie/wildcard-dns-http-server
# Typical start command:
# docker run -it --rm -p 53:53/udp -p 80:80 cunnie/wildcard-dns-http-server
# To test from host:
# dig +short txt 127-0-0-1.example.com @localhost
# "Set this TXT record: curl -X POST http://localhost/update -d '{\"txt\":\"Certificate Authority's validation token\"}'"
# curl -X POST http://localhost/update -d '{"txt":"new-TXT-record"}'
# dig +short txt any-domain-you-want @localhost
# "new-TXT-record"
FROM alpine AS sslip.io
LABEL org.opencontainers.image.authors="Brian Cunnie <brian.cunnie@gmail.com>"
COPY wildcard-dns-http-server /usr/sbin/wildcard-dns-http-server
ENTRYPOINT ["/usr/sbin/wildcard-dns-http-server"]
# DNS listens on port 53 UDP
# The `EXPOSE` directive doesn't do much in our case. We use it for documentation.
EXPOSE 53/udp
EXPOSE 80/tcp

View File

@@ -172,9 +172,6 @@ dig @localhost 127-0-0-1.nip.io +short # returns "127.0.0.1"</pre>
https://www.52.0.56.137.xip.example.com/.
</p>
</div>
<p>if you're interested in acquiring a wildcard certificate for your nip.io domain, e.g.
"*.52-0-56-137.nip.io", the procedure is described <a
href="https://github.com/cunnie/sslip.io/blob/main/docs/wildcard.md">here</a>.</p>
<h3 id="experimental">Experimental Features</h3>
<p>Experimental features can change; don't depend on them.</p>
<h4 id="whatismyip">Determining Your External IP Address via DNS Lookup</h4>

1
src/.gitignore vendored
View File

@@ -1 +0,0 @@
.idea/

View File

@@ -1,5 +0,0 @@
module github.com/cunnie/sslip.io/src/wildcard-dns-http-server
go 1.15
require golang.org/x/net v0.7.0

View File

@@ -1,28 +0,0 @@
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -1,236 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"runtime"
"strings"
"sync"
"syscall"
"golang.org/x/net/dns/dnsmessage"
)
var txts = []string{`Set this TXT record: curl -X POST http://localhost/update -d '{"txt":"Certificate Authority validation token"}'`}
// Txt is for parsing the JSON POST to set the DNS TXT record
type Txt struct {
Txt string `json:"txt"`
}
func main() {
var wg sync.WaitGroup
log.Println("DNS: starting up.")
conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 53})
switch {
case err == nil:
log.Println(`DNS: Successfully bound to all interfaces, port 53.`)
wg.Add(1)
go dnsServer(conn, &wg)
case isErrorPermissionsError(err):
log.Println("DNS: Try invoking me with `sudo` because I don't have permission to bind to port 53.")
log.Fatal("DNS: " + err.Error())
case isErrorAddressAlreadyInUse(err):
log.Println(`DNS: I couldn't bind to "0.0.0.0:53" (INADDR_ANY, all interfaces), so I'll try to bind to each address individually.`)
ipCIDRs := listLocalIPCIDRs()
var boundIPsPorts, unboundIPs []string
for _, ipCIDR := range ipCIDRs {
ip, _, err := net.ParseCIDR(ipCIDR)
if err != nil {
log.Printf(`DNS: I couldn't parse the local interface "%s".`, ipCIDR)
continue
}
conn, err = net.ListenUDP("udp", &net.UDPAddr{
IP: ip,
Port: 53,
Zone: "",
})
if err != nil {
unboundIPs = append(unboundIPs, ip.String())
} else {
wg.Add(1)
boundIPsPorts = append(boundIPsPorts, conn.LocalAddr().String())
go dnsServer(conn, &wg)
}
}
if len(boundIPsPorts) > 0 {
log.Printf(`DNS: I bound to the following: "%s"`, strings.Join(boundIPsPorts, `", "`))
}
if len(unboundIPs) > 0 {
log.Printf(`DNS: I couldn't bind to the following IPs: "%s"`, strings.Join(unboundIPs, `", "`))
}
default:
log.Fatal("DNS: " + err.Error())
}
wg.Add(1)
go httpServer(&wg)
wg.Wait()
}
func dnsServer(conn *net.UDPConn, group *sync.WaitGroup) {
var query dnsmessage.Message
defer group.Done()
queryRaw := make([]byte, 512)
for {
_, addr, err := conn.ReadFromUDP(queryRaw)
if err != nil {
log.Println("DNS: " + err.Error())
continue
}
err = query.Unpack(queryRaw)
if err != nil {
log.Println("DNS: " + err.Error())
continue
}
// Technically, there can be multiple questions in a DNS message; practically, there's only one
if len(query.Questions) != 1 {
log.Printf("DNS: I expected one question but got %d.\n", len(query.Questions))
continue
}
// We only return answers to TXT queries, nothing else
if query.Questions[0].Type != dnsmessage.TypeTXT {
log.Println("DNS: I expected a question for a TypeTXT record but got a question for a " + query.Questions[0].Type.String() + " record.")
continue
}
var txtAnswers = []dnsmessage.Resource{}
for _, txt := range txts {
txtAnswers = append(txtAnswers, dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{
Name: query.Questions[0].Name,
Type: dnsmessage.TypeTXT,
Class: dnsmessage.ClassINET,
TTL: 60,
},
Body: &dnsmessage.TXTResource{TXT: []string{txt}},
})
}
reply := dnsmessage.Message{
Header: dnsmessage.Header{
ID: query.ID,
Response: true,
Authoritative: true,
RecursionDesired: query.RecursionDesired,
},
Questions: query.Questions,
Answers: txtAnswers,
}
replyRaw, err := reply.Pack()
if err != nil {
log.Println("DNS: " + err.Error())
continue
}
_, err = conn.WriteToUDP(replyRaw, addr)
if err != nil {
log.Println("DNS: " + err.Error())
continue
}
log.Printf("DNS: %v.%d %s → \"%v\"\n", addr.IP, addr.Port, query.Questions[0].Type.String(), txts)
}
}
func httpServer(group *sync.WaitGroup) {
defer group.Done()
log.Println("HTTP: starting up.")
http.HandleFunc("/", usageHandler)
http.HandleFunc("/update", updateTxtHandler)
log.Fatal("HTTP: " + http.ListenAndServe(":80", nil).Error())
}
func usageHandler(w http.ResponseWriter, r *http.Request) {
_, err := fmt.Fprintln(w, `Set the TXT record: curl -X POST http://localhost/update -d '{"txt":"Certificate Authority's validation token"}'`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println("HTTP: " + err.Error())
}
log.Printf("HTTP: wrong path (%s) with method (%s).\n", r.URL.Path, r.Method)
}
func updateTxtHandler(w http.ResponseWriter, r *http.Request) {
var err error
if r.Method != http.MethodPost {
err = errors.New("/update requires POST method, not " + r.Method + " method")
http.Error(w, err.Error(), http.StatusBadRequest)
log.Println("HTTP: " + err.Error())
return
}
var body []byte
if body, err = ioutil.ReadAll(r.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println("HTTP: " + err.Error())
return
}
var updateTxt Txt
if err := json.Unmarshal(body, &updateTxt); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println("HTTP: " + err.Error())
return
}
if body, err = json.Marshal(updateTxt); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println("HTTP: " + err.Error())
return
}
if _, err = fmt.Fprintf(w, string(body)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println("HTTP: " + err.Error())
return
}
log.Println("HTTP: Creating new TXT record \"" + updateTxt.Txt + "\".")
// this is the money shot, where we create a new DNS TXT record to what was in the POST request
txts = append(txts, updateTxt.Txt)
}
func listLocalIPCIDRs() []string {
var ifaces []net.Interface
var cidrStrings []string
var err error
if ifaces, err = net.Interfaces(); err != nil {
panic(err)
}
for _, iface := range ifaces {
var cidrs []net.Addr
if cidrs, err = iface.Addrs(); err != nil {
panic(err)
}
for _, cidr := range cidrs {
cidrStrings = append(cidrStrings, cidr.String())
}
}
return cidrStrings
}
// Thanks https://stackoverflow.com/a/52152912/2510873
func isErrorAddressAlreadyInUse(err error) bool {
var eOsSyscall *os.SyscallError
if !errors.As(err, &eOsSyscall) {
return false
}
var errErrno syscall.Errno // doesn't need a "*" (ptr) because it's already a ptr (uintptr)
if !errors.As(eOsSyscall, &errErrno) {
return false
}
if errErrno == syscall.EADDRINUSE {
return true
}
const WSAEADDRINUSE = 10048
if runtime.GOOS == "windows" && errErrno == WSAEADDRINUSE {
return true
}
return false
}
func isErrorPermissionsError(err error) bool {
var eOsSyscall *os.SyscallError
if errors.As(err, &eOsSyscall) {
if os.IsPermission(eOsSyscall) {
return true
}
}
return false
}