All checks were successful
check / check (push) Successful in 9s
Go embeds the request URL in *url.Error, so any transport failure — DNS, TLS, refused, timeout, SSRF dial block — persisted the full Slack webhook URL into the per-webhook SQLite database via DeliveryResult.Error. That field is tagged json:"error,omitempty", so a future REST API would have served it. maskURLError rebuilds the error preserving Op and the wrapped cause, so DNS vs TLS vs timeout still read differently and errors.Is/As and Timeout() keep working; only path, query and userinfo are dropped. Applied where the errors are born, which covers both the Slack and HTTP targets. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment. The SSRF rejection log now logs the masked URL, and source_logs.html receives view types rather than raw rows, so no config blob is reachable from that template. MaskURL is now the single masker for the whole tree.
300 lines
5.6 KiB
Go
300 lines
5.6 KiB
Go
package delivery
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// errMissingWebhookURL is returned when a Slack target config
|
|
// omits its webhook URL.
|
|
var errMissingWebhookURL = errors.New(
|
|
"webhook_url is required",
|
|
)
|
|
|
|
// SlackTargetConfig holds configuration for slack target
|
|
// types.
|
|
type SlackTargetConfig struct {
|
|
WebhookURL string `json:"webhookUrl"`
|
|
}
|
|
|
|
// slackTarget delivers events to Slack incoming webhooks. It
|
|
// formats the event into a Slack message and posts it as
|
|
// JSON. It shares the retry core with the HTTP target: a
|
|
// MaxRetries of 0 stays single-attempt fire-and-forget
|
|
// (preserving existing Slack targets), while a positive
|
|
// MaxRetries adds backoff and circuit breaking.
|
|
type slackTarget struct {
|
|
*httpCore
|
|
|
|
client *http.Client
|
|
}
|
|
|
|
// Deliver implements Target.
|
|
func (t *slackTarget) Deliver(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
task *Task,
|
|
sched Scheduler,
|
|
) {
|
|
cfg, err := parseSlackConfig(d.Target.Config)
|
|
if err != nil {
|
|
t.eng.log.Error(
|
|
"invalid Slack target config",
|
|
"target_id", d.TargetID,
|
|
"error", err,
|
|
)
|
|
|
|
t.failConfig(webhookDB, d, err)
|
|
|
|
return
|
|
}
|
|
|
|
msg := FormatSlackMessage(&d.Event)
|
|
|
|
payload, err := json.Marshal(
|
|
map[string]string{"text": msg},
|
|
)
|
|
if err != nil {
|
|
t.eng.log.Error(
|
|
"failed to marshal Slack payload",
|
|
"target_id", d.TargetID,
|
|
"error", err,
|
|
)
|
|
|
|
t.failConfig(webhookDB, d, err)
|
|
|
|
return
|
|
}
|
|
|
|
attempt := func() attemptResult {
|
|
return t.attempt(ctx, cfg, payload)
|
|
}
|
|
|
|
t.deliver(
|
|
webhookDB, d, task, sched,
|
|
d.Target.MaxRetries, attempt,
|
|
)
|
|
}
|
|
|
|
// failConfig records a first-attempt failure for a delivery
|
|
// that could not be prepared (bad config or unmarshalable
|
|
// payload) and marks it failed.
|
|
func (t *slackTarget) failConfig(
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
err error,
|
|
) {
|
|
t.eng.recordResult(
|
|
webhookDB, d, 1,
|
|
false, 0, "", err.Error(), 0,
|
|
)
|
|
|
|
t.eng.updateDeliveryStatus(
|
|
webhookDB, d, database.DeliveryStatusFailed,
|
|
)
|
|
}
|
|
|
|
// attempt performs a single Slack POST and derives its
|
|
// outcome, preserving the engine's original semantics: a
|
|
// non-2xx response records an "HTTP <code>" error string and
|
|
// a transport error records a "sending request" error.
|
|
func (t *slackTarget) attempt(
|
|
ctx context.Context,
|
|
cfg *SlackTargetConfig,
|
|
payload []byte,
|
|
) attemptResult {
|
|
start := time.Now()
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
cfg.WebhookURL,
|
|
bytes.NewReader(payload),
|
|
)
|
|
if err != nil {
|
|
return attemptResult{
|
|
success: false,
|
|
errMsg: maskURLError(err).Error(),
|
|
}
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
|
|
|
resp, doErr := executeHTTPRequest(t.client, req)
|
|
durationMs := time.Since(start).Milliseconds()
|
|
|
|
if doErr != nil {
|
|
return attemptResult{
|
|
success: false,
|
|
duration: durationMs,
|
|
errMsg: fmt.Errorf(
|
|
"sending request: %w", doErr,
|
|
).Error(),
|
|
}
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
return t.readSlackResponse(resp, durationMs)
|
|
}
|
|
|
|
func (t *slackTarget) readSlackResponse(
|
|
resp *http.Response,
|
|
durationMs int64,
|
|
) attemptResult {
|
|
body, readErr := io.ReadAll(
|
|
io.LimitReader(resp.Body, maxBodyLog),
|
|
)
|
|
if readErr != nil {
|
|
t.eng.log.Error(
|
|
"failed to read Slack response body",
|
|
"error", readErr,
|
|
)
|
|
}
|
|
|
|
success := resp.StatusCode >= httpSuccessMin &&
|
|
resp.StatusCode < httpSuccessMax
|
|
|
|
errMsg := ""
|
|
if !success {
|
|
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
return attemptResult{
|
|
statusCode: resp.StatusCode,
|
|
respBody: string(body),
|
|
duration: durationMs,
|
|
success: success,
|
|
errMsg: errMsg,
|
|
}
|
|
}
|
|
|
|
func parseSlackConfig(
|
|
configJSON string,
|
|
) (*SlackTargetConfig, error) {
|
|
if configJSON == "" {
|
|
return nil, errEmptyTargetConfig
|
|
}
|
|
|
|
var cfg SlackTargetConfig
|
|
|
|
err := json.Unmarshal(
|
|
[]byte(configJSON), &cfg,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"parsing config JSON: %w", err,
|
|
)
|
|
}
|
|
|
|
if cfg.WebhookURL == "" {
|
|
return nil, errMissingWebhookURL
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// FormatSlackMessage builds a Slack-compatible message
|
|
// string from a webhook event.
|
|
func FormatSlackMessage(
|
|
event *database.Event,
|
|
) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("*Webhook Event Received*\n")
|
|
|
|
fmt.Fprintf(
|
|
&b, "*Method:* `%s`\n", event.Method,
|
|
)
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Content-Type:* `%s`\n",
|
|
event.ContentType,
|
|
)
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Timestamp:* `%s`\n",
|
|
event.CreatedAt.UTC().Format(time.RFC3339),
|
|
)
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Body Size:* %d bytes\n",
|
|
len(event.Body),
|
|
)
|
|
|
|
if event.Body == "" {
|
|
b.WriteString("\n_(empty body)_\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
if formatted := formatJSONBody(event.Body); formatted != "" {
|
|
b.WriteString(formatted)
|
|
|
|
return b.String()
|
|
}
|
|
|
|
formatRawBody(&b, event.Body)
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func formatJSONBody(body string) string {
|
|
var parsed json.RawMessage
|
|
if json.Unmarshal([]byte(body), &parsed) != nil {
|
|
return ""
|
|
}
|
|
|
|
var pretty bytes.Buffer
|
|
if json.Indent(&pretty, parsed, "", " ") != nil {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString("\n```\n")
|
|
|
|
prettyStr := pretty.String()
|
|
|
|
const maxPayloadDisplay = 3500
|
|
if len(prettyStr) > maxPayloadDisplay {
|
|
b.WriteString(prettyStr[:maxPayloadDisplay])
|
|
b.WriteString("\n... (truncated)")
|
|
} else {
|
|
b.WriteString(prettyStr)
|
|
}
|
|
|
|
b.WriteString("\n```\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func formatRawBody(b *strings.Builder, body string) {
|
|
b.WriteString("\n```\n")
|
|
|
|
const maxRawDisplay = 3500
|
|
if len(body) > maxRawDisplay {
|
|
b.WriteString(body[:maxRawDisplay])
|
|
b.WriteString("\n... (truncated)")
|
|
} else {
|
|
b.WriteString(body)
|
|
}
|
|
|
|
b.WriteString("\n```\n")
|
|
}
|