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"}) } }