Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd5fe26
Merge pull request #12 from GaIsBAX/refactor/serve-config-options
GaIsBAX May 17, 2026
a8823b9
feat: add forward command, search/filter UI, fix request id type
GaIsBAX May 17, 2026
80c4c79
fix(lint): resolve nilerr and errcheck in forward runner
GaIsBAX May 17, 2026
36f7833
fix(forward): use bufio.Reader, cfg-based auth, scanner buffer limit
GaIsBAX May 17, 2026
0c0ea59
Merge pull request #14 from GaIsBAX/feature/search-filter-forward
DENFNC May 17, 2026
42bdf9c
refactor(repos): move hook repository out of store
DENFNC May 17, 2026
a6d8774
feat(serve): add retention cleanup flow
DENFNC May 17, 2026
4553905
refactor(app): split application wiring
DENFNC May 17, 2026
88bffe1
fix(app): pass context to Shutdown, return deps close error
GaIsBAX May 18, 2026
0a545d4
fix(cli): RetentionCleaner
DENFNC May 18, 2026
a07d398
feat(cli): add version command and app wiring
DENFNC May 18, 2026
958684d
feat(server): add read-only serve mode
DENFNC May 18, 2026
d4f7a51
fix(cli): mark yaml as direct dependency
DENFNC May 18, 2026
2bb6ead
fix(cli): format version output switch
DENFNC May 18, 2026
17e8a0f
fix(cli): handle linted errors
DENFNC May 18, 2026
3fe8794
fix(sql): clean up old webhook requests instead of hooks
DENFNC May 18, 2026
1b43fe0
refactor(app): restructure application initialization and error handling
DENFNC May 18, 2026
b85ffd7
refactor(app): enhance application structure with new HTTP handler an…
DENFNC May 18, 2026
2a7faae
fix(app): configure HTTP read header timeout
DENFNC May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ You create an endpoint, point your webhook source at it, and watch requests come

Beyond just inspecting requests, you can:

- **Replay** any request with one click, optionally with edits
- **Custom responses** — configure the status code, headers, and body your endpoint returns (useful as a lightweight mock server)
- **Replay** any captured request with one click
- **Custom responses** — configure the status code, headers, and body your endpoint returns to senders (useful as a lightweight mock server)
- **CLI forwarding** — pipe incoming requests to a local port: `webhix forward <token> --to localhost:3000`
- **Export as curl or HTTPie** -- copy any request as a runnable command
- **Export as curl** — copy any request as a runnable curl command
- **Search and filter** — filter requests by text or HTTP method

## Quick start

Expand Down Expand Up @@ -57,12 +58,21 @@ Endpoint URLs follow the pattern `https://<base-url>/r/<token>`.

## Auth

Single-user by default. Set a password via env or let Webhix generate one on first run:
Auth is required. Set at least one of:

```sh
# Basic auth password (browser login)
WEBHIX_PASSWORD=yourpassword webhix serve

# Secret key for API / CLI access (Authorization: Bearer or X-Webhix-Key header)
WEBHIX_SECRET_KEY=yourkey webhix serve

# Both at once
webhix serve --password yourpassword --secret-key yourkey
```

Webhook capture URLs (`/r/<token>`) are always public — no auth required there.

## Reverse proxy

Works behind Caddy, Nginx, Traefik. Reads `X-Forwarded-*` headers automatically. Set `--base-url` or `WEBHIX_BASE_URL` to match your public domain.
Expand Down
12 changes: 9 additions & 3 deletions cmd/webhix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"os/signal"
"syscall"

"github.com/GaIsBAX/Webhix/internal/cli"
"github.com/GaIsBAX/Webhix/internal/app"
"github.com/GaIsBAX/Webhix/internal/config"
_ "github.com/GaIsBAX/Webhix/pkg"
)
Expand All @@ -26,8 +26,14 @@ func main() {
os.Exit(1)
}

