Ben Allen пре 1 недеља
комит
d244df89e6
12 измењених фајлова са 1835 додато и 0 уклоњено
  1. 0 0
      .codex
  2. 6 0
      .gitignore
  3. 23 0
      Dockerfile
  4. 110 0
      README.md
  5. 34 0
      go.mod
  6. 89 0
      go.sum
  7. 669 0
      main.go
  8. 144 0
      main_test.go
  9. 11 0
      publish.sh
  10. 311 0
      static/app.js
  11. 295 0
      static/styles.css
  12. 143 0
      templates/index.html

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+tlsdns-tools
+*.exe
+*.out
+*.test
+coverage.out
+.DS_Store

+ 23 - 0
Dockerfile

@@ -0,0 +1,23 @@
+FROM golang:1.24 AS builder
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tools-server .
+
+FROM gcr.io/distroless/base-debian12
+
+WORKDIR /app
+
+COPY --from=builder /tools-server /app/tools-server
+COPY templates /app/templates
+COPY static /app/static
+
+EXPOSE 8080
+
+ENV PORT=8080
+
+ENTRYPOINT ["/app/tools-server"]

+ 110 - 0
README.md

@@ -0,0 +1,110 @@
+# Tools
+
+A small Go web app built with Gin that provides a browser-based toolkit for:
+
+- TLS certificate generation
+- DNS record lookup
+- SSL/TLS certificate inspection for a remote URL
+- PEM inspection for pasted certificates, CSRs, private keys, and public keys
+
+## Requirements
+
+- Go 1.24+
+- Optional: Docker
+
+## Run locally
+
+Start the app over HTTP on the default port:
+
+```bash
+go run .
+```
+
+The app listens on `PORT` if it is set, otherwise it uses `8080`.
+
+```bash
+PORT=9090 go run .
+```
+
+## Run with TLS
+
+If both `TLS_CERT_FILE` and `TLS_KEY_FILE` are provided, the server starts with HTTPS:
+
+```bash
+PORT=8443 TLS_CERT_FILE=server.crt TLS_KEY_FILE=server.key go run .
+```
+
+If only one of `TLS_CERT_FILE` or `TLS_KEY_FILE` is set, startup fails.
+
+## Available tools
+
+### TLS Generator
+
+Generate an RSA private key, public key, CSR, and self-signed certificate.
+
+- Supports subject fields like common name, organization, OU, locality, state/province, and country
+- Supports DNS SAN entries
+- Supports RSA key sizes `2048`, `3072`, and `4096`
+
+### DNS Lookup
+
+Look up common DNS records for a hostname:
+
+- `A` / `AAAA`
+- `CNAME`
+- `MX`
+- `NS`
+- `TXT`
+
+### SSL Check
+
+Enter a URL and the app connects to the remote server over TLS and displays:
+
+- Negotiated TLS version
+- Cipher suite
+- ALPN protocol
+- Leaf certificate PEM
+- Parsed certificate chain details
+
+### PEM Check
+
+Paste PEM blocks directly into the UI to inspect:
+
+- Certificates
+- Certificate signing requests
+- RSA private keys
+- PKCS#8 private keys
+- EC private keys
+- Public keys
+
+## API endpoints
+
+The UI uses these JSON endpoints:
+
+- `POST /api/tls/generate`
+- `POST /api/dns/lookup`
+- `POST /api/ssl/check`
+- `POST /api/pem/check`
+
+## Test and build
+
+```bash
+go test ./...
+go build ./...
+```
+
+## Docker
+
+Build the image manually:
+
+```bash
+docker build -t bmallenxs/tools:latest .
+```
+
+Or use the helper script:
+
+```bash
+./publish.sh
+```
+
+The script builds the Docker image with the tag `bmallenxs/tools:latest`.

+ 34 - 0
go.mod

