Files
pawtoolbox/tools.go
2026-08-26 07:30:25 +02:00

520 lines
16 KiB
Go

package main
import (
"archive/zip"
"bytes"
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"mime/multipart"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
const toolUploadLimit int64 = 64 << 20 // 64 MiB for inspection tools
func toolReadUpload(w http.ResponseWriter, r *http.Request, field string) ([]byte, *multipart.FileHeader, error) {
r.Body = http.MaxBytesReader(w, r.Body, toolUploadLimit+2<<20)
if err := r.ParseMultipartForm(toolUploadLimit); err != nil {
return nil, nil, fmt.Errorf("Datei zu groß oder ungültiger Upload")
}
f, h, err := r.FormFile(field)
if err != nil {
return nil, nil, errors.New("Datei fehlt")
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, toolUploadLimit+1))
if err != nil {
return nil, nil, err
}
if int64(len(b)) > toolUploadLimit {
return nil, nil, fmt.Errorf("maximal %d MiB für Analysewerkzeuge", toolUploadLimit>>20)
}
return b, h, nil
}
// -------------------- file inspector --------------------
type fileInspectResult struct {
Name string `json:"name"`
Size int `json:"size"`
MIME string `json:"mime"`
Extension string `json:"extension"`
MagicHex string `json:"magic_hex"`
MD5 string `json:"md5"`
SHA256 string `json:"sha256"`
SHA512 string `json:"sha512"`
}
func (a *Application) handleToolFileInspect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
b, h, err := toolReadUpload(w, r, "file")
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
m5 := md5.Sum(b)
s256 := sha256.Sum256(b)
s512 := sha512.Sum512(b)
magic := b
if len(magic) > 32 {
magic = magic[:32]
}
mimeType := "application/octet-stream"
if len(b) > 0 {
mimeType = http.DetectContentType(b[:minInt(len(b), 512)])
}
writeJSON(w, 200, fileInspectResult{
Name: filepath.Base(h.Filename), Size: len(b), MIME: mimeType,
Extension: strings.ToLower(filepath.Ext(h.Filename)), MagicHex: strings.ToUpper(hex.EncodeToString(magic)),
MD5: hex.EncodeToString(m5[:]), SHA256: hex.EncodeToString(s256[:]), SHA512: hex.EncodeToString(s512[:]),
})
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// -------------------- certificate inspector --------------------
type certInfo struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
Serial string `json:"serial"`
DNSNames []string `json:"dns_names"`
IPAddresses []string `json:"ip_addresses"`
Emails []string `json:"emails"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
Expired bool `json:"expired"`
DaysLeft int `json:"days_left"`
IsCA bool `json:"is_ca"`
SignatureAlg string `json:"signature_algorithm"`
PublicKeyAlg string `json:"public_key_algorithm"`
KeyUsage []string `json:"key_usage"`
ExtKeyUsage []string `json:"ext_key_usage"`
SHA1 string `json:"sha1_thumbprint"`
SHA256 string `json:"sha256_thumbprint"`
}
func certToInfo(c *x509.Certificate) certInfo {
now := time.Now()
ips := make([]string, 0, len(c.IPAddresses))
for _, ip := range c.IPAddresses {
ips = append(ips, ip.String())
}
ku := []string{}
usage := []struct {
bit x509.KeyUsage
name string
}{
{x509.KeyUsageDigitalSignature, "Digital Signature"}, {x509.KeyUsageContentCommitment, "Content Commitment"}, {x509.KeyUsageKeyEncipherment, "Key Encipherment"}, {x509.KeyUsageDataEncipherment, "Data Encipherment"}, {x509.KeyUsageKeyAgreement, "Key Agreement"}, {x509.KeyUsageCertSign, "Certificate Sign"}, {x509.KeyUsageCRLSign, "CRL Sign"}, {x509.KeyUsageEncipherOnly, "Encipher Only"}, {x509.KeyUsageDecipherOnly, "Decipher Only"},
}
for _, x := range usage {
if c.KeyUsage&x.bit != 0 {
ku = append(ku, x.name)
}
}
eku := []string{}
ekuNames := map[x509.ExtKeyUsage]string{
x509.ExtKeyUsageAny: "Any", x509.ExtKeyUsageServerAuth: "Server Authentication", x509.ExtKeyUsageClientAuth: "Client Authentication",
x509.ExtKeyUsageCodeSigning: "Code Signing", x509.ExtKeyUsageEmailProtection: "E-mail Protection", x509.ExtKeyUsageTimeStamping: "Time Stamping",
x509.ExtKeyUsageOCSPSigning: "OCSP Signing", x509.ExtKeyUsageMicrosoftServerGatedCrypto: "Microsoft SGC", x509.ExtKeyUsageNetscapeServerGatedCrypto: "Netscape SGC",
}
for _, x := range c.ExtKeyUsage {
if n, ok := ekuNames[x]; ok {
eku = append(eku, n)
} else {
eku = append(eku, fmt.Sprintf("EKU %d", x))
}
}
h1 := sha1Sum(c.Raw)
h256 := sha256.Sum256(c.Raw)
days := int(time.Until(c.NotAfter).Hours() / 24)
return certInfo{Subject: c.Subject.String(), Issuer: c.Issuer.String(), Serial: c.SerialNumber.Text(16), DNSNames: c.DNSNames, IPAddresses: ips, Emails: c.EmailAddresses, NotBefore: c.NotBefore, NotAfter: c.NotAfter, Expired: now.After(c.NotAfter), DaysLeft: days, IsCA: c.IsCA, SignatureAlg: c.SignatureAlgorithm.String(), PublicKeyAlg: c.PublicKeyAlgorithm.String(), KeyUsage: ku, ExtKeyUsage: eku, SHA1: strings.ToUpper(hex.EncodeToString(h1)), SHA256: strings.ToUpper(hex.EncodeToString(h256[:]))}
}
func sha1Sum(b []byte) []byte {
// SHA-1 is exposed only as a legacy certificate thumbprint identifier.
s := sha1.Sum(b)
return s[:]
}
func parseCertificates(data []byte) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
rest := data
for {
block, r := pem.Decode(rest)
if block == nil {
break
}
rest = r
if block.Type == "CERTIFICATE" {
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
certs = append(certs, c)
}
}
if len(certs) > 0 {
return certs, nil
}
c, err := x509.ParseCertificate(data)
if err == nil {
return []*x509.Certificate{c}, nil
}
many, err2 := x509.ParseCertificates(data)
if err2 == nil && len(many) > 0 {
return many, nil
}
return nil, err
}
func extractPFXWithOpenSSL(data []byte, password string) ([]byte, error) {
if _, err := exec.LookPath("openssl"); err != nil {
return nil, errors.New("PFX/P12 benötigt OpenSSL auf dem Server")
}
dir, err := os.MkdirTemp("", "paw-cert-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
in := filepath.Join(dir, "input.pfx")
if err := os.WriteFile(in, data, 0o600); err != nil {
return nil, err
}
cmd := exec.Command("openssl", "pkcs12", "-in", in, "-nokeys", "-nodes", "-passin", "stdin")
cmd.Stdin = strings.NewReader(password + "\n")
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("PFX konnte nicht geöffnet werden: %s", strings.TrimSpace(string(out)))
}
return out, nil
}
func (a *Application) handleToolCertInspect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
b, h, err := toolReadUpload(w, r, "file")
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
ext := strings.ToLower(filepath.Ext(h.Filename))
if ext == ".pfx" || ext == ".p12" {
b, err = extractPFXWithOpenSSL(b, r.FormValue("password"))
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
}
certs, err := parseCertificates(b)
if err != nil {
writeJSON(w, 400, apiError{Error: "Kein unterstütztes X.509-Zertifikat erkannt"})
return
}
out := make([]certInfo, 0, len(certs))
for _, c := range certs {
out = append(out, certToInfo(c))
}
writeJSON(w, 200, map[string]any{"file": filepath.Base(h.Filename), "certificates": out})
}
// -------------------- ZIP archive viewer --------------------
type archiveEntry struct {
Name string `json:"name"`
Size uint64 `json:"size"`
CompressedSize uint64 `json:"compressed_size"`
Method uint16 `json:"method"`
Modified time.Time `json:"modified"`
Directory bool `json:"directory"`
}
func (a *Application) handleToolArchive(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
b, h, err := toolReadUpload(w, r, "file")
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
writeJSON(w, 400, apiError{Error: "Keine gültige ZIP-Datei"})
return
}
entries := make([]archiveEntry, 0, len(zr.File))
var unpacked uint64
for _, f := range zr.File {
unpacked += f.UncompressedSize64
entries = append(entries, archiveEntry{Name: f.Name, Size: f.UncompressedSize64, CompressedSize: f.CompressedSize64, Method: f.Method, Modified: f.Modified, Directory: f.FileInfo().IsDir()})
}
sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) })
writeJSON(w, 200, map[string]any{"file": filepath.Base(h.Filename), "entries": entries, "entry_count": len(entries), "compressed_bytes": len(b), "uncompressed_bytes": unpacked})
}
// -------------------- DNS --------------------
type dnsReq struct {
Name string `json:"name"`
Type string `json:"type"`
}
type dnsResp struct {
Name string `json:"name"`
Type string `json:"type"`
Results []string `json:"results"`
DurationMS int64 `json:"duration_ms"`
}
func (a *Application) handleToolDNS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
var q dnsReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&q); err != nil {
writeJSON(w, 400, apiError{Error: "invalid JSON"})
return
}
q.Name = strings.TrimSpace(q.Name)
q.Type = strings.ToUpper(strings.TrimSpace(q.Type))
if q.Name == "" {
writeJSON(w, 400, apiError{Error: "Name fehlt"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
res := net.DefaultResolver
start := time.Now()
var out []string
var err error
switch q.Type {
case "A", "AAAA":
var ips []net.IPAddr
ips, err = res.LookupIPAddr(ctx, q.Name)
if err == nil {
for _, x := range ips {
if (q.Type == "A" && x.IP.To4() != nil) || (q.Type == "AAAA" && x.IP.To4() == nil) {
out = append(out, x.IP.String())
}
}
}
case "CNAME":
var x string
x, err = res.LookupCNAME(ctx, q.Name)
if err == nil {
out = []string{x}
}
case "MX":
var xs []*net.MX
xs, err = res.LookupMX(ctx, q.Name)
if err == nil {
for _, x := range xs {
out = append(out, fmt.Sprintf("%d %s", x.Pref, x.Host))
}
}
case "TXT":
out, err = res.LookupTXT(ctx, q.Name)
case "PTR":
out, err = res.LookupAddr(ctx, q.Name)
case "SRV":
service, proto, name := parseSRVName(q.Name)
var xs []*net.SRV
_, xs, err = res.LookupSRV(ctx, service, proto, name)
if err == nil {
for _, x := range xs {
out = append(out, fmt.Sprintf("priority=%d weight=%d port=%d target=%s", x.Priority, x.Weight, x.Port, x.Target))
}
}
default:
writeJSON(w, 400, apiError{Error: "Typ muss A, AAAA, CNAME, MX, TXT, SRV oder PTR sein"})
return
}
if err != nil {
writeJSON(w, 502, apiError{Error: err.Error()})
return
}
sort.Strings(out)
writeJSON(w, 200, dnsResp{Name: q.Name, Type: q.Type, Results: out, DurationMS: time.Since(start).Milliseconds()})
}
func parseSRVName(s string) (string, string, string) {
parts := strings.Split(strings.TrimSuffix(s, "."), ".")
if len(parts) >= 3 && strings.HasPrefix(parts[0], "_") && strings.HasPrefix(parts[1], "_") {
return strings.TrimPrefix(parts[0], "_"), strings.TrimPrefix(parts[1], "_"), strings.Join(parts[2:], ".")
}
return "", "", s
}
// -------------------- connectivity --------------------
type connReq struct {
Host string `json:"host"`
Port int `json:"port"`
TimeoutMS int `json:"timeout_ms"`
}
func (a *Application) handleToolConnectivity(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
var q connReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&q); err != nil {
writeJSON(w, 400, apiError{Error: "invalid JSON"})
return
}
q.Host = strings.TrimSpace(q.Host)
if q.Host == "" || q.Port < 1 || q.Port > 65535 {
writeJSON(w, 400, apiError{Error: "Host oder Port ungültig"})
return
}
if q.TimeoutMS < 100 || q.TimeoutMS > 10000 {
q.TimeoutMS = 3000
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(q.TimeoutMS)*time.Millisecond)
defer cancel()
ips, _ := net.DefaultResolver.LookupHost(ctx, q.Host)
start := time.Now()
d := net.Dialer{Timeout: time.Duration(q.TimeoutMS) * time.Millisecond}
c, err := d.DialContext(ctx, "tcp", net.JoinHostPort(q.Host, strconv.Itoa(q.Port)))
ms := time.Since(start).Milliseconds()
remote := ""
if err == nil {
remote = c.RemoteAddr().String()
_ = c.Close()
}
resp := map[string]any{"host": q.Host, "port": q.Port, "resolved_ips": ips, "latency_ms": ms, "reachable": err == nil, "remote": remote}
if err != nil {
resp["error"] = err.Error()
}
writeJSON(w, 200, resp)
}
// -------------------- subnet calculator --------------------
type subnetReq struct {
CIDR string `json:"cidr"`
}
func (a *Application) handleToolSubnet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
var q subnetReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&q); err != nil {
writeJSON(w, 400, apiError{Error: "invalid JSON"})
return
}
p, err := netip.ParsePrefix(strings.TrimSpace(q.CIDR))
if err != nil {
writeJSON(w, 400, apiError{Error: "Ungültiges CIDR"})
return
}
p = p.Masked()
a0 := p.Addr()
bits := 128
if a0.Is4() {
bits = 32
}
hostBits := bits - p.Bits()
last := prefixLast(p)
total := new(big.Int).Lsh(big.NewInt(1), uint(hostBits))
resp := map[string]any{"input": q.CIDR, "network": p.String(), "prefix_length": p.Bits(), "address_bits": bits, "first_address": a0.String(), "last_address": last.String(), "address_count": total.String(), "family": "IPv6"}
if a0.Is4() {
resp["family"] = "IPv4"
resp["netmask"] = prefixMask4(p.Bits())
resp["broadcast"] = last.String()
if p.Bits() <= 30 {
resp["first_host"] = nextAddr(a0).String()
resp["last_host"] = prevAddr(last).String()
resp["usable_hosts"] = new(big.Int).Sub(total, big.NewInt(2)).String()
} else {
resp["first_host"] = a0.String()
resp["last_host"] = last.String()
resp["usable_hosts"] = total.String()
}
}
writeJSON(w, 200, resp)
}
func prefixLast(p netip.Prefix) netip.Addr {
a := p.Masked().Addr()
b := a.As16()
start := p.Bits()
if a.Is4() {
start += 96
}
for i := start; i < 128; i++ {
byteIdx := i / 8
bit := uint(7 - (i % 8))
b[byteIdx] |= 1 << bit
}
out := netip.AddrFrom16(b)
if a.Is4() {
out = out.Unmap()
}
return out
}
func nextAddr(a netip.Addr) netip.Addr { return a.Next() }
func prevAddr(a netip.Addr) netip.Addr { return a.Prev() }
func prefixMask4(bits int) string {
if bits < 0 || bits > 32 {
return ""
}
var n uint32
if bits > 0 {
n = ^uint32(0) << uint(32-bits)
}
return fmt.Sprintf("%d.%d.%d.%d", byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
}
func (a *Application) handleTools(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/tools/"), "/")
switch path {
case "file-inspect":
a.handleToolFileInspect(w, r)
case "cert-inspect":
a.handleToolCertInspect(w, r)
case "archive":
a.handleToolArchive(w, r)
case "dns":
a.handleToolDNS(w, r)
case "connectivity":
a.handleToolConnectivity(w, r)
case "subnet":
a.handleToolSubnet(w, r)
default:
writeJSON(w, 404, apiError{Error: "unknown tool"})
}
}