Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,4 @@ $RECYCLE.BIN/

# Windows shortcuts
*.lnk
.frp/
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ run:
./cmd
@echo "✅ Running $(BIN) version $(APP_VERSION)"

# Local frps matching the client library pinned in go.mod; used to verify the
# tunnel end-to-end without deploying anything (see deploy/tunnel/). frps is
# not `go run`-able (its go.mod has replace directives and its web assets are
# not in the module zip), so the official release binary is downloaded once
# into the git-ignored .frp/ directory.
FRP_VERSION := 0.69.1
FRP_DIST := frp_$(FRP_VERSION)_$(shell go env GOOS)_$(shell go env GOARCH)
FRPS_BIN := .frp/$(FRP_DIST)/frps

$(FRPS_BIN):
@mkdir -p .frp
@curl -fsSL https://github.com/fatedier/frp/releases/download/v$(FRP_VERSION)/$(FRP_DIST).tar.gz | tar -xz -C .frp
@echo "✅ Downloaded frps v$(FRP_VERSION) to $(FRPS_BIN)"

.PHONY: dev-frps
dev-frps: $(FRPS_BIN)
$(FRPS_BIN) -c deploy/tunnel/frps.dev.toml

.PHONY: run-frontend
run-frontend:
@cd ${FRONTEND_DIR} && pnpm install
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ To run the binary with custom config, you can pass the config file as an argumen
### Docker
TBD

## Local Tunneling