@@ -0,0 +1,34 @@
+module tlsdns-tools
+
+go 1.24.5
+
+require github.com/gin-gonic/gin v1.10.0
+
+require (
+	github.com/bytedance/sonic v1.11.6 // indirect
+	github.com/bytedance/sonic/loader v0.1.1 // indirect
+	github.com/cloudwego/base64x v0.1.4 // indirect
+	github.com/cloudwego/iasm v0.2.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.20.0 // indirect
+	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.2.12 // indirect
+	golang.org/x/arch v0.8.0 // indirect
+	golang.org/x/crypto v0.23.0 // indirect
+	golang.org/x/net v0.25.0 // indirect
+	golang.org/x/sys v0.20.0 // indirect
+	golang.org/x/text v0.15.0 // indirect
+	google.golang.org/protobuf v1.34.1 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 89 - 0
go.sum

@@ -0,0 +1,89 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 669 - 0
main.go

@@ -0,0 +1,669 @@
+package main
+
+import (
+	"archive/zip"
+	"bytes"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/base64"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"math/big"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"reflect"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+type tlsGenerateRequest struct {
+	CommonName         string `json:"commonName"`
+	Organization       string `json:"organization"`
+	OrganizationalUnit string `json:"organizationalUnit"`
+	Locality           string `json:"locality"`
+	State              string `json:"state"`
+	Country            string `json:"country"`
+	DNSNames           string `json:"dnsNames"`
+	ValidDays          int    `json:"validDays"`
+	KeySize            int    `json:"keySize"`
+}
+
+type dnsLookupRequest struct {
+	Host string `json:"host"`
+}
+
+type sslCheckRequest struct {
+	URL string `json:"url"`
+}
+
+type pemCheckRequest struct {
+	PEM string `json:"pem"`
+}
+
+type apiError struct {
+	Error string `json:"error"`
+}
+
+func main() {
+	port := getenv("PORT", "8080")
+	certFile := strings.TrimSpace(os.Getenv("TLS_CERT_FILE"))
+	keyFile := strings.TrimSpace(os.Getenv("TLS_KEY_FILE"))
+
+	router := newRouter()
+	addr := ":" + port
+
+	var err error
+	if certFile != "" || keyFile != "" {
+		if certFile == "" || keyFile == "" {
+			panic("both TLS_CERT_FILE and TLS_KEY_FILE must be set to enable TLS")
+		}
+		err = router.RunTLS(addr, certFile, keyFile)
+	} else {
+		err = router.Run(addr)
+	}
+
+	if err != nil {
+		panic(err)
+	}
+}
+
+func newRouter() *gin.Engine {
+	router := gin.Default()
+	router.LoadHTMLGlob("templates/*")
+	router.Static("/static", "./static")
+	_ = router.SetTrustedProxies(nil)
+
+	router.GET("/", func(c *gin.Context) {
+		c.HTML(http.StatusOK, "index.html", gin.H{
+			"title": "Network Tools",
+		})
+	})
+
+	api := router.Group("/api")
+	{
+		api.POST("/tls/generate", handleTLSGenerate)
+		api.POST("/dns/lookup", handleDNSLookup)
+		api.POST("/ssl/check", handleSSLCheck)
+		api.POST("/pem/check", handlePEMCheck)
+	}
+
+	return router
+}
+
+func handleTLSGenerate(c *gin.Context) {
+	var req tlsGenerateRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, apiError{Error: "invalid request body"})
+		return
+	}
+
+	commonName := strings.TrimSpace(req.CommonName)
+	if commonName == "" {
+		c.JSON(http.StatusBadRequest, apiError{Error: "commonName is required"})
+		return
+	}
+
+	validDays := req.ValidDays
+	if validDays <= 0 {
+		validDays = 365
+	}
+
+	keySize := normalizeKeySize(req.KeySize)
+	if keySize == 0 {
+		c.JSON(http.StatusBadRequest, apiError{Error: "keySize must be one of 2048, 3072, or 4096"})
+		return
+	}
+
+	dnsNames := splitCSV(req.DNSNames)
+	if len(dnsNames) == 0 {
+		dnsNames = []string{commonName}
+	}
+
+	subject := pkix.Name{
+		CommonName:         commonName,
+		Organization:       splitCSV(req.Organization),
+		OrganizationalUnit: splitCSV(req.OrganizationalUnit),
+		Locality:           splitCSV(req.Locality),
+		Province:           splitCSV(req.State),
+		Country:            splitCSV(req.Country),
+	}
+
+	certPEM, keyPEM, publicKeyPEM, csrPEM, parsed, err := generateCertificate(subject, dnsNames, validDays, keySize)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, apiError{Error: err.Error()})
+		return
+	}
+
+	baseName := sanitizeFilename(commonName)
+	zipBytes, err := buildTLSArchive(baseName, certPEM, keyPEM, publicKeyPEM, csrPEM)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, apiError{Error: err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"certificatePem":           string(certPEM),
+		"selfSignedCertificatePem": string(certPEM),
+		"privateKeyPem":            string(keyPEM),
+		"publicKeyPem":             string(publicKeyPEM),
+		"csrPem":                   string(csrPEM),
+		"subject":                  parsed.Subject.String(),
+		"issuer":                   parsed.Issuer.String(),
+		"serialNumber":             parsed.SerialNumber.String(),
+		"notBefore":                parsed.NotBefore.Format(time.RFC3339),
+		"notAfter":                 parsed.NotAfter.Format(time.RFC3339),
+		"dnsNames":                 parsed.DNSNames,
+		"keySize":                  keySize,
+		"isSelfSigned":             parsed.Subject.String() == parsed.Issuer.String(),
+		"organization":             parsed.Subject.Organization,
+		"ou":                       parsed.Subject.OrganizationalUnit,
+		"locality":                 parsed.Subject.Locality,
+		"state":                    parsed.Subject.Province,
+		"country":                  parsed.Subject.Country,
+		"zipFilename":              baseName + ".zip",
+		"zipBase64":                base64.StdEncoding.EncodeToString(zipBytes),
+	})
+}
+
+func handleDNSLookup(c *gin.Context) {
+	var req dnsLookupRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, apiError{Error: "invalid request body"})
+		return
+	}
+
+	host := normalizeHost(req.Host)
+	if host == "" {
+		c.JSON(http.StatusBadRequest, apiError{Error: "host is required"})
+		return
+	}
+
+	ips, ipErr := net.LookupIP(host)
+	txt, txtErr := net.LookupTXT(host)
+	mx, mxErr := net.LookupMX(host)
+	ns, nsErr := net.LookupNS(host)
+	cname, cnameErr := net.LookupCNAME(host)
+
+	if ipErr != nil && txtErr != nil && mxErr != nil && nsErr != nil && cnameErr != nil {
+		c.JSON(http.StatusBadGateway, apiError{Error: fmt.Sprintf("lookup failed for %s", host)})
+		return
+	}
+
+	ipStrings := make([]string, 0, len(ips))
+	for _, ip := range ips {
+		ipStrings = append(ipStrings, ip.String())
+	}
+
+	txtStrings := make([]string, 0, len(txt))
+	txtStrings = append(txtStrings, txt...)
+
+	mxStrings := make([]gin.H, 0, len(mx))
+	for _, record := range mx {
+		mxStrings = append(mxStrings, gin.H{
+			"host": record.Host,
+			"pref": record.Pref,
+		})
+	}
+
+	nsStrings := make([]string, 0, len(ns))
+	for _, record := range ns {
+		nsStrings = append(nsStrings, record.Host)
+	}
+
+	errorsByType := gin.H{}
+	if ipErr != nil {
+		errorsByType["a_aaaa"] = ipErr.Error()
+	}
+	if txtErr != nil {
+		errorsByType["txt"] = txtErr.Error()
+	}
+	if mxErr != nil {
+		errorsByType["mx"] = mxErr.Error()
+	}
+	if nsErr != nil {
+		errorsByType["ns"] = nsErr.Error()
+	}
+	if cnameErr != nil {
+		errorsByType["cname"] = cnameErr.Error()
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"host":   host,
+		"ips":    ipStrings,
+		"txt":    txtStrings,
+		"mx":     mxStrings,
+		"ns":     nsStrings,
+		"cname":  strings.TrimSuffix(cname, "."),
+		"errors": errorsByType,
+	})
+}
+
+func handleSSLCheck(c *gin.Context) {
+	var req sslCheckRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, apiError{Error: "invalid request body"})
+		return
+	}
+
+	targetURL := strings.TrimSpace(req.URL)
+	if targetURL == "" {
+		c.JSON(http.StatusBadRequest, apiError{Error: "url is required"})
+		return
+	}
+
+	parsedURL, err := normalizeURL(targetURL)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, apiError{Error: err.Error()})
+		return
+	}
+
+	host := parsedURL.Hostname()
+	port := parsedURL.Port()
+	if port == "" {
+		port = "443"
+	}
+
+	conn, err := tls.DialWithDialer(
+		&net.Dialer{Timeout: 10 * time.Second},
+		"tcp",
+		net.JoinHostPort(host, port),
+		&tls.Config{ServerName: host},
+	)
+	if err != nil {
+		c.JSON(http.StatusBadGateway, apiError{Error: err.Error()})
+		return
+	}
+	defer conn.Close()
+
+	state := conn.ConnectionState()
+	if len(state.PeerCertificates) == 0 {
+		c.JSON(http.StatusBadGateway, apiError{Error: "no peer certificates returned"})
+		return
+	}
+
+	leaf := state.PeerCertificates[0]
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf.Raw})
+
+	chain := make([]gin.H, 0, len(state.PeerCertificates))
+	for _, cert := range state.PeerCertificates {
+		chain = append(chain, gin.H{
+			"subject":      cert.Subject.String(),
+			"issuer":       cert.Issuer.String(),
+			"serialNumber": cert.SerialNumber.String(),
+			"notBefore":    cert.NotBefore.Format(time.RFC3339),
+			"notAfter":     cert.NotAfter.Format(time.RFC3339),
+			"dnsNames":     cert.DNSNames,
+		})
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"url":                parsedURL.String(),
+		"serverName":         host,
+		"version":            tlsVersionName(state.Version),
+		"cipherSuite":        tls.CipherSuiteName(state.CipherSuite),
+		"negotiatedProtocol": state.NegotiatedProtocol,
+		"certificatePem":     string(certPEM),
+		"chain":              chain,
+	})
+}
+
+func handlePEMCheck(c *gin.Context) {
+	var req pemCheckRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, apiError{Error: "invalid request body"})
+		return
+	}
+
+	input := strings.TrimSpace(req.PEM)
+	if input == "" {
+		c.JSON(http.StatusBadRequest, apiError{Error: "pem is required"})
+		return
+	}
+
+	blocks, err := inspectPEM(input)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, apiError{Error: err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"blocks": blocks,
+		"count":  len(blocks),
+	})
+}
+
+func generateCertificate(subject pkix.Name, dnsNames []string, validDays int, keySize int) ([]byte, []byte, []byte, []byte, *x509.Certificate, error) {
+	privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
+	if err != nil {
+		return nil, nil, nil, nil, nil, err
+	}
+
+	serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialLimit)
+	if err != nil {
+		return nil, nil, nil, nil, nil, err
+	}
+
+	notBefore := time.Now().UTC()
+	template := &x509.Certificate{
+		SerialNumber:          serialNumber,
+		Subject:               subject,
+		NotBefore:             notBefore,
+		NotAfter:              notBefore.Add(time.Duration(validDays) * 24 * time.Hour),
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+		BasicConstraintsValid: true,
+		DNSNames:              dnsNames,
+	}
+
+	derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
+	if err != nil {
+		return nil, nil, nil, nil, nil, err
+	}
+
+	csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
+		Subject:            subject,
+		SignatureAlgorithm: x509.SHA256WithRSA,
+		DNSNames:           dnsNames,
+	}, privateKey)
+	if err != nil {
+		return nil, nil, nil, nil, nil, err
+	}
+
+	publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
+	if err != nil {
+		return nil, nil, nil, nil, nil, err
+	}
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
+	publicKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicKeyBytes})
+	csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes})
+
+	parsedCert, err := x509.ParseCertificate(derBytes)
+	if err != nil {
+		return nil, nil, nil, nil, nil, err
+	}
+
+	return certPEM, keyPEM, publicKeyPEM, csrPEM, parsedCert, nil
+}
+
+func buildTLSArchive(baseName string, certPEM []byte, keyPEM []byte, publicKeyPEM []byte, csrPEM []byte) ([]byte, error) {
+	var buffer bytes.Buffer
+	archive := zip.NewWriter(&buffer)
+
+	files := []struct {
+		name    string
+		content []byte
+	}{
+		{name: baseName + ".crt.pem", content: certPEM},
+		{name: baseName + ".key.pem", content: keyPEM},
+		{name: baseName + ".pub.pem", content: publicKeyPEM},
+		{name: baseName + ".csr.pem", content: csrPEM},
+	}
+
+	for _, file := range files {
+		writer, err := archive.Create(file.name)
+		if err != nil {
+			return nil, err
+		}
+		if _, err := writer.Write(file.content); err != nil {
+			return nil, err
+		}
+	}
+
+	if err := archive.Close(); err != nil {
+		return nil, err
+	}
+
+	return buffer.Bytes(), nil
+}
+
+func inspectPEM(input string) ([]gin.H, error) {
+	var blocks []gin.H
+	remaining := []byte(input)
+
+	for len(strings.TrimSpace(string(remaining))) > 0 {
+		block, rest := pem.Decode(remaining)
+		if block == nil {
+			return nil, errors.New("unable to decode PEM data")
+		}
+
+		inspected := gin.H{
+			"pemType": block.Type,
+		}
+
+		switch block.Type {
+		case "CERTIFICATE":
+			cert, err := x509.ParseCertificate(block.Bytes)
+			if err != nil {
+				return nil, err
+			}
+			inspected["kind"] = "certificate"
+			inspected["subject"] = cert.Subject.String()
+			inspected["issuer"] = cert.Issuer.String()
+			inspected["serialNumber"] = cert.SerialNumber.String()
+			inspected["notBefore"] = cert.NotBefore.Format(time.RFC3339)
+			inspected["notAfter"] = cert.NotAfter.Format(time.RFC3339)
+			inspected["dnsNames"] = cert.DNSNames
+			inspected["isCA"] = cert.IsCA
+			inspected["signatureAlgorithm"] = cert.SignatureAlgorithm.String()
+			inspected["publicKeyAlgorithm"] = cert.PublicKeyAlgorithm.String()
+		case "CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST":
+			csr, err := x509.ParseCertificateRequest(block.Bytes)
+			if err != nil {
+				return nil, err
+			}
+			inspected["kind"] = "certificate_request"
+			inspected["subject"] = csr.Subject.String()
+			inspected["dnsNames"] = csr.DNSNames
+			inspected["emailAddresses"] = csr.EmailAddresses
+			inspected["signatureAlgorithm"] = csr.SignatureAlgorithm.String()
+			inspected["publicKeyAlgorithm"] = csr.PublicKeyAlgorithm.String()
+		default:
+			keyInfo, err := inspectKeyBlock(block)
+			if err != nil {
+				return nil, err
+			}
+			for key, value := range keyInfo {
+				inspected[key] = value
+			}
+		}
+
+		blocks = append(blocks, inspected)
+		remaining = rest
+	}
+
+	if len(blocks) == 0 {
+		return nil, errors.New("no PEM blocks found")
+	}
+
+	return blocks, nil
+}
+
+func inspectKeyBlock(block *pem.Block) (gin.H, error) {
+	switch block.Type {
+	case "RSA PRIVATE KEY":
+		key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+		if err != nil {
+			return nil, err
+		}
+		return gin.H{
+			"kind":      "private_key",
+			"algorithm": "RSA",
+			"size":      key.N.BitLen(),
+		}, nil
+	case "EC PRIVATE KEY":
+		key, err := x509.ParseECPrivateKey(block.Bytes)
+		if err != nil {
+			return nil, err
+		}
+		return gin.H{
+			"kind":      "private_key",
+			"algorithm": "EC",
+			"curve":     key.Curve.Params().Name,
+		}, nil
+	case "PRIVATE KEY":
+		key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+		if err != nil {
+			return nil, err
+		}
+		return describePrivateKey(key), nil
+	case "PUBLIC KEY":
+		key, err := x509.ParsePKIXPublicKey(block.Bytes)
+		if err != nil {
+			return nil, err
+		}
+		return describePublicKey(key), nil
+	default:
+		return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
+	}
+}
+
+func describePrivateKey(key any) gin.H {
+	switch typed := key.(type) {
+	case *rsa.PrivateKey:
+		return gin.H{
+			"kind":      "private_key",
+			"algorithm": "RSA",
+			"size":      typed.N.BitLen(),
+		}
+	default:
+		return gin.H{
+			"kind":      "private_key",
+			"algorithm": reflect.TypeOf(key).String(),
+		}
+	}
+}
+
+func describePublicKey(key any) gin.H {
+	switch typed := key.(type) {
+	case *rsa.PublicKey:
+		return gin.H{
+			"kind":      "public_key",
+			"algorithm": "RSA",
+			"size":      typed.N.BitLen(),
+		}
+	default:
+		return gin.H{
+			"kind":      "public_key",
+			"algorithm": reflect.TypeOf(key).String(),
+		}
+	}
+}
+
+func splitCSV(input string) []string {
+	parts := strings.Split(input, ",")
+	values := make([]string, 0, len(parts))
+	for _, part := range parts {
+		trimmed := strings.TrimSpace(part)
+		if trimmed != "" {
+			values = append(values, trimmed)
+		}
+	}
+	return values
+}
+
+func normalizeHost(input string) string {
+	value := strings.TrimSpace(input)
+	value = strings.TrimPrefix(value, "https://")
+	value = strings.TrimPrefix(value, "http://")
+	value = strings.Trim(value, "/")
+	if host, _, err := net.SplitHostPort(value); err == nil {
+		return host
+	}
+	if parsed, err := url.Parse("https://" + value); err == nil {
+		return parsed.Hostname()
+	}
+	return value
+}
+
+func normalizeURL(input string) (*url.URL, error) {
+	value := strings.TrimSpace(input)
+	if value == "" {
+		return nil, errors.New("url is required")
+	}
+	if !strings.Contains(value, "://") {
+		value = "https://" + value
+	}
+	parsed, err := url.Parse(value)
+	if err != nil {
+		return nil, err
+	}
+	if parsed.Hostname() == "" {
+		return nil, errors.New("url must include a hostname")
+	}
+	return parsed, nil
+}
+
+func tlsVersionName(version uint16) string {
+	switch version {
+	case tls.VersionTLS10:
+		return "TLS 1.0"
+	case tls.VersionTLS11:
+		return "TLS 1.1"
+	case tls.VersionTLS12:
+		return "TLS 1.2"
+	case tls.VersionTLS13:
+		return "TLS 1.3"
+	default:
+		return fmt.Sprintf("0x%x", version)
+	}
+}
+
+func normalizeKeySize(value int) int {
+	switch value {
+	case 0, 2048:
+		return 2048
+	case 3072:
+		return 3072
+	case 4096:
+		return 4096
+	default:
+		return 0
+	}
+}
+
+func sanitizeFilename(value string) string {
+	trimmed := strings.TrimSpace(strings.ToLower(value))
+	if trimmed == "" {
+		return "certificate"
+	}
+
+	var builder strings.Builder
+	lastDash := false
+	for _, r := range trimmed {
+		isAlphaNum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
+		if isAlphaNum {
+			builder.WriteRune(r)
+			lastDash = false
+			continue
+		}
+		if !lastDash {
+			builder.WriteRune('-')
+			lastDash = true
+		}
+	}
+
+	result := strings.Trim(builder.String(), "-")
+	if result == "" {
+		return "certificate"
+	}
+	return result
+}
+
+func getenv(key, fallback string) string {
+	value := strings.TrimSpace(os.Getenv(key))
+	if value == "" {
+		return fallback
+	}
+	return value
+}

