Rewrite the service in Go and ship it as a static container
The PHP app, Apache, and .htaccess are replaced by a single Go binary (standard library only). Routing, the JSON response, the landing page, the shared email design, and all four parsers are ported with the same output; JSON object keys are kept in sender order via an ordered decoder. - mail.go: SMTP delivery with quoted-printable body and RFC 2047 subject - main.go: routing, client IP from the proxy, /healthz, -check flag - render.go: email shell, escaping, table helpers, generic renderer - parser_*.go: github, grafana, json, wxinteract - main_test.go: handler, response shape, IP forwarding, every parser - Dockerfile: multi-stage build to distroless static, non-root, HEALTHCHECK - deploy/webhooks.dixon.cx.service: systemd unit for the container - README and PARSER_PROMPT updated for Go; IMPLEMENTATION_SUMMARY removed
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
// Webhook Email Forwarder
|
||||
//
|
||||
// Captures incoming webhook requests and forwards the complete request
|
||||
// (headers, body, metadata) as an HTML email to the address given in the
|
||||
// URL path: /you%40example.com[/parser]
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed docs.html
|
||||
var docsSrc string
|
||||
|
||||
var docsTmpl = template.Must(template.New("docs").Parse(docsSrc))
|
||||
|
||||
const maxBody = 10 << 20 // 10 MB, like PHP's post_max_size
|
||||
|
||||
// Meta is the request metadata handed to every parser.
|
||||
type Meta struct {
|
||||
Date string // Y-m-d H:i:s
|
||||
IP string
|
||||
Method string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Parser turns a raw webhook into an email. ok=false means "fall back to the default email".
|
||||
type Parser func(raw []byte, headers http.Header, meta Meta) (html, subject string, ok bool)
|
||||
|
||||
var parsers = map[string]Parser{
|
||||
"github": githubParse,
|
||||
"grafana": grafanaParse,
|
||||
"json": jsonParse,
|
||||
"wxinteract": wxinteractParse,
|
||||
}
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", envOr("LISTEN", "127.0.0.1:8080"), "address to listen on")
|
||||
check := flag.Bool("check", false, "health-check a running instance and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *check {
|
||||
addr := *listen
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "127.0.0.1" + addr
|
||||
}
|
||||
resp, err := http.Get("http://" + addr + "/healthz")
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: http.HandlerFunc(handle),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("listening on %s, smtp %s, from %s", *listen, smtpAddr, mailFrom)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
func validEmail(s string) bool {
|
||||
if !emailRe.MatchString(s) {
|
||||
return false
|
||||
}
|
||||
a, err := mail.ParseAddress(s)
|
||||
return err == nil && a.Address == s
|
||||
}
|
||||
|
||||
func handle(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
_ = docsTmpl.Execute(w, map[string]string{"Host": r.Host})
|
||||
return
|
||||
}
|
||||
if path == "healthz" {
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(path, "/", 3)
|
||||
email := parts[0] // r.URL.Path is already percent-decoded
|
||||
parserName := ""
|
||||
if len(parts) > 1 {
|
||||
parserName = parts[1]
|
||||
}
|
||||
|
||||
if !validEmail(email) {
|
||||
writeJSON(w, 400, errResp{false, "invalid_email", "Invalid email address provided in URL path.", "Encode @ as %40, e.g. /you%40example.com"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
meta := Meta{
|
||||
Date: now.Format("2006-01-02 15:04:05"),
|
||||
IP: clientIP(r),
|
||||
Method: r.Method,
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
}
|
||||
if meta.ContentType == "" {
|
||||
meta.ContentType = "N/A"
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
||||
if err != nil {
|
||||
writeJSON(w, 413, errResp{false, "body_too_large", err.Error(), ""})
|
||||
return
|
||||
}
|
||||
|
||||
// Form fields and uploaded file names, the way PHP's $_POST / $_FILES saw them.
|
||||
form := map[string]string{}
|
||||
files := []string{}
|
||||
ct := strings.ToLower(meta.ContentType)
|
||||
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") {
|
||||
r.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(maxBody); err == nil && r.MultipartForm != nil {
|
||||
for name := range r.MultipartForm.File {
|
||||
files = append(files, name)
|
||||
}
|
||||
sort.Strings(files)
|
||||
}
|
||||
} else {
|
||||
_ = r.ParseForm()
|
||||
}
|
||||
for k, v := range r.PostForm {
|
||||
form[k] = v[len(v)-1]
|
||||
}
|
||||
}
|
||||
query := map[string]string{}
|
||||
for k, v := range r.URL.Query() {
|
||||
query[k] = v[len(v)-1]
|
||||
}
|
||||
|
||||
// Every header, Host included, as the email and parsers see them.
|
||||
headers := r.Header.Clone()
|
||||
headers.Set("Host", r.Host)
|
||||
|
||||
var body, subject string
|
||||
if p, found := parsers[parserName]; found {
|
||||
body, subject, _ = p(raw, headers, meta)
|
||||
}
|
||||
if body == "" {
|
||||
body, subject = defaultEmail(raw, headers, meta, query, form, files)
|
||||
}
|
||||
|
||||
if err := sendMail(email, subject, body); err != nil {
|
||||
log.Printf("mail to %s failed: %v", email, err)
|
||||
writeJSON(w, 502, errResp{false, "mail_failed", "Webhook received but the email could not be sent.", ""})
|
||||
return
|
||||
}
|
||||
|
||||
var jsonEcho json.RawMessage
|
||||
if len(raw) > 0 && strings.Contains(ct, "json") && json.Valid(raw) {
|
||||
jsonEcho = raw
|
||||
}
|
||||
if parserName == "" {
|
||||
parserName = "default"
|
||||
}
|
||||
writeJSON(w, 200, response{
|
||||
OK: true,
|
||||
Message: "Webhook received and forwarded to " + email,
|
||||
ForwardedTo: email,
|
||||
Parser: parserName,
|
||||
Subject: subject,
|
||||
ReceivedAt: now.Format("2006-01-02T15:04:05-07:00"),
|
||||
Request: reqInfo{
|
||||
Method: meta.Method,
|
||||
ContentType: meta.ContentType,
|
||||
IP: meta.IP,
|
||||
Headers: len(headers),
|
||||
BodyBytes: len(raw),
|
||||
},
|
||||
Data: reqData{Query: query, Form: form, JSON: jsonEcho, Files: files},
|
||||
})
|
||||
}
|
||||
|
||||
type errResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
ForwardedTo string `json:"forwarded_to"`
|
||||
Parser string `json:"parser"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
Request reqInfo `json:"request"`
|
||||
Data reqData `json:"data"`
|
||||
}
|
||||
|
||||
type reqInfo struct {
|
||||
Method string `json:"method"`
|
||||
ContentType string `json:"content_type"`
|
||||
IP string `json:"ip"`
|
||||
Headers int `json:"headers"`
|
||||
BodyBytes int `json:"body_bytes"`
|
||||
}
|
||||
|
||||
type reqData struct {
|
||||
Query map[string]string `json:"query"`
|
||||
Form map[string]string `json:"form"`
|
||||
JSON json.RawMessage `json:"json"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// writeJSON emits pretty-printed JSON with 4-space indent and no HTML escaping,
|
||||
// preserving key order inside any RawMessage.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
var out bytes.Buffer
|
||||
_ = json.Indent(&out, bytes.TrimSpace(buf.Bytes()), "", " ")
|
||||
out.WriteByte('\n')
|
||||
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(out.Bytes())
|
||||
}
|
||||
|
||||
// clientIP prefers the address nginx forwards, but only when the direct peer
|
||||
// is private/loopback (i.e. a proxy), so public clients cannot spoof it.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()) {
|
||||
fwd := r.Header.Get("X-Real-IP")
|
||||
if fwd == "" {
|
||||
fwd = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
|
||||
}
|
||||
if net.ParseIP(fwd) != nil {
|
||||
return fwd
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// ---------- default email ----------
|
||||
|
||||
func defaultEmail(raw []byte, headers http.Header, meta Meta, query, form map[string]string, files []string) (string, string) {
|
||||
keys := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var hb strings.Builder
|
||||
for _, k := range keys {
|
||||
for _, v := range headers[k] {
|
||||
hb.WriteString(h(k) + ": " + h(v) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
vars, _ := json.MarshalIndent(map[string]any{
|
||||
"GET Variables": query,
|
||||
"POST Variables": form,
|
||||
"Uploaded Files": files,
|
||||
}, "", " ")
|
||||
|
||||
body := section("📋 Request Information", table(
|
||||
row("IP Address", h(meta.IP)),
|
||||
row("Method", h(meta.Method)),
|
||||
row("Content-Type", h(meta.ContentType)),
|
||||
)) +
|
||||
section("📨 Request Headers", `<pre class="data-box">`+hb.String()+`</pre>`) +
|
||||
section("📦 Request Body", `<pre class="data-box">`+h(string(raw))+`</pre>`) +
|
||||
section("🔧 Parsed Variables", `<pre class="data-box">`+h(string(vars))+`</pre>`)
|
||||
|
||||
html := emailShell("⚡ Webhook Request Received", h(meta.Date), body,
|
||||
"This webhook was automatically forwarded to your email address.")
|
||||
return html, fmt.Sprintf("‼️ Webhook Request Received - %s", meta.Date)
|
||||
}
|
||||
Reference in New Issue
Block a user