520 lines
16 KiB
Go
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"})
|
|
}
|
|
}
|