https server

This commit is contained in:
m5r 2024-07-10 01:09:18 +02:00
parent ccaef7827c
commit 92cdddec9c
Signed by: mokhtar
GPG Key ID: 1509B54946D08A95
12 changed files with 360 additions and 70 deletions

View File

@ -10,12 +10,13 @@ FROM gcr.io/distroless/base-debian12:latest
ENV PORT 53 ENV PORT 53
WORKDIR / WORKDIR /app
COPY --from=build /app/local-ip / COPY --from=build /app/local-ip /app/local-ip
COPY ./.lego /.lego COPY --from=build /app/http/static /app/http/static
COPY ./.lego /app/.lego
EXPOSE $PORT EXPOSE $PORT
USER root USER root
CMD ["/local-ip"] CMD ["/app/local-ip"]

View File

@ -39,7 +39,7 @@ as a proxy to access the files securely.
## Self-hosting ## 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. 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: You will essentially need to:

View File

@ -2,6 +2,7 @@ package certs
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"os" "os"
"strings" "strings"
@ -16,13 +17,31 @@ import (
type certsClient struct { type certsClient struct {
legoClient *lego.Client legoClient *lego.Client
lastCertificate *certificate.Resource lastWildcardCertificate *certificate.Resource
lastRootCertificate *certificate.Resource
} }
func (c *certsClient) RequestCertificate() { func (c *certsClient) RequestCertificates() {
log.Println("requesting a certificate") c.requestCertificate("wildcard")
if c.lastCertificate != nil { c.requestCertificate("root")
certificates, err := certcrypto.ParsePEMBundle(c.lastCertificate.Certificate) }
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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -34,45 +53,55 @@ func (c *certsClient) RequestCertificate() {
return return
} }
c.renewCertificate() c.renewCertificates()
return return
} }
certificates, err := c.legoClient.Certificate.Obtain(certificate.ObtainRequest{ certificates, err := c.legoClient.Certificate.Obtain(certificate.ObtainRequest{
Domains: []string{"*.local-ip.sh"}, Domains: domains,
Bundle: true, Bundle: true,
}) })
if err != nil { if err != nil {
log.Fatal(err) 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") 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 { if err != nil {
log.Fatal(err) 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) { func persistFiles(certificates *certificate.Resource, certType string) {
os.WriteFile("/certs/server.pem", certificates.Certificate, 0o644) os.MkdirAll(fmt.Sprintf("/certs/%s", certType), 0o755)
os.WriteFile("/certs/server.key", certificates.PrivateKey, 0o644) 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") jsonBytes, err := json.MarshalIndent(certificates, "", "\t")
if err != nil { if err != nil {
log.Fatal(err) 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 { func NewCertsClient(xip *xip.Xip, user *Account) *certsClient {
@ -86,16 +115,18 @@ func NewCertsClient(xip *xip.Xip, user *Account) *certsClient {
provider := newProviderLocalIp(xip) provider := newProviderLocalIp(xip)
legoClient.Challenge.SetDNS01Provider(provider, dns01.AddRecursiveNameservers([]string{"1.1.1.1:53", "8.8.8.8:53"}), dns01.DisableCompletePropagationRequirement()) 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{ return &certsClient{
legoClient, legoClient,
lastCertificate, lastWildcardCertificate,
lastRootCertificate,
} }
} }
func getLastCertificate(legoClient *lego.Client) *certificate.Resource { func getLastCertificate(legoClient *lego.Client, certType string) *certificate.Resource {
jsonBytes, err := os.ReadFile("/certs/output.json") jsonBytes, err := os.ReadFile(fmt.Sprintf("/certs/%s/output.json", certType))
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no such file or directory") { if strings.Contains(err.Error(), "no such file or directory") {
return nil return nil

View File

@ -10,6 +10,7 @@ import (
const ( const (
email = "admin@local-ip.sh" email = "admin@local-ip.sh"
caDirUrl = lego.LEDirectoryProduction caDirUrl = lego.LEDirectoryProduction
// caDirUrl = lego.LEDirectoryStaging
) )
var ( var (

View File

@ -20,14 +20,6 @@ PORT = "53"
source = "certs" source = "certs"
destination = "/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]] [[services]]
protocol = "udp" protocol = "udp"
internal_port = 53 internal_port = 53
@ -38,7 +30,33 @@ min_machines_running = 0
[[services.ports]] [[services.ports]]
port = 53 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]] [[vm]]
size = "shared-cpu-1x"
cpu_kind = "shared" cpu_kind = "shared"
cpus = 1 cpus = 1
memory_mb = 256 memory_mb = 256

View File

@ -3,17 +3,61 @@ package http
import ( import (
"log" "log"
"net/http" "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) { http.HandleFunc("/server.key", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream") 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) { http.HandleFunc("/server.pem", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-x509-ca-cert") 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.HandleFunc("/og.png", func(w http.ResponseWriter, r *http.Request) {
http.ListenAndServe(":9229", nil) 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
}
} }

BIN
http/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

89
http/static/index.html Normal file
View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1" />
<title>local-ip.sh</title>
<meta name="description" content="local-ip.sh is a magic domain name that provides wildcard DNS for any IP address." />
<meta name="author" content="Mokhtar Mial" />
<meta name="robots" content="index,follow" />
<meta name="googlebot" content="index,follow" />
<meta property="twitter:title" content="local-ip.sh" />
<meta property="twitter:description" content="local-ip.sh is a magic domain name that provides wildcard DNS for any IP address." />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:site" content="https://local-ip.sh/" />
<meta property="twitter:image" content="https://local-ip.sh/og.png" />
<meta property="twitter:image:alt" content="og image" />
<meta property="og:title" content="local-ip.sh" />
<meta property="og:description" content="local-ip.sh is a magic domain name that provides wildcard DNS for any IP address." />
<meta property="og:url" content="https://local-ip.sh/" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://local-ip.sh/og.png" />
<meta property="og:image:alt" content="og image" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header>
<pre>
_ _ _ _
| | | | (_) | |
| | ___ ___ __ _| | _ _ __ ___| |__
| |/ _ \ / __/ _` | |_____| | &#x27;_ \ / __| &#x27;_ \
| | (_) | (_| (_| | |_____| | |_) |\__ \ | | |
|_|\___/ \___\__,_|_| |_| .__(_)___/_| |_|
| |
|_|
</pre>
</header>
<main>
<section>
<header><strong>What is local-ip.sh?</strong></header>
<main>
<article>local-ip.sh is a magic domain name that provides wildcard DNS for any IP address. It is heavily
inspired by <a href="http://local-ip.co">local-ip.co</a>, <a
href="https://sslip.io">sslip.io</a>, and <a href="https://xip.io">xip.io</a>.</article>
<article>Quick example, say your LAN IP address is <strong>192.168.1.10</strong>. Using
local-ip.sh,<br /><br />
<pre> <strong>192.168.1.10</strong>.local-ip.sh resolves to 192.168.1.10
dots.<strong>192.168.1.10</strong>.local-ip.sh resolves to 192.168.1.10
dashes.<strong>192-168-1-10</strong>.local-ip.sh resolves to 192.168.1.10</pre>
</article>
<article>...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!</article>
<article>The best part is, you can serve your content over HTTPS with our TLS certificate for
<code>*.local-ip.sh</code>:<ul>
<li><a href="/server.pem">server.pem</a></li>
<li><a href="/server.key">server.key</a></li>
</ul>Be aware that wildcard certificates are not recursive, meaning they don&#x27;t match
&quot;sub-subdomains&quot;. <br />In our case, this certificate will only match subdomains of
<code>local-ip.sh</code> such as <code>192-168-1-10.local-ip.sh</code> where dashes separate
the numbers that make up the IP address.</article>
</main>
</section>
<section>
<header><strong>How does it work?</strong></header>
<main>
<article>
local-ip.sh runs publicly a <a href="https://git.capsulecorp.dev/mokhtar/local-ip.sh">custom DNS server</a>.
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.
</article>
<article>
The TLS certificate is obtained from Let&#x27;s Encrypt and renewed up to a month before it expires.
</article>
</main>
</section>
</main>
<footer class="copyright">© 2024 <a href="https://www.mokhtar.dev">Mokhtar Mial</a></footer>
<script>
(function () {
window.counterscale = {
q: [["set", "siteId", "local-ip"], ["trackPageview"]],
};
})();
</script>
<script id="counterscale-script" src="https://counterscale.m5r.workers.dev/tracker.js" defer=""></script>
</body>
</html>

BIN
http/static/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

107
http/static/styles.css Normal file
View File

@ -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;
}
}

View File

@ -20,16 +20,16 @@ func main() {
certsClient := certs.NewCertsClient(n, account) certsClient := certs.NewCertsClient(n, account)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
certsClient.RequestCertificate() certsClient.RequestCertificates()
for { for {
// try to renew certificate every day // try to renew certificate every day
time.Sleep(24 * time.Hour) time.Sleep(24 * time.Hour)
certsClient.RequestCertificate() certsClient.RequestCertificates()
} }
}() }()
go http.ServeCertificate() go http.ServeHttp()
n.StartServer() n.StartServer()
} }

View File

@ -23,6 +23,7 @@ type HardcodedRecord struct {
TXT *dns.TXT TXT *dns.TXT
MX []*dns.MX MX []*dns.MX
CNAME []*dns.CNAME CNAME []*dns.CNAME
SRV *dns.SRV
} }
const ( const (
@ -54,47 +55,45 @@ var (
}, },
"local-ip.sh.": { "local-ip.sh.": {
A: []*dns.A{ A: []*dns.A{
{A: net.IPv4(66, 241, 125, 48)}, // {A: net.IPv4(66, 241, 125, 48)},
}, {A: net.IPv4(137, 66, 40, 11)}, // fly.io edge-only ip address
AAAA: []*dns.AAAA{
{AAAA: net.IP{0x2a, 0x09, 0x82, 0x80, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x1C, 0xC1, 0xC1}},
}, },
TXT: &dns.TXT{ TXT: &dns.TXT{
Txt: []string{ Txt: []string{
"sl-verification=frudknyqpqlpgzbglkqnsmorfcvxrf", "sl-verification=frudknyqpqlpgzbglkqnsmorfcvxrf",
"v=spf1 include:simplelogin.co ~all", "v=spf1 include:capsulecorp.dev ~all",
}, },
}, },
MX: []*dns.MX{ MX: []*dns.MX{
{Preference: 10, Mx: "mx1.simplelogin.co."}, {Preference: 10, Mx: "email.capsulecorp.dev."},
{Preference: 20, Mx: "mx2.simplelogin.co."}, },
},
"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.": { "_dmarc.local-ip.sh.": {
TXT: &dns.TXT{ 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.": { "dkim._domainkey.local-ip.sh.": {
CNAME: []*dns.CNAME{ TXT: &dns.TXT{
{Target: "dkim._domainkey.simplelogin.co."}, Txt: []string{"v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMW6NFo34qzKRPbzK41GwbWncB8IDg1i2eA2VWznIVDmTzzsqILaBOGv2xokVpzZm0QRF9wSbeVUmvwEeQ7Z6wkfMjawenDEc3XxsNSvQUVBP6LU/xcm1zsR8wtD8r5J+Jm45pNFaateiM/kb/Eypp2ntdtd8CPsEgCEDpNb62LWdy0yzRdZ/M/fNn51UMN8hVFp4YfZngAt3bQwa6kPtgvTeqEbpNf5xanpDysNJt2S8zfqJMVGvnr8JaJiTv7ZlKMMp94aC5Ndcir1WbMyfmgSnGgemuCTVMWDGPJnXDi+8BQMH1b1hmTpWDiVdVlehyyWx5AfPrsWG9cEuDIfXwIDAQAB"},
},
},
"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."},
}, },
}, },
"_acme-challenge.local-ip.sh.": { "_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 // will be filled in later when requesting the wildcard certificate
TXT: &dns.TXT{}, TXT: &dns.TXT{},
}, },
@ -365,7 +364,6 @@ func (xip *Xip) handleDnsRequest(response dns.ResponseWriter, request *dns.Msg)
} }
func (xip *Xip) StartServer() { func (xip *Xip) StartServer() {
log.Printf("Listening on %s\n", xip.server.Addr)
err := xip.server.ListenAndServe() err := xip.server.ListenAndServe()
defer xip.server.Shutdown() defer xip.server.Shutdown()
if err != nil { if err != nil {
@ -383,6 +381,7 @@ func (xip *Xip) StartServer() {
log.Fatalf("Failed to start server: %s\n ", err.Error()) 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) { func NewXip(port int) (xip *Xip) {