diff --git a/Dockerfile b/Dockerfile index 3d6c462..391942a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,13 @@ FROM gcr.io/distroless/base-debian12:latest ENV PORT 53 -WORKDIR / +WORKDIR /app -COPY --from=build /app/local-ip / -COPY ./.lego /.lego +COPY --from=build /app/local-ip /app/local-ip +COPY --from=build /app/http/static /app/http/static +COPY ./.lego /app/.lego EXPOSE $PORT USER root -CMD ["/local-ip"] +CMD ["/app/local-ip"] diff --git a/README.md b/README.md index 7e23a0a..fd65450 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ as a proxy to access the files securely. ## Self-hosting -I'm currently hosting [local-ip.sh](https://www.local-ip.sh) at [Fly.io](https://fly.io) but you can host the service yourself +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: diff --git a/certs/certs.go b/certs/certs.go index 4aeca48..73e87eb 100644 --- a/certs/certs.go +++ b/certs/certs.go @@ -2,6 +2,7 @@ package certs import ( "encoding/json" + "fmt" "log" "os" "strings" @@ -15,14 +16,32 @@ import ( ) type certsClient struct { - legoClient *lego.Client - lastCertificate *certificate.Resource + legoClient *lego.Client + lastWildcardCertificate *certificate.Resource + lastRootCertificate *certificate.Resource } -func (c *certsClient) RequestCertificate() { - log.Println("requesting a certificate") - if c.lastCertificate != nil { - certificates, err := certcrypto.ParsePEMBundle(c.lastCertificate.Certificate) +func (c *certsClient) RequestCertificates() { + c.requestCertificate("wildcard") + c.requestCertificate("root") +} + +func (c *certsClient) requestCertificate(certType string) { + var lastCertificate *certificate.Resource + var domains []string + if certType == "wildcard" { + lastCertificate = c.lastWildcardCertificate + domains = []string{"*.local-ip.sh"} + } else if certType == "root" { + lastCertificate = c.lastRootCertificate + domains = []string{"local-ip.sh"} + } else { + log.Fatalf("Unexpected certType %s. Only \"wildcard\" and \"root\" are supported", certType) + } + + log.Printf("requesting %s certificate\n", certType) + if lastCertificate != nil { + certificates, err := certcrypto.ParsePEMBundle(c.lastWildcardCertificate.Certificate) if err != nil { log.Fatal(err) } @@ -34,45 +53,55 @@ func (c *certsClient) RequestCertificate() { return } - c.renewCertificate() + c.renewCertificates() return } certificates, err := c.legoClient.Certificate.Obtain(certificate.ObtainRequest{ - Domains: []string{"*.local-ip.sh"}, + Domains: domains, Bundle: true, }) if err != nil { log.Fatal(err) } - c.lastCertificate = certificates + if certType == "wildcard" { + c.lastWildcardCertificate = certificates + } else if certType == "root" { + c.lastRootCertificate = certificates + } + + persistFiles(certificates, certType) - persistFiles(certificates) - log.Printf("%#v\n", certificates) } -func (c *certsClient) renewCertificate() { +func (c *certsClient) renewCertificates() { log.Println("renewing currently existing certificate") - certificates, err := c.legoClient.Certificate.Renew(*c.lastCertificate, true, false, "") + wildcardCertificate, err := c.legoClient.Certificate.Renew(*c.lastWildcardCertificate, true, false, "") if err != nil { log.Fatal(err) } + c.lastWildcardCertificate = wildcardCertificate + persistFiles(wildcardCertificate, "wildcard") - c.lastCertificate = certificates + rootCertificate, err := c.legoClient.Certificate.Renew(*c.lastRootCertificate, true, false, "") + if err != nil { + log.Fatal(err) + } + c.lastRootCertificate = rootCertificate + persistFiles(rootCertificate, "root") - persistFiles(certificates) - log.Printf("%#v\n", certificates) } -func persistFiles(certificates *certificate.Resource) { - os.WriteFile("/certs/server.pem", certificates.Certificate, 0o644) - os.WriteFile("/certs/server.key", certificates.PrivateKey, 0o644) +func persistFiles(certificates *certificate.Resource, certType string) { + os.MkdirAll(fmt.Sprintf("/certs/%s", certType), 0o755) + os.WriteFile(fmt.Sprintf("/certs/%s/server.pem", certType), certificates.Certificate, 0o644) + os.WriteFile(fmt.Sprintf("/certs/%s/server.key", certType), certificates.PrivateKey, 0o644) jsonBytes, err := json.MarshalIndent(certificates, "", "\t") if err != nil { log.Fatal(err) } - os.WriteFile("/certs/output.json", jsonBytes, 0o644) + os.WriteFile(fmt.Sprintf("/certs/%s/output.json", certType), jsonBytes, 0o644) } func NewCertsClient(xip *xip.Xip, user *Account) *certsClient { @@ -86,16 +115,18 @@ func NewCertsClient(xip *xip.Xip, user *Account) *certsClient { provider := newProviderLocalIp(xip) legoClient.Challenge.SetDNS01Provider(provider, dns01.AddRecursiveNameservers([]string{"1.1.1.1:53", "8.8.8.8:53"}), dns01.DisableCompletePropagationRequirement()) - lastCertificate := getLastCertificate(legoClient) + lastWildcardCertificate := getLastCertificate(legoClient, "wildcard") + lastRootCertificate := getLastCertificate(legoClient, "root") return &certsClient{ legoClient, - lastCertificate, + lastWildcardCertificate, + lastRootCertificate, } } -func getLastCertificate(legoClient *lego.Client) *certificate.Resource { - jsonBytes, err := os.ReadFile("/certs/output.json") +func getLastCertificate(legoClient *lego.Client, certType string) *certificate.Resource { + jsonBytes, err := os.ReadFile(fmt.Sprintf("/certs/%s/output.json", certType)) if err != nil { if strings.Contains(err.Error(), "no such file or directory") { return nil diff --git a/certs/config.go b/certs/config.go index ab4033d..b9577b1 100644 --- a/certs/config.go +++ b/certs/config.go @@ -10,6 +10,7 @@ import ( const ( email = "admin@local-ip.sh" caDirUrl = lego.LEDirectoryProduction + // caDirUrl = lego.LEDirectoryStaging ) var ( diff --git a/fly.toml b/fly.toml index bf8724d..3aeb3da 100644 --- a/fly.toml +++ b/fly.toml @@ -20,14 +20,6 @@ PORT = "53" source = "certs" destination = "/certs" -[http_service] -internal_port = 53 -force_https = true -auto_stop_machines = false -auto_start_machines = true -min_machines_running = 1 -processes = ["app"] - [[services]] protocol = "udp" internal_port = 53 @@ -38,7 +30,33 @@ min_machines_running = 0 [[services.ports]] port = 53 +[[services]] +protocol = "tcp" +internal_port = 80 + +[[services.ports]] +port = 80 + +[[services]] +protocol = "tcp" +internal_port = 443 + +[[services.ports]] +port = 443 + +# [[services.http_checks]] +# interval = 10000 +# grace_period = "30s" +# method = "get" +# path = "/" +# protocol = "https" +# timeout = 15000 +# tls_skip_verify = false +# tls_server_name = "local-ip.sh" +# [services.http_checks.headers] + [[vm]] +size = "shared-cpu-1x" cpu_kind = "shared" cpus = 1 memory_mb = 256 diff --git a/http/server.go b/http/server.go index a2cdf9f..44bb673 100644 --- a/http/server.go +++ b/http/server.go @@ -3,17 +3,61 @@ package http import ( "log" "net/http" + "os" + "strings" + "time" ) -func ServeCertificate() { +func ServeHttp() { + waitForCertificate() + + go http.ListenAndServe(":80", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + url := r.URL + url.Host = r.Host + url.Scheme = "https" + http.Redirect(w, r, url.String(), http.StatusMovedPermanently) + })) + http.HandleFunc("/server.key", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") - http.ServeFile(w, r, "/certs/server.key") + http.ServeFile(w, r, "/certs/wildcard/server.key") }) http.HandleFunc("/server.pem", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-x509-ca-cert") - http.ServeFile(w, r, "/certs/server.pem") + http.ServeFile(w, r, "/certs/wildcard/server.pem") }) - log.Printf("Serving cert files on :9229\n") - http.ListenAndServe(":9229", nil) + http.HandleFunc("/og.png", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeFile(w, r, "./http/static/og.png") + }) + http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/x-icon; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeFile(w, r, "./http/static/favicon.ico") + }) + http.HandleFunc("/styles.css", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + http.ServeFile(w, r, "./http/static/styles.css") + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeFile(w, r, "./http/static/index.html") + }) + log.Printf("Serving HTTPS server on :443\n") + http.ListenAndServeTLS(":443", "/certs/root/server.pem", "/certs/root/server.key", nil) +} + +func waitForCertificate() { + for { + _, err := os.Stat("/certs/root/output.json") + if err != nil { + if strings.Contains(err.Error(), "no such file or directory") { + time.Sleep(1 * time.Second) + continue + } + log.Fatal(err) + } + break + } } diff --git a/http/static/favicon.ico b/http/static/favicon.ico new file mode 100644 index 0000000..e29f8ee Binary files /dev/null and b/http/static/favicon.ico differ diff --git a/http/static/index.html b/http/static/index.html new file mode 100644 index 0000000..d8af752 --- /dev/null +++ b/http/static/index.html @@ -0,0 +1,89 @@ + + + + + + + local-ip.sh + + + + + + + + + + + + + + + + + + + + +
+
+  _                 _       _            _
+ | |               | |     (_)          | |
+ | | ___   ___ __ _| |      _ _ __   ___| |__
+ | |/ _ \ / __/ _` | |_____| | '_ \ / __| '_ \
+ | | (_) | (_| (_| | |_____| | |_) |\__ \ | | |
+ |_|\___/ \___\__,_|_|     |_| .__(_)___/_| |_|
+                             | |
+                             |_|
+        
+
+
+
+
What is local-ip.sh?
+
+
local-ip.sh is a magic domain name that provides wildcard DNS for any IP address. It is heavily + inspired by local-ip.co, sslip.io, and xip.io.
+
Quick example, say your LAN IP address is 192.168.1.10. Using + local-ip.sh,

+
       192.168.1.10.local-ip.sh resolves to 192.168.1.10
+     dots.192.168.1.10.local-ip.sh resolves to 192.168.1.10
+   dashes.192-168-1-10.local-ip.sh resolves to 192.168.1.10
+
+
...and so on. You can use these domains to access virtual hosts on your development web server + from devices on your local network. No configuration required!
+
The best part is, you can serve your content over HTTPS with our TLS certificate for + *.local-ip.sh:Be aware that wildcard certificates are not recursive, meaning they don't match + "sub-subdomains".
In our case, this certificate will only match subdomains of + local-ip.sh such as 192-168-1-10.local-ip.sh where dashes separate + the numbers that make up the IP address.
+
+
+
+
How does it work?
+
+
+ local-ip.sh runs publicly a custom DNS server. + When your computer looks up a local-ip.sh domain, the local-ip.sh DNS server resolves to the IP address it extracts from the domain. +
+
+ The TLS certificate is obtained from Let's Encrypt and renewed up to a month before it expires. +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/http/static/og.png b/http/static/og.png new file mode 100644 index 0000000..d0c1781 Binary files /dev/null and b/http/static/og.png differ diff --git a/http/static/styles.css b/http/static/styles.css new file mode 100644 index 0000000..23e1261 --- /dev/null +++ b/http/static/styles.css @@ -0,0 +1,107 @@ +html { + background: #111; +} + +body { + color: #728ea7; + display: flex; + flex-direction: column; + margin-inline: auto; + padding-left: 1.5em; + padding-right: 1.5em; + width: min(100%, 41.5rem); + margin-top: 50px; + margin-bottom: 50px; + + font-family: ui-monospace, monospace; + font-size: 18px; + font-weight: bold; + + -webkit-font-smoothing: antialiased; +} + +header { + color: #7aa6da; + display: flex; +} + +header > pre { + margin: 1rem auto; +} + +main a { + color: #728ea7; +} + +main a:hover { + background: #7aa6da; + color: #111; + text-decoration: none; +} + +section:nth-child(n + 2) { + margin-top: 3rem; +} + +section > main { + display: flex; + flex-direction: column; + justify-content: space-between; + row-gap: 2rem; +} + +code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + background-color: rgba(27, 31, 35, 0.95); + border-radius: 3px; + white-space: nowrap; +} + +header span.dim { + color: #728ea7; +} + +section strong { + color: #7aa6da; +} + +footer { + margin: 5rem auto 0; + color: #556a7d; +} + +footer a { + color: inherit; +} + +footer a:hover { + color: #728ea7; +} + +div.cursor { + display: inline-block; + background: #111; + margin-left: 1px; + margin-right: -1px; + animation: blink 2s linear 0s infinite; +} + +@keyframes blink { + 0% { + background: #7aa6da; + } + 47% { + background: #728ea7; + } + 50% { + background: #111; + } + 97% { + background: #111; + } + 100% { + background: #728ea7; + } +} diff --git a/main.go b/main.go index 449f308..9f4d601 100644 --- a/main.go +++ b/main.go @@ -20,16 +20,16 @@ func main() { certsClient := certs.NewCertsClient(n, account) time.Sleep(5 * time.Second) - certsClient.RequestCertificate() + certsClient.RequestCertificates() for { // try to renew certificate every day time.Sleep(24 * time.Hour) - certsClient.RequestCertificate() + certsClient.RequestCertificates() } }() - go http.ServeCertificate() + go http.ServeHttp() n.StartServer() } diff --git a/xip/xip.go b/xip/xip.go index c97969e..68c1aa3 100644 --- a/xip/xip.go +++ b/xip/xip.go @@ -23,6 +23,7 @@ type HardcodedRecord struct { TXT *dns.TXT MX []*dns.MX CNAME []*dns.CNAME + SRV *dns.SRV } const ( @@ -54,47 +55,45 @@ var ( }, "local-ip.sh.": { A: []*dns.A{ - {A: net.IPv4(66, 241, 125, 48)}, - }, - AAAA: []*dns.AAAA{ - {AAAA: net.IP{0x2a, 0x09, 0x82, 0x80, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x1C, 0xC1, 0xC1}}, + // {A: net.IPv4(66, 241, 125, 48)}, + {A: net.IPv4(137, 66, 40, 11)}, // fly.io edge-only ip address }, TXT: &dns.TXT{ Txt: []string{ "sl-verification=frudknyqpqlpgzbglkqnsmorfcvxrf", - "v=spf1 include:simplelogin.co ~all", + "v=spf1 include:capsulecorp.dev ~all", }, }, MX: []*dns.MX{ - {Preference: 10, Mx: "mx1.simplelogin.co."}, - {Preference: 20, Mx: "mx2.simplelogin.co."}, + {Preference: 10, Mx: "email.capsulecorp.dev."}, + }, + }, + "autodiscover.local-ip.sh.": { + CNAME: []*dns.CNAME{ + {Target: "email.capsulecorp.dev"}, + }, + }, + "_autodiscover._tcp.local-ip.sh.": { + SRV: &dns.SRV{ + Target: "email.capsulecorp.dev 443", + }, + }, + "autoconfig.local-ip.sh.": { + CNAME: []*dns.CNAME{ + {Target: "email.capsulecorp.dev"}, }, }, "_dmarc.local-ip.sh.": { TXT: &dns.TXT{ - Txt: []string{"v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"}, + Txt: []string{"v=DMARC1; p=none; rua=mailto:postmaster@local-ip.sh; ruf=mailto:admin@local-ip.sh"}, }, }, "dkim._domainkey.local-ip.sh.": { - CNAME: []*dns.CNAME{ - {Target: "dkim._domainkey.simplelogin.co."}, - }, - }, - "dkim02._domainkey.local-ip.sh.": { - CNAME: []*dns.CNAME{ - {Target: "dkim02._domainkey.simplelogin.co."}, - }, - }, - "dkim03._domainkey.local-ip.sh.": { - CNAME: []*dns.CNAME{ - {Target: "dkim03._domainkey.simplelogin.co."}, + TXT: &dns.TXT{ + Txt: []string{"v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMW6NFo34qzKRPbzK41GwbWncB8IDg1i2eA2VWznIVDmTzzsqILaBOGv2xokVpzZm0QRF9wSbeVUmvwEeQ7Z6wkfMjawenDEc3XxsNSvQUVBP6LU/xcm1zsR8wtD8r5J+Jm45pNFaateiM/kb/Eypp2ntdtd8CPsEgCEDpNb62LWdy0yzRdZ/M/fNn51UMN8hVFp4YfZngAt3bQwa6kPtgvTeqEbpNf5xanpDysNJt2S8zfqJMVGvnr8JaJiTv7ZlKMMp94aC5Ndcir1WbMyfmgSnGgemuCTVMWDGPJnXDi+8BQMH1b1hmTpWDiVdVlehyyWx5AfPrsWG9cEuDIfXwIDAQAB"}, }, }, "_acme-challenge.local-ip.sh.": { - // required for fly.io to obtain a certificate for the website - CNAME: []*dns.CNAME{ - {Target: "local-ip.sh.zzkxm3.flydns.net."}, - }, // will be filled in later when requesting the wildcard certificate TXT: &dns.TXT{}, }, @@ -365,7 +364,6 @@ func (xip *Xip) handleDnsRequest(response dns.ResponseWriter, request *dns.Msg) } func (xip *Xip) StartServer() { - log.Printf("Listening on %s\n", xip.server.Addr) err := xip.server.ListenAndServe() defer xip.server.Shutdown() if err != nil { @@ -383,6 +381,7 @@ func (xip *Xip) StartServer() { log.Fatalf("Failed to start server: %s\n ", err.Error()) } + log.Printf("Listening on %s\n", xip.server.Addr) } func NewXip(port int) (xip *Xip) {