if err := cli.Run(ctx, cfg, os.Args[1:]); err != nil {
slog.Error("app run", "err", err)
application, err := app.New(ctx, cfg)
if err != nil {
slog.Error("up app", "err", err)
os.Exit(1)
}

if err := application.Start(ctx); err != nil {
slog.Error("start app", "err", err)
os.Exit(1)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand All @@ -23,6 +24,5 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)
110 changes: 49 additions & 61 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,121 +2,109 @@ package app

import (
"context"
"fmt"
"io/fs"
"errors"
"log/slog"
"net/http"
"time"

"github.com/GaIsBAX/Webhix/internal/config"
"github.com/GaIsBAX/Webhix/internal/core"
"github.com/GaIsBAX/Webhix/internal/hub"
"github.com/GaIsBAX/Webhix/internal/server"
"github.com/GaIsBAX/Webhix/internal/server/middleware"
"github.com/GaIsBAX/Webhix/internal/store"
"github.com/GaIsBAX/Webhix/internal/web"
)

const (
readHeaderTimeout = 5 * time.Second
shutdownTimeout = 10 * time.Second
)

type App struct {
srv *http.Server
cfg *config.Config
deps *Deps
hub *hub.Hub
server *http.Server

config *config.Config
deps *dependencies
}

func New(ctx context.Context, cfg *config.Config) (*App, error) {
mux := http.NewServeMux()

deps, err := NewDeps(ctx, cfg)
deps, err := newDependencies(ctx, cfg)
if err != nil {
return nil, err
}

eventHub := hub.New()

hookRepository := store.NewHookRepository(deps.DB.DB)
hookService := core.NewHookService(hookRepository)
hookHandler := server.NewHookHandler(mux, hookService, eventHub, server.HookHandlerOptions{
BaseURL: cfg.BaseURL,
MaxBodySize: cfg.MaxBodySize,
})

hookHandler.RegisterRoutes()

staticSub, err := fs.Sub(web.Static, "static")
handler, err := newHTTPHandler(deps.mux, cfg)
if err != nil {
if closeErr := deps.close(); closeErr != nil {
return nil, errors.Join(err, closeErr)
}
return nil, err
}
staticFS := http.FileServer(http.FS(staticSub))
mux.Handle("/ui/", http.StripPrefix("/ui/", staticFS))
mux.Handle("/", staticFS)

password, secretKey, err := resolveAuth(cfg)
if err != nil {
return nil, fmt.Errorf("auth setup: %w", err)
server := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
}

handler := http.Handler(mux)
handler = middleware.NewAuth(password, secretKey).Protect(handler)
return &App{
server: server,
config: cfg,
deps: deps,
}, nil
}

func newHTTPHandler(mux *http.ServeMux, cfg *config.Config) (http.Handler, error) {
var middlewares []func(http.Handler) http.Handler

if len(cfg.TrustedProxies) > 0 {
trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies)
if trustedProxies == nil {
return nil, fmt.Errorf("invalid trusted proxies")
return nil, ErrInvalidTrustedProxies
}
middlewares = append(middlewares, trustedProxies.BehindProxy)
}

handler = trustedProxies.BehindProxy(handler)
if cfg.Password != "" || cfg.SecretKey != "" {
auth := middleware.NewAuth(cfg.Password, cfg.SecretKey)
middlewares = append(middlewares, auth.Protect)
}

return &App{
srv: &http.Server{Addr: cfg.Addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second},
cfg: cfg,
deps: deps,
hub: eventHub,
}, nil
return server.Chain(mux, middlewares...), nil
}

func (a *App) Start(ctx context.Context) error {
errCh := make(chan error, 1)
serverErr := make(chan error, 1)
go func() {
slog.Info("webhix started", "addr", a.cfg.Addr, "base_url", a.cfg.BaseURL)
if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
slog.Info("webhix started", "addr", a.config.Addr, "base_url", a.config.BaseURL)
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()

select {
case err := <-errCh:
case err := <-serverErr:
return err
case <-ctx.Done():
return a.Shutdown()
}
}

func resolveAuth(cfg *config.Config) (password, secretKey string, err error) {
if cfg.Password == "" && cfg.SecretKey == "" {
return "", "", fmt.Errorf("auth is required: set WEBHIX_PASSWORD or WEBHIX_SECRET_KEY")
case <-ctx.Done():
return a.Shutdown(ctx)
}

return cfg.Password, cfg.SecretKey, nil
}

func (a *App) Shutdown() error {
func (a *App) Shutdown(ctx context.Context) error {
slog.Info("shutting down")
a.hub.Close()
a.deps.infra.hub.Close()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
defer cancel()

if err := a.srv.Shutdown(ctx); err != nil {
if err := a.server.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed, forcing close", "err", err)
if closeErr := a.srv.Close(); closeErr != nil {
if closeErr := a.server.Close(); closeErr != nil {
slog.Error("server close failed", "err", closeErr)
}
}

if err := a.deps.teardownInfrastructure(); err != nil {
if err := a.deps.close(); err != nil {
slog.Error("teardown error", "err", err)
return err
}

return nil
Expand Down
Loading