Running locally but need Stripe/GitHub/etc. to reach you? InHook has a built-in
tunnel, powered by an embedded [frp](https://github.com/fatedier/frp) client.
Click **Expose Online** in the dashboard and your webhook URL switches to a
public address like `https://<id>.t.inhook.mrinal.xyz/webhook/<token>/` —
requests to it are captured straight into your local instance.

- Only the webhook ingestion path (`/webhook/...`) is exposed publicly. The
dashboard, API, and captured data stay on your machine.
- Your subdomain is remembered, so the public URL is stable across restarts.
- To start the tunnel with the app, or to point it at your own tunnel server,
see the `[tunnel]` section in `config.toml`. A guide for self-hosting the
tunnel server (frps) lives in [deploy/tunnel/](deploy/tunnel/).

InHook bundles the frp client library, licensed under the
[Apache License 2.0](https://github.com/fatedier/frp/blob/dev/LICENSE).

### License
inHook is licensed under the [MIT](LICENSE.md) license.

Expand Down
21 changes: 21 additions & 0 deletions cmd/db_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ func (s *DBService) MarkEventAsRead(eventId string) (int64, error) {
return tokenID, nil
}

func (s *DBService) GetSetting(key string) (string, error) {
var value string
err := s.db.Get(&value, "SELECT value FROM app_setting WHERE key = ?", key)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return value, nil
}

func (s *DBService) SetSetting(key, value string) error {
_, err := s.db.Exec(
`INSERT INTO app_setting (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = current_timestamp`,
key, value,
)
return err
}

func (s *DBService) DeleteAllEventsByTokenID(tokenId string) error {
_, err := s.db.Exec("DELETE FROM webhook_event WHERE token_id = ?", tokenId)
if err != nil {
Expand Down
67 changes: 67 additions & 0 deletions cmd/db_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import (
"path/filepath"
"testing"

"github.com/jmoiron/sqlx"
)

func newTestDB(t *testing.T) *sqlx.DB {
t.Helper()

db, err := sqlx.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { db.Close() })
initMigrations(db)
return db
}

func TestGetSettingAbsentReturnsEmpty(t *testing.T) {
service := DBService{db: newTestDB(t)}

value, err := service.GetSetting("tunnel_subdomain")
if err != nil {
t.Fatalf("expected no error for absent key, got %v", err)
}
if value != "" {
t.Errorf("expected empty value for absent key, got %q", value)
}
}

func TestSetGetSettingRoundtrip(t *testing.T) {
service := DBService{db: newTestDB(t)}

if err := service.SetSetting("tunnel_subdomain", "a1b2c3d4"); err != nil {
t.Fatalf("failed to set setting: %v", err)
}

value, err := service.GetSetting("tunnel_subdomain")
if err != nil {
t.Fatalf("failed to get setting: %v", err)
}
if value != "a1b2c3d4" {
t.Errorf("expected a1b2c3d4, got %q", value)
}
}

func TestSetSettingUpsertsExistingKey(t *testing.T) {
service := DBService{db: newTestDB(t)}

if err := service.SetSetting("tunnel_subdomain", "first000"); err != nil {
t.Fatalf("failed to set setting: %v", err)
}
if err := service.SetSetting("tunnel_subdomain", "second11"); err != nil {
t.Fatalf("failed to overwrite setting: %v", err)
}

value, err := service.GetSetting("tunnel_subdomain")
if err != nil {
t.Fatalf("failed to get setting: %v", err)
}
if value != "second11" {
t.Errorf("expected second11 after upsert, got %q", value)
}
}
3 changes: 3 additions & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,9 @@ func initHandlers(app *App) http.Handler {
)
handler.HandleFunc("GET /api/webhook/config/{$}", getWebhookConfigHandler(app))
handler.HandleFunc("GET /api/webhook/{token_id}/ws", wsHandler(app))
handler.HandleFunc("GET /api/tunnel/status/{$}", tunnelStatusHandler(app))
handler.HandleFunc("POST /api/tunnel/start/{$}", tunnelStartHandler(app))
handler.HandleFunc("POST /api/tunnel/stop/{$}", tunnelStopHandler(app))
handler.HandleFunc(
"POST /api/webhook/event/{event_id}/read/", markEventAsReadHandler(app),
)
Expand Down
19 changes: 19 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
Expand All @@ -20,6 +21,18 @@ import (

// initConfig loads the config files into the koanf instance
func initConfig(ko *koanf.Koanf) {
// Defaults for optional sections; config files override them.
if err := ko.Load(confmap.Provider(map[string]interface{}{
"tunnel.enabled": false,
"tunnel.server_addr": "t.inhook.mrinal.xyz",
"tunnel.server_port": 9090,
"tunnel.auth_token": "inhook-public-tunnel",
"tunnel.domain": "t.inhook.mrinal.xyz",
"tunnel.scheme": "https",
}, "."), nil); err != nil {
log.Fatalf("Error loading config defaults: %v", err)
}

for _, f := range ko.Strings("config") {
log.Println("Loading config file(s)")
if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
Expand Down Expand Up @@ -142,6 +155,12 @@ func initMigrations(db *sqlx.DB) {
);`,
// index to speed up the queries
`CREATE INDEX IF NOT EXISTS idx_webhook_events_token_id ON webhook_event(token_id, created_at DESC);`,
// table to store the app settings (e.g. the tunnel subdomain)
`CREATE TABLE IF NOT EXISTS app_setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT current_timestamp
);`,
}

for _, migration := range migrations {
Expand Down
28 changes: 28 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/zerodha/logf"

"github.com/themrinalsinha/inhook/internal/tunnel"
)

var (
Expand All @@ -33,6 +35,7 @@ type App struct {
fs stuffbin.FileSystem
lo *logf.Logger
hub *Hub
tunnel tunnelController
buildVersion string
buildHash string
buildDate string
Expand Down Expand Up @@ -65,17 +68,39 @@ func main() {
// Initialize the logger
lo := initLogger(appName)

// Initialize the tunnel manager (started on demand, or at boot when
// tunnel.enabled is set)
localPort, err := localPortFromAddr(ko.String("app.port"))
if err != nil {
log.Fatalf("Error parsing app.port %q: %v", ko.String("app.port"), err)
}
tm := tunnel.NewManager(tunnel.Config{
ServerAddr: ko.String("tunnel.server_addr"),
ServerPort: ko.Int("tunnel.server_port"),
AuthToken: ko.String("tunnel.auth_token"),
Domain: ko.String("tunnel.domain"),
Scheme: ko.String("tunnel.scheme"),
LocalPort: localPort,
}, tunnelSlugStore{svc: DBService{db: db}}, lo)

var app = &App{
db: db,
fs: fs,
lo: lo,
hub: NewHub(),
tunnel: tm,
buildVersion: buildVersion,
buildHash: buildHash,
buildDate: buildDate,
buildHashFull: buildHashFull,
}

if ko.Bool("tunnel.enabled") {
// Boot autostart retries quietly until the tunnel server is
// reachable; errors are logged by the manager itself.
go tm.Start(false)
}

// initiate net/http and pass app as context
server := &http.Server{
Addr: ko.String("app.port"),
Expand Down Expand Up @@ -108,6 +133,9 @@ func main() {
log.Println("\033[32mServer stopped gracefully\033[0m")
}

// Stop the tunnel, if running
tm.Shutdown(ctx)

// Close the database connection
if err := db.Close(); err != nil {
log.Printf("\033[31mError closing database: %v\033[0m", err)
Expand Down
83 changes: 83 additions & 0 deletions cmd/tunnel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"encoding/json"
"net"
"net/http"
"strconv"

"github.com/themrinalsinha/inhook/internal/tunnel"
)

// tunnelController is the slice of tunnel.Manager the handlers need; tests
// substitute a fake.
type tunnelController interface {
Start(failFast bool) error
Stop() error
Status() tunnel.Status
}

// tunnelSlugStore adapts DBService to the tunnel.SlugStore interface.
type tunnelSlugStore struct {
svc DBService
}

func (s tunnelSlugStore) Load() (string, error) {
return s.svc.GetSetting("tunnel_subdomain")
}

func (s tunnelSlugStore) Save(slug string) error {
return s.svc.SetSetting("tunnel_subdomain", slug)
}

// localPortFromAddr extracts the port number from a listen address like
// ":9000" so the tunnel knows where to forward traffic.
func localPortFromAddr(addr string) (int, error) {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return 0, err
}
return strconv.Atoi(port)
}

func writeTunnelStatus(w http.ResponseWriter, status tunnel.Status) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(status)
}

func writeTunnelError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}

func tunnelStatusHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeTunnelStatus(w, app.tunnel.Status())
}
}

func tunnelStartHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
app.lo.Info("Starting tunnel")
// failFast: a UI-triggered start should surface a bad server or
// token immediately instead of retrying in the background.
if err := app.tunnel.Start(true); err != nil {
writeTunnelError(w, err)
return
}
writeTunnelStatus(w, app.tunnel.Status())
}
}

func tunnelStopHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
app.lo.Info("Stopping tunnel")
if err := app.tunnel.Stop(); err != nil {
writeTunnelError(w, err)
return
}
writeTunnelStatus(w, app.tunnel.Status())
}
}
Loading