+ 144 - 0
main_test.go

@@ -0,0 +1,144 @@
+package main
+
+import (
+	"bytes"
+	"crypto/x509/pkix"
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestIndexRoute(t *testing.T) {
+	router := newRouter()
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequest(http.MethodGet, "/", nil)
+
+	router.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusOK {
+		t.Fatalf("expected 200, got %d", recorder.Code)
+	}
+}
+
+func TestTLSGenerateRoute(t *testing.T) {
+	router := newRouter()
+	recorder := httptest.NewRecorder()
+
+	payload, err := json.Marshal(map[string]any{
+		"commonName":         "example.local",
+		"organization":       "Example Corp",
+		"organizationalUnit": "Engineering",
+		"locality":           "Charlotte Amalie",
+		"state":              "St Thomas",
+		"country":            "VI",
+		"dnsNames":           "example.local,www.example.local",
+		"validDays":          30,
+		"keySize":            3072,
+	})
+	if err != nil {
+		t.Fatalf("marshal payload: %v", err)
+	}
+
+	request := httptest.NewRequest(http.MethodPost, "/api/tls/generate", bytes.NewReader(payload))
+	request.Header.Set("Content-Type", "application/json")
+
+	router.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusOK {
+		t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
+	}
+
+	var response map[string]any
+	if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+		t.Fatalf("unmarshal response: %v", err)
+	}
+
+	for _, key := range []string{"certificatePem", "privateKeyPem", "publicKeyPem", "csrPem"} {
+		value, _ := response[key].(string)
+		if !strings.Contains(value, "BEGIN") {
+			t.Fatalf("expected %s to contain PEM data", key)
+		}
+	}
+
+	zipBase64, _ := response["zipBase64"].(string)
+	zipFilename, _ := response["zipFilename"].(string)
+	if zipFilename == "" {
+		t.Fatal("expected zipFilename to be present")
+	}
+	zipBytes, err := base64.StdEncoding.DecodeString(zipBase64)
+	if err != nil {
+		t.Fatalf("decode zipBase64: %v", err)
+	}
+	if len(zipBytes) == 0 {
+		t.Fatal("expected zip archive bytes")
+	}
+
+	if keySize, ok := response["keySize"].(float64); !ok || keySize != 3072 {
+		t.Fatalf("expected keySize 3072, got %#v", response["keySize"])
+	}
+}
+
+func TestSSLCheckRejectsEmptyURL(t *testing.T) {
+	router := newRouter()
+	recorder := httptest.NewRecorder()
+
+	payload, err := json.Marshal(map[string]any{
+		"url": "",
+	})
+	if err != nil {
+		t.Fatalf("marshal payload: %v", err)
+	}
+
+	request := httptest.NewRequest(http.MethodPost, "/api/ssl/check", bytes.NewReader(payload))
+	request.Header.Set("Content-Type", "application/json")
+
+	router.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusBadRequest {
+		t.Fatalf("expected 400, got %d: %s", recorder.Code, recorder.Body.String())
+	}
+}
+
+func TestPEMCheckRoute(t *testing.T) {
+	router := newRouter()
+	recorder := httptest.NewRecorder()
+
+	subject := pkix.Name{
+		CommonName:         "example.local",
+		Organization:       []string{"Example Corp"},
+		OrganizationalUnit: []string{"Engineering"},
+	}
+
+	certPEM, keyPEM, _, csrPEM, _, err := generateCertificate(subject, []string{"example.local"}, 30, 2048)
+	if err != nil {
+		t.Fatalf("generate certificate: %v", err)
+	}
+
+	payload, err := json.Marshal(map[string]any{
+		"pem": string(certPEM) + "\n" + string(csrPEM) + "\n" + string(keyPEM),
+	})
+	if err != nil {
+		t.Fatalf("marshal payload: %v", err)
+	}
+
+	request := httptest.NewRequest(http.MethodPost, "/api/pem/check", bytes.NewReader(payload))
+	request.Header.Set("Content-Type", "application/json")
+
+	router.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusOK {
+		t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
+	}
+
+	var response map[string]any
+	if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+		t.Fatalf("unmarshal response: %v", err)
+	}
+
+	if count, ok := response["count"].(float64); !ok || count != 3 {
+		t.Fatalf("expected 3 blocks, got %#v", response["count"])
+	}
+}

+ 11 - 0
publish.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+IMAGE_NAME="bmallenxs/tools:latest"
+
+docker build -t "${IMAGE_NAME}" .
+
+printf 'Built %s\n' "${IMAGE_NAME}"
+
+docker push ${IMAGE_NAME}

+ 311 - 0
static/app.js

@@ -0,0 +1,311 @@
+const toolButtons = document.querySelectorAll(".tool-button");
+const panels = document.querySelectorAll(".panel");
+
+document.addEventListener("click", async (event) => {
+  const button = event.target.closest("[data-copy-value]");
+  if (!button) {
+    return;
+  }
+
+  const value = button.dataset.copyValue || "";
+  const originalLabel = button.textContent;
+
+  try {
+    await copyText(value);
+    button.textContent = "Copied";
+    button.classList.add("copied");
+    setTimeout(() => {
+      button.textContent = originalLabel;
+      button.classList.remove("copied");
+    }, 1500);
+  } catch (error) {
+    button.textContent = "Copy failed";
+    setTimeout(() => {
+      button.textContent = originalLabel;
+    }, 1500);
+  }
+});
+
+toolButtons.forEach((button) => {
+  button.addEventListener("click", () => {
+    const target = button.dataset.tool;
+
+    toolButtons.forEach((item) => item.classList.remove("active"));
+    panels.forEach((panel) => panel.classList.remove("active"));
+
+    button.classList.add("active");
+    document.getElementById(target).classList.add("active");
+  });
+});
+
+document.getElementById("tls-form").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const form = new FormData(event.currentTarget);
+  const action = event.submitter?.value || "generate";
+
+  await submitJSON("/api/tls/generate", {
+    commonName: form.get("commonName"),
+    organization: form.get("organization"),
+    organizationalUnit: form.get("organizationalUnit"),
+    locality: form.get("locality"),
+    state: form.get("state"),
+    country: form.get("country"),
+    dnsNames: form.get("dnsNames"),
+    validDays: Number(form.get("validDays")),
+    keySize: Number(form.get("keySize")),
+  }, renderTLSResult, document.getElementById("tls-result"), (data) => {
+    if (action === "download") {
+      downloadBase64File(data.zipBase64, data.zipFilename || "tls-materials.zip", "application/zip");
+    }
+  });
+});
+
+document.getElementById("dns-form").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const form = new FormData(event.currentTarget);
+
+  await submitJSON("/api/dns/lookup", {
+    host: form.get("host"),
+  }, renderDNSResult, document.getElementById("dns-result"));
+});
+
+document.getElementById("ssl-form").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const form = new FormData(event.currentTarget);
+
+  await submitJSON("/api/ssl/check", {
+    url: form.get("url"),
+  }, renderSSLResult, document.getElementById("ssl-result"));
+});
+
+document.getElementById("pem-form").addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const form = new FormData(event.currentTarget);
+
+  await submitJSON("/api/pem/check", {
+    pem: form.get("pem"),
+  }, renderPEMResult, document.getElementById("pem-result"));
+});
+
+async function submitJSON(url, payload, renderer, target, onSuccess) {
+  setLoading(target);
+
+  try {
+    const response = await fetch(url, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify(payload),
+    });
+
+    const data = await response.json();
+    if (!response.ok) {
+      throw new Error(data.error || "Request failed");
+    }
+
+    target.classList.remove("empty", "error");
+    target.innerHTML = renderer(data);
+    if (onSuccess) {
+      onSuccess(data);
+    }
+  } catch (error) {
+    target.classList.remove("empty");
+    target.classList.add("error");
+    target.textContent = error.message;
+  }
+}
+
+function setLoading(target) {
+  target.classList.remove("error");
+  target.classList.add("empty");
+  target.textContent = "Working...";
+}
+
+function renderTLSResult(data) {
+  return `
+    ${renderMetaGrid([
+      ["Subject", escapeHTML(data.subject)],
+      ["Issuer", escapeHTML(data.issuer)],
+      ["Serial Number", escapeHTML(data.serialNumber)],
+      ["Valid From", escapeHTML(data.notBefore)],
+      ["Valid Until", escapeHTML(data.notAfter)],
+      ["Certificate Type", escapeHTML(data.isSelfSigned ? "Self-Signed" : "Signed")],
+      ["Key Size", escapeHTML(`${data.keySize || ""}-bit RSA`)],
+      ["Organization", escapeHTML((data.organization || []).join(", "))],
+      ["OU", escapeHTML((data.ou || []).join(", "))],
+      ["Locality", escapeHTML((data.locality || []).join(", "))],
+      ["State / Province", escapeHTML((data.state || []).join(", "))],
+      ["Country", escapeHTML((data.country || []).join(", "))],
+      ["DNS Names", escapeHTML((data.dnsNames || []).join(", "))],
+    ])}
+    ${renderCopyablePEMBlock("Public Key PEM", data.publicKeyPem)}
+    ${renderCopyablePEMBlock("Certificate Signing Request", data.csrPem)}
+    ${renderCopyablePEMBlock("Self-Signed Certificate PEM", data.selfSignedCertificatePem || data.certificatePem)}
+    ${renderCopyablePEMBlock("Private Key PEM", data.privateKeyPem)}
+  `;
+}
+
+function renderDNSResult(data) {
+  return `
+    ${renderMetaGrid([
+      ["Host", escapeHTML(data.host)],
+      ["CNAME", escapeHTML(data.cname || "None")],
+    ])}
+    ${renderListBlock("A / AAAA", data.ips)}
+    ${renderListBlock("TXT", data.txt)}
+    ${renderListBlock("NS", data.ns)}
+    ${renderListBlock("MX", (data.mx || []).map((record) => `${record.host} (pref ${record.pref})`))}
+    ${renderErrorBlock(data.errors)}
+  `;
+}
+
+function renderSSLResult(data) {
+  const leaf = (data.chain || [])[0] || {};
+
+  return `
+    ${renderMetaGrid([
+      ["Requested URL", escapeHTML(data.url)],
+      ["Server Name", escapeHTML(data.serverName)],
+      ["TLS Version", escapeHTML(data.version)],
+      ["Cipher Suite", escapeHTML(data.cipherSuite)],
+      ["ALPN", escapeHTML(data.negotiatedProtocol || "None")],
+      ["Leaf Subject", escapeHTML(leaf.subject || "")],
+      ["Leaf Issuer", escapeHTML(leaf.issuer || "")],
+      ["Leaf Valid Until", escapeHTML(leaf.notAfter || "")],
+    ])}
+    <div class="record-block">
+      <h3>Certificate Chain</h3>
+      <pre>${escapeHTML(JSON.stringify(data.chain, null, 2))}</pre>
+    </div>
+    <div class="record-block">
+      <h3>Leaf Certificate PEM</h3>
+      <pre>${escapeHTML(data.certificatePem)}</pre>
+    </div>
+  `;
+}
+
+function renderPEMResult(data) {
+  return `
+    ${renderMetaGrid([
+      ["PEM Blocks", escapeHTML(String(data.count || 0))],
+    ])}
+    ${(data.blocks || []).map((block, index) => `
+      <div class="record-block">
+        <h3>Block ${index + 1}: ${escapeHTML(block.kind || "unknown")} (${escapeHTML(block.pemType || "")})</h3>
+        ${renderMetaGrid([
+          ["Subject", escapeHTML(block.subject || "None")],
+          ["Issuer", escapeHTML(block.issuer || "None")],
+          ["Serial Number", escapeHTML(block.serialNumber || "None")],
+          ["Valid From", escapeHTML(block.notBefore || "None")],
+          ["Valid Until", escapeHTML(block.notAfter || "None")],
+          ["Algorithm", escapeHTML(block.algorithm || block.publicKeyAlgorithm || "None")],
+          ["Signature Algorithm", escapeHTML(block.signatureAlgorithm || "None")],
+          ["Size", escapeHTML(block.size ? `${block.size}-bit` : "None")],
+          ["Curve", escapeHTML(block.curve || "None")],
+          ["DNS Names", escapeHTML((block.dnsNames || []).join(", ") || "None")],
+          ["Emails", escapeHTML((block.emailAddresses || []).join(", ") || "None")],
+          ["CA", escapeHTML(block.isCA === true ? "Yes" : block.isCA === false ? "No" : "None")],
+        ])}
+      </div>
+    `).join("")}
+  `;
+}
+
+function renderCopyablePEMBlock(title, value) {
+  return `
+    <div class="record-block">
+      <div class="record-heading">
+        <h3>${escapeHTML(title)}</h3>
+        <button type="button" class="copy-button" data-copy-value="${escapeHTML(value || "")}">Copy</button>
+      </div>
+      <pre>${escapeHTML(value || "")}</pre>
+    </div>
+  `;
+}
+
+function renderMetaGrid(items) {
+  return `
+    <div class="meta-grid">
+      ${items.map(([label, value]) => `
+        <div class="meta-item">
+          <strong>${label}</strong>
+          <span>${value || "None"}</span>
+        </div>
+      `).join("")}
+    </div>
+  `;
+}
+
+function renderListBlock(title, values = []) {
+  return `
+    <div class="record-block">
+      <h3>${title}</h3>
+      ${values.length ? `<ul>${values.map((value) => `<li>${escapeHTML(value)}</li>`).join("")}</ul>` : "<p>No records returned.</p>"}
+    </div>
+  `;
+}
+
+function renderErrorBlock(errors = {}) {
+  const entries = Object.entries(errors);
+  if (!entries.length) {
+    return "";
+  }
+
+  return `
+    <div class="record-block">
+      <h3>Lookup Notes</h3>
+      <ul>${entries.map(([kind, message]) => `<li>${escapeHTML(kind)}: ${escapeHTML(message)}</li>`).join("")}</ul>
+    </div>
+  `;
+}
+
+async function copyText(value) {
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(value);
+    return;
+  }
+
+  const textarea = document.createElement("textarea");
+  textarea.value = value;
+  textarea.setAttribute("readonly", "");
+  textarea.style.position = "absolute";
+  textarea.style.left = "-9999px";
+  document.body.appendChild(textarea);
+  textarea.select();
+
+  const success = document.execCommand("copy");
+  document.body.removeChild(textarea);
+
+  if (!success) {
+    throw new Error("copy failed");
+  }
+}
+
+function downloadBase64File(base64Value, filename, mimeType) {
+  const binary = atob(base64Value || "");
+  const bytes = new Uint8Array(binary.length);
+
+  for (let index = 0; index < binary.length; index += 1) {
+    bytes[index] = binary.charCodeAt(index);
+  }
+
+  const blob = new Blob([bytes], { type: mimeType });
+  const url = URL.createObjectURL(blob);
+  const link = document.createElement("a");
+  link.href = url;
+  link.download = filename;
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
+  URL.revokeObjectURL(url);
+}
+
+function escapeHTML(value) {
+  return String(value ?? "")
+    .replaceAll("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;")
+    .replaceAll('"', "&quot;")
+    .replaceAll("'", "&#39;");
+}

+ 295 - 0
static/styles.css

@@ -0,0 +1,295 @@
+:root {
+  color-scheme: dark;
+  --bg: #07111a;
+  --panel: rgba(12, 24, 36, 0.9);
+  --panel-strong: rgba(16, 32, 48, 0.98);
+  --border: rgba(120, 150, 176, 0.2);
+  --text: #e5f1ff;
+  --muted: #93abc3;
+  --accent: #5eead4;
+  --accent-strong: #14b8a6;
+  --danger: #fda4af;
+  --shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
+  font-family: "Segoe UI", sans-serif;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  background:
+    radial-gradient(circle at top left, rgba(20, 184, 166, 0.18), transparent 32%),
+    radial-gradient(circle at bottom right, rgba(96, 165, 250, 0.14), transparent 28%),
+    linear-gradient(160deg, #030712, #07111a 45%, #0b1726 100%);
+  color: var(--text);
+}
+
+button,
+input,
+select,
+textarea {
+  font: inherit;
+}
+
+.page-shell {
+  display: grid;
+  grid-template-columns: 320px 1fr;
+  min-height: 100vh;
+}
+
+.sidebar {
+  padding: 40px 28px;
+  border-right: 1px solid var(--border);
+  background: rgba(3, 10, 18, 0.72);
+  backdrop-filter: blur(18px);
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  gap: 32px;
+}
+
+.content {
+  padding: 40px;
+  display: grid;
+}
+
+.eyebrow {
+  margin: 0 0 12px;
+  text-transform: uppercase;
+  letter-spacing: 0.14em;
+  font-size: 0.72rem;
+  color: var(--accent);
+}
+
+h1,
+h2 {
+  margin: 0;
+  font-weight: 700;
+}
+
+h1 {
+  font-size: clamp(2rem, 4vw, 3rem);
+}
+
+h2 {
+  font-size: clamp(1.5rem, 3vw, 2rem);
+}
+
+.intro,
+.panel-copy {
+  color: var(--muted);
+  line-height: 1.6;
+}
+
+.tool-menu {
+  display: grid;
+  gap: 12px;
+}
+
+.tool-button {
+  border: 1px solid transparent;
+  border-radius: 18px;
+  background: rgba(17, 34, 51, 0.6);
+  color: var(--text);
+  text-align: left;
+  padding: 16px 18px;
+  cursor: pointer;
+  transition: 180ms ease;
+}
+
+.tool-button:hover,
+.tool-button.active {
+  border-color: rgba(94, 234, 212, 0.5);
+  background: rgba(18, 44, 62, 0.96);
+  transform: translateY(-1px);
+}
+
+.panel {
+  display: none;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 28px;
+  padding: 28px;
+  box-shadow: var(--shadow);
+}
+
+.panel.active {
+  display: block;
+}
+
+.panel-header {
+  display: flex;
+  align-items: end;
+  justify-content: space-between;
+  gap: 24px;
+  margin-bottom: 24px;
+}
+
+.tool-form {
+  display: grid;
+  gap: 16px;
+  margin-bottom: 24px;
+}
+
+.tool-form label {
+  display: grid;
+  gap: 8px;
+  color: var(--muted);
+}
+
+.tool-form input,
+.tool-form select,
+.tool-form textarea {
+  width: 100%;
+  border-radius: 14px;
+  border: 1px solid var(--border);
+  background: rgba(7, 17, 26, 0.95);
+  color: var(--text);
+  padding: 14px 16px;
+}
+
+.tool-form input:focus,
+.tool-form select:focus,
+.tool-form textarea:focus {
+  outline: 2px solid rgba(94, 234, 212, 0.45);
+  border-color: rgba(94, 234, 212, 0.6);
+}
+
+.tool-form textarea {
+  resize: vertical;
+  min-height: 240px;
+}
+
+.tool-form button {
+  width: fit-content;
+  border: 0;
+  border-radius: 999px;
+  padding: 14px 20px;
+  background: linear-gradient(135deg, var(--accent), var(--accent-strong));
+  color: #06211e;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.form-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.secondary-button {
+  background: rgba(17, 34, 51, 0.9);
+  color: var(--text);
+  border: 1px solid rgba(94, 234, 212, 0.28);
+}
+
+.result-card {
+  background: var(--panel-strong);
+  border: 1px solid var(--border);
+  border-radius: 20px;
+  padding: 20px;
+  min-height: 180px;
+  overflow: auto;
+}
+
+.result-card.empty {
+  color: var(--muted);
+}
+
+.result-card.error {
+  border-color: rgba(253, 164, 175, 0.45);
+  color: #ffe4e6;
+}
+
+.meta-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 18px;
+}
+
+.meta-item {
+  background: rgba(7, 17, 26, 0.86);
+  border: 1px solid var(--border);
+  border-radius: 16px;
+  padding: 12px 14px;
+}
+
+.meta-item strong {
+  display: block;
+  margin-bottom: 6px;
+  color: var(--muted);
+  font-size: 0.86rem;
+  font-weight: 600;
+}
+
+pre {
+  margin: 16px 0 0;
+  padding: 16px;
+  border-radius: 16px;
+  background: #02060d;
+  border: 1px solid rgba(148, 163, 184, 0.15);
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.record-block {
+  margin-top: 16px;
+}
+
+.record-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.record-block h3 {
+  margin: 0 0 8px;
+  font-size: 0.96rem;
+}
+
+.copy-button {
+  border: 1px solid rgba(94, 234, 212, 0.28);
+  border-radius: 999px;
+  background: rgba(10, 28, 42, 0.9);
+  color: var(--text);
+  padding: 8px 14px;
+  cursor: pointer;
+  transition: 180ms ease;
+}
+
+.copy-button:hover,
+.copy-button.copied {
+  border-color: rgba(94, 234, 212, 0.55);
+  background: rgba(18, 44, 62, 0.96);
+}
+
+.record-block ul {
+  margin: 0;
+  padding-left: 18px;
+  color: var(--muted);
+}
+
+@media (max-width: 960px) {
+  .page-shell {
+    grid-template-columns: 1fr;
+  }
+
+  .sidebar {
+    border-right: 0;
+    border-bottom: 1px solid var(--border);
+  }
+
+  .content {
+    padding: 20px;
+  }
+
+  .panel-header {
+    align-items: start;
+    flex-direction: column;
+  }
+}

+ 143 - 0
templates/index.html

@@ -0,0 +1,143 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>{{ .title }}</title>
+    <link rel="stylesheet" href="/static/styles.css" />
+  </head>
+  <body>
+    <div class="page-shell">
+      <aside class="sidebar">
+        <div>
+          <p class="eyebrow">Toolkit</p>
+          <h1>Tools</h1>
+          <p class="intro">
+            <!-- Generate local TLS material, inspect DNS records, and fetch live server certificates. -->
+          </p>
+        </div>
+        <nav class="tool-menu" aria-label="Tool menu">
+          <button class="tool-button active" data-tool="tls-generator">TLS Generator</button>
+          <button class="tool-button" data-tool="dns-lookup">DNS Lookup</button>
+          <button class="tool-button" data-tool="ssl-check">SSL Check</button>
+          <button class="tool-button" data-tool="pem-check">PEM Check</button>
+        </nav>
+      </aside>
+
+      <main class="content">
+        <section class="panel active" id="tls-generator">
+          <div class="panel-header">
+            <div>
+              <p class="eyebrow">Certificate Tool</p>
+              <h2>TLS Generator</h2>
+            </div>
+            <p class="panel-copy">Create a self-signed certificate and private key for local testing.</p>
+          </div>
+          <form id="tls-form" class="tool-form">
+            <label>
+              Common Name
+              <input type="text" name="commonName" placeholder="example.local" required />
+            </label>
+            <label>
+              Organization
+              <input type="text" name="organization" placeholder="Example Corp" />
+            </label>
+            <label>
+              Organizational Unit
+              <input type="text" name="organizationalUnit" placeholder="Engineering" />
+            </label>
+            <label>
+              Locality
+              <input type="text" name="locality" placeholder="DC" />
+            </label>
+            <label>
+              State / Province
+              <input type="text" name="state" placeholder="DC" />
+            </label>
+            <label>
+              Country
+              <input type="text" name="country" placeholder="US" />
+            </label>
+            <label>
+              DNS Names
+              <input type="text" name="dnsNames" placeholder="example.local, www.example.local" />
+            </label>
+            <label>
+              Valid Days
+              <input type="number" name="validDays" min="1" value="365" />
+            </label>
+            <label>
+              Key Size
+              <select name="keySize">
+                <option value="2048" selected>2048-bit RSA</option>
+                <option value="3072">3072-bit RSA</option>
+                <option value="4096">4096-bit RSA</option>
+              </select>
+            </label>
+            <div class="form-actions">
+              <button type="submit" name="action" value="generate">Generate Certificate</button>
+              <button type="submit" name="action" value="download" class="secondary-button">Generate and Download</button>
+            </div>
+          </form>
+          <div id="tls-result" class="result-card empty">Run the generator to view the PEM output and metadata.</div>
+        </section>
+
+        <section class="panel" id="dns-lookup">
+          <div class="panel-header">
+            <div>
+              <p class="eyebrow">Resolver Tool</p>
+              <h2>DNS Lookup</h2>
+            </div>
+            <p class="panel-copy">Query common DNS record types for a hostname.</p>
+          </div>
+          <form id="dns-form" class="tool-form">
+            <label>
+              Hostname
+              <input type="text" name="host" placeholder="openai.com" required />
+            </label>
+            <button type="submit">Lookup Records</button>
+          </form>
+          <div id="dns-result" class="result-card empty">Lookups will show A/AAAA, CNAME, MX, NS, and TXT records here.</div>
+        </section>
+
+        <section class="panel" id="ssl-check">
+          <div class="panel-header">
+            <div>
+              <p class="eyebrow">Inspection Tool</p>
+              <h2>SSL Check</h2>
+            </div>
+            <p class="panel-copy">Connect to a TLS endpoint and display the negotiated certificate.</p>
+          </div>
+          <form id="ssl-form" class="tool-form">
+            <label>
+              URL
+              <input type="text" name="url" placeholder="https://example.com" required />
+            </label>
+            <button type="submit">Retrieve Certificate</button>
+          </form>
+          <div id="ssl-result" class="result-card empty">Certificate details and PEM output will appear after a check.</div>
+        </section>
+
+        <section class="panel" id="pem-check">
+          <div class="panel-header">
+            <div>
+              <p class="eyebrow">Parser Tool</p>
+              <h2>PEM Check</h2>
+            </div>
+            <p class="panel-copy">Paste a PEM-encoded certificate, CSR, private key, or public key to inspect it.</p>
+          </div>
+          <form id="pem-form" class="tool-form">
+            <label>
+              PEM Content
+              <textarea name="pem" rows="16" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" required></textarea>
+            </label>
+            <button type="submit">Inspect PEM</button>
+          </form>
+          <div id="pem-result" class="result-card empty">Parsed PEM blocks and metadata will appear here.</div>
+        </section>
+      </main>
+    </div>
+
+    <script src="/static/app.js" defer></script>
+  </body>
+</html>