diff --git a/.gitignore b/.gitignore
index 3b735ec..87fd87c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,21 +1,68 @@
-# If you prefer the allow list template instead of the deny list, see community template:
-# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
-#
+/bin
+/dist
+/target
+/.cr-release-packages
+/vendor
+/reverse-http
+/*.pem
+/*.b64
+
+/*.tar
+/*.tgz
+
+# Intellij
+.idea/
+out/
+*.iml
+
# Binaries for programs and plugins
*.exe
-*.exe~
*.dll
*.so
*.dylib
-# Test binary, built with `go test -c`
+# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
-# Dependency directories (remove the comment below to include it)
-# vendor/
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.prof
+
+# coverage
+.coverprofile
+gover.coverprofile
+
+# Swap
+[._]*.s[a-v][a-z]
+[._]*.sw[a-p]
+[._]s[a-v][a-z]
+[._]sw[a-p]
+
+# Session
+Session.vim
-# Go workspace file
-go.work
+# Temporary
+.netrwhist
+*~
+# Auto-generated tag files
+tags
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..8f84707
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,34 @@
+# options for analysis running
+run:
+ # exit code when at least one issue was found, default is 1
+ issues-exit-code: 1
+
+ # which dirs to skip: they won't be analyzed;
+ # can use regexp here: generated.*, regexp is applied on full path;
+ # default value is empty list, but next dirs are always skipped independently
+ # from this option's value:
+ # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
+ skip-dirs:
+ - vendor
+
+linters:
+ enable:
+ - errcheck
+ - goconst
+ - godot
+ - gofmt
+ - goimports
+ - gosimple
+ - govet
+ - ineffassign
+ - staticcheck
+ - typecheck
+ - unparam
+ - unused
+ - exportloopref
+
+issues:
+ exclude-rules:
+ - path: _test\.go
+ linters:
+ - unparam
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b9ee7ab
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM golang:1.21-alpine3.19 AS builder
+# hadolint ignore=DL3018
+RUN apk add --no-cache alpine-sdk ca-certificates curl
+
+WORKDIR /app
+COPY go.* ./
+RUN go mod download
+COPY . .
+RUN make vendor build
+
+FROM alpine:3.19
+
+COPY --from=builder /app/reverse-http /reverse-http
+
+USER 65532:65532
+ENTRYPOINT ["/reverse-http"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0cd2355
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,117 @@
+.DEFAULT_GOAL := help
+
+.PHONY: clean build fmt test
+
+ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
+
+BUILD_FLAGS ?=
+VERSION = "0.0.1"
+BRANCH = $(shell git rev-parse --abbrev-ref HEAD)
+REVISION = $(shell git describe --tags --always --dirty)
+BUILD_DATE = $(shell date +'%Y.%m.%d-%H:%M:%S')
+LDFLAGS ?= -w -s
+BINARY = reverse-http
+
+TEST_AGENT_ID = 4711
+TEST_AUTH = ha-tls
+TEST_STORE_TYPE = none
+
+default: help
+
+.PHONY: help
+help:
+ @grep -E '^[a-zA-Z%_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+
+build: ## Build executable
+ @CGO_ENABLED=0 GO111MODULE=on go build -mod=vendor -o $(BINARY) $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" .
+
+test: ## Test
+ @GO111MODULE=on go test -count=1 -mod=vendor -v ./...
+
+fmt: ## Go format
+ go fmt ./...
+
+vet: ## Go vet
+ go vet ./...
+
+clean: ## Clean
+ @rm -rf $(BINARY)
+
+lint: ## Lint
+ @golangci-lint run
+
+.PHONY: deps
+deps: ## Get dependencies
+ GO111MODULE=on go get ./...
+
+.PHONY: vendor
+vendor: ## Go vendor
+ GO111MODULE=on go mod vendor
+
+.PHONY: tidy
+tidy: ## Go tidy
+ GO111MODULE=on go mod tidy
+
+##### Testing
+
+docker-compose.build:
+ docker-compose -f $(ROOT_DIR)/docker-compose.${TEST_AUTH}.yml build
+
+docker-compose.up:
+ docker-compose -f $(ROOT_DIR)/docker-compose.${TEST_AUTH}.yml up --remove-orphans
+
+docker-compose.down:
+ docker-compose -f $(ROOT_DIR)/docker-compose.${TEST_AUTH}.yml down --remove-orphans
+
+docker-compose.run: docker-compose.build docker-compose.up
+
+start-proxy: build
+ @export QUIC_GO_LOG_LEVEL_=debug && ${ROOT_DIR}/reverse-http proxy --store.type="${TEST_STORE_TYPE}" --agent-server.listen-address=":4242" \
+ --http-proxy.listen-address=":3128" --agent-server.tls.file.key=tests/cfssl/certs/proxy-key.pem --agent-server.tls.file.cert=tests/cfssl/certs/proxy.pem
+
+start-proxy-tls: build
+ @export QUIC_GO_LOG_LEVEL_=debug && ${ROOT_DIR}/reverse-http proxy --store.type="${TEST_STORE_TYPE}" --agent-server.listen-address=":4242" \
+ --http-proxy.listen-address=":3128" --agent-server.tls.file.key=tests/cfssl/certs/proxy-key.pem --agent-server.tls.file.cert=tests/cfssl/certs/proxy.pem \
+ --http-proxy.tls.enable --http-proxy.tls.file.key=tests/cfssl/certs/proxy-key.pem --http-proxy.tls.file.cert=tests/cfssl/certs/proxy.pem
+
+start-proxy2: build
+ @${ROOT_DIR}/reverse-http proxy --store.type="memcached" --agent-server.listen-address=":4243" --http-proxy.listen-address=":3127" \
+ --store.http-proxy-address="localhost:3127" --agent-server.tls.file.key=tests/cfssl/certs/proxy-key.pem --agent-server.tls.file.cert=tests/cfssl/certs/proxy.pem
+
+start-agent: build
+ @export QUIC_GO_LOG_LEVEL_=debug && ${ROOT_DIR}/reverse-http agent --auth.noauth.agent-id="4711" --agent-client.server-address="localhost:4242" --agent-client.tls.file.root-ca=tests/cfssl/certs/ca.pem
+
+start-agent2: build
+ @${ROOT_DIR}/reverse-http agent --auth.noauth.agent-id="4712" --agent-client.server-address="localhost:4243" --agent-client.tls.insecure-skip-verify
+
+start-lb: build
+ @${ROOT_DIR}/reverse-http lb --http-proxy.listen-address=":3129" --store.type="${TEST_STORE_TYPE}"
+
+curl-proxy:
+ curl -x "http://${TEST_AGENT_ID}:noauth@localhost:3128" https://httpbin.org/ip
+
+curl-proxy-tls:
+ curl -x "https://${TEST_AGENT_ID}:noauth@localhost:3128" https://httpbin.org/ip --proxy-cacert tests/cfssl/certs/ca.pem
+
+curl-lb:
+ curl -x "http://${TEST_AGENT_ID}:noauth@localhost:3129" https://httpbin.org/ip
+
+jwt-keys: build
+ @${ROOT_DIR}/reverse-http auth key private --out=${ROOT_DIR}/tests/jwt/auth-key-private.pem
+ @${ROOT_DIR}/reverse-http auth key public --out=${ROOT_DIR}/tests/jwt/auth-key-public.pem --in=${ROOT_DIR}/tests/jwt/auth-key-private.pem
+ @${ROOT_DIR}/reverse-http auth jwt token --duration=87600h --agent-id="4711" --role "client" --out ${ROOT_DIR}/tests/jwt/auth-client-jwt-4711.b64 --in=${ROOT_DIR}/tests/jwt/auth-key-private.pem
+ @${ROOT_DIR}/reverse-http auth jwt token --duration=87600h --agent-id="4711" --role "agent" --out ${ROOT_DIR}/tests/jwt/auth-agent-jwt-4711.b64 --in=${ROOT_DIR}/tests/jwt/auth-key-private.pem
+ @${ROOT_DIR}/reverse-http auth jwt token --duration=87600h --agent-id="4712" --role "client" --out ${ROOT_DIR}/tests/jwt/auth-client-jwt-4712.b64 --in=${ROOT_DIR}/tests/jwt/auth-key-private.pem
+ @${ROOT_DIR}/reverse-http auth jwt token --duration=87600h --agent-id="4712" --role "agent" --out ${ROOT_DIR}/tests/jwt/auth-agent-jwt-4712.b64 --in=${ROOT_DIR}/tests/jwt/auth-key-private.pem
+
+start-proxy-jwt: build
+ @${ROOT_DIR}/reverse-http proxy --auth.type="jwt" --auth.jwt.public-key=tests/jwt/auth-key-public.pem \
+ --agent-server.listen-address=":4242" --http-proxy.listen-address=":3128" --agent-server.tls.file.key=tests/cfssl/certs/proxy-key.pem --agent-server.tls.file.cert=tests/cfssl/certs/proxy.pem
+
+start-agent-jwt: build
+ @$(eval JWT_TOKEN=$(shell cat tests/jwt/auth-agent-jwt-${TEST_AGENT_ID}.b64))
+ @${ROOT_DIR}/reverse-http agent --auth.type="jwt" --agent-client.server-address="localhost:4242" --agent-client.tls.file.root-ca=tests/cfssl/certs/ca.pem --auth.jwt.token="file:tests/jwt/auth-agent-jwt-${TEST_AGENT_ID}.b64"
+
+curl-proxy-jwt:
+ @$(eval JWT_TOKEN=$(shell cat tests/jwt/auth-client-jwt-${TEST_AGENT_ID}.b64))
+ curl -x "http://${TEST_AGENT_ID}:${JWT_TOKEN}@localhost:3128" https://httpbin.org/ip
diff --git a/README.md b/README.md
index c044e79..b51ac71 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,107 @@
# reverse-http
-Reverse HTTP proxy over QUIC protocol
+
+Reverse HTTP proxy over QUIC protocol ([RFC 9000](https://datatracker.ietf.org/doc/html/rfc9000)).
+
+## Architecture
+
+### Standalone
+
+
+
+* Agent connection process
+ * An agent initiates a connection to the proxy server utilizing the `QUIC` protocol.
+ * The connection between the agent and the proxy is persistent
+ * Upon connection, the proxy server performs an agent authentication
+ * The proxy keeps track of agents' connections
+ * Each agent is uniquely identified by an `agentID`
+ * Multiple agents can simultaneously connect to the proxy.
+ * Only one connection per `agentID` is allowed.
+
+* Client connection process
+ * Clients establish a connection with the HTTP proxy by issuing an `HTTP CONNECT` request. This standard method allows the client to specify the desired destination.
+ * During the connection process, the proxy authenticates the connecting client using basic `Proxy-Authorization`, where the `username` is utilized to specify the `agentID` that the client wishes to connect to.
+ * Once authenticated, the proxy server locates the corresponding agent's `QUIC` connection that is already being tracked.
+ * Proxy opens a new `QUIC` stream to the agent and sends all subsequent data through it
+ * The agent proceeds with the `CONNECT` procedure by establishing a new TCP connection to the requested destination.
+
+### HA setup
+
+
+
+* Agent connection process
+ * An agent initiates a connection to the UDP load balancer, which in turn establishes a connection with one of the proxy servers
+ * Upon establishing a connection, the proxy server records an entry in `memcached` for an agentID along with its own HTTP proxy address.
+* Client connection process
+ * Clients connect to the TCP load balancer, which then establishes a connection with one of the LB servers.
+ * Upon connection, the LB server retrieves the HTTP proxy address and an agentID from Memcached.
+ * The LB server then sends an `HTTP CONNECT` request to the proxy.
+
+## Build
+### build binary
+
+ make clean build
+
+## Quick requirements
+
+https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes
+
+```bash
+sudo bash -c 'echo net.core.rmem_max=2500000 >> /etc/sysctl.conf'
+sudo bash -c 'echo net.core.wmem_max=2500000 >> /etc/sysctl.conf'
+sudo sysctl -p
+```
+
+
+
+## Local test standalone
+
+### no auth
+
+```bash
+make start-proxy
+make start-agent
+curl -x "http://4711:noauth@localhost:3128" https://httpbin.org/ip
+```
+
+### jwt auth
+
+```bash
+make start-proxy-jwt
+make start-agent-jwt
+make curl-proxy-jwt
+```
+
+## Local test docker-compose
+
+```bash
+make TEST_AUTH=noauth docker-compose.run
+make TEST_AGENT_ID=4711 curl-proxy
+make TEST_AGENT_ID=4712 curl-proxy
+```
+
+## Whitelisting patterns
+
+```
+localhost
+localhost:80
+localhost:1000-2000
+*.zone
+*.zone:80
+*.zone:1000-2000
+127.0.0.1
+127.0.0.1:80
+127.0.0.1:1000-2000
+10.0.0.1/8
+10.0.0.1/8:80
+10.0.0.1/8:1000-2000
+1000::/16
+1000::/16:80
+1000::/16:1000-2000
+[2001:db8::1]/64
+[2001:db8::1]/64:80
+[2001:db8::1]/64:1000-2000
+2001:db8::1
+[2001:db8::1]
+[2001:db8::1]:80
+[2001:db8::1]:1000-2000
+```
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 0000000..304c058
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/alecthomas/kong"
+ kongyaml "github.com/alecthomas/kong-yaml"
+
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/agent"
+ "github.com/grepplabs/reverse-http/pkg/jwtutil"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/proxy"
+)
+
+type CLI struct {
+ LogConfig logger.LogConfig `embed:"" prefix:"log."`
+ Agent config.AgentCmd `name:"agent" cmd:"" help:"Start agent."`
+ Proxy config.ProxyCmd `name:"proxy" cmd:"" help:"Start proxy server."`
+ LoadBalancer config.LoadBalancerCmd `name:"lb" cmd:"" help:"Start load balancer."`
+ Auth config.AuthCmd `name:"auth" cmd:"" help:"auth tools."`
+}
+
+func Execute() {
+ var cli CLI
+ ctx := kong.Parse(&cli,
+ kong.Name(os.Args[0]),
+ kong.Description("HTTP reverse tunnel"),
+ kong.Configuration(kong.JSON, "/etc/reverse-http/config.json", "~/.reverse-http.json"),
+ kong.Configuration(kongyaml.Loader, "/etc/reverse-http/config.yaml", "~/.reverse-http.yaml"),
+ kong.UsageOnError(),
+ )
+ logger.InitInstance(cli.LogConfig)
+ switch ctx.Command() {
+ case "agent":
+ err := runAgent(&cli.Agent)
+ ctx.FatalIfErrorf(err)
+ case "proxy":
+ err := runProxy(&cli.Proxy)
+ ctx.FatalIfErrorf(err)
+ case "lb":
+ err := runLoadBalancer(&cli.LoadBalancer)
+ ctx.FatalIfErrorf(err)
+ case "auth key private":
+ err := runAuthKeyPrivate(&cli.Auth.KeyCmd.PrivateCmd)
+ ctx.FatalIfErrorf(err)
+ case "auth key public":
+ err := runAuthKeyPublic(&cli.Auth.KeyCmd.PublicCmd)
+ ctx.FatalIfErrorf(err)
+ case "auth jwt token":
+ err := runAuthJwtToken(&cli.Auth.JwtCmd.TokenCmd)
+ ctx.FatalIfErrorf(err)
+ default:
+ fmt.Println(ctx.Command())
+ os.Exit(1)
+ }
+}
+
+func runAgent(conf *config.AgentCmd) error {
+ return agent.RunAgentClient(conf)
+}
+
+func runProxy(conf *config.ProxyCmd) error {
+ proxy.RunProxyServer(conf)
+ return nil
+}
+
+func runLoadBalancer(conf *config.LoadBalancerCmd) error {
+ proxy.RunLoadBalancerServer(conf)
+ return nil
+}
+
+func runAuthKeyPrivate(conf *config.AuthKeyPrivateCmd) error {
+ return jwtutil.GeneratePrivateKey(conf)
+}
+
+func runAuthKeyPublic(conf *config.AuthKeyPublicCmd) error {
+ return jwtutil.GeneratePublicKey(conf)
+}
+
+func runAuthJwtToken(conf *config.AuthJwtTokenCmd) error {
+ return jwtutil.GenerateJWTToken(conf)
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..c98a247
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,144 @@
+package config
+
+import (
+ "time"
+
+ certconfig "github.com/grepplabs/cert-source/config"
+)
+
+const (
+ ReverseHttpProto = "reverse-http-proto"
+ DefaultKeepAlivePeriod = 10 * time.Second
+)
+
+const (
+ AuthNoAuth = "noauth"
+ AuthJWT = "jwt"
+)
+
+const (
+ StoreNone = "none"
+ StoreMemcached = "memcached"
+)
+
+const (
+ RoleClient string = "client"
+ RoleAgent string = "agent"
+)
+
+const (
+ TokenFromFilePrefix = "file:"
+)
+
+type ProxyCmd struct {
+ AgentServer struct {
+ ListenAddress string `default:":4242" help:"Agent server listen address."`
+ TLS TLSServerConfig `embed:"" prefix:"tls."`
+ Agent struct {
+ DialTimeout time.Duration `default:"10s" help:"Agent dial timeout."`
+ } `embed:"" prefix:"agent."`
+ } `embed:"" prefix:"agent-server."`
+ HttpProxyServer struct {
+ ListenAddress string `default:":3128" help:"HTTP proxy listen address."`
+ TLS certconfig.TLSServerConfig `embed:"" prefix:"tls."`
+ HostWhitelist []string `placeholder:"PATTERNS" help:"List of whitelisted hosts. Empty list allows all destinations."`
+ } `embed:"" prefix:"http-proxy."`
+ Auth AuthVerifier `embed:"" prefix:"auth."`
+ Store struct {
+ Type string `enum:"none,memcached" default:"none" help:"Agent access store. One of: [none, memcached]"`
+ HttpProxyAddress string `help:"Host and port for HTTP proxy access."`
+ Memcached MemcachedConfig `embed:"" prefix:"memcached."`
+ } `embed:"" prefix:"store."`
+}
+
+type LoadBalancerCmd struct {
+ HttpProxyServer struct {
+ ListenAddress string `default:":3129" help:"HTTP proxy listen address."`
+ TLS certconfig.TLSServerConfig `embed:"" prefix:"tls."`
+ HostWhitelist []string `placeholder:"PATTERNS" help:"List of whitelisted hosts. Empty list allows all destinations."`
+ } `embed:"" prefix:"http-proxy."`
+ HttpConnector struct {
+ TLS certconfig.TLSClientConfig `embed:"" prefix:"tls."`
+ } `embed:"" prefix:"http-connector."`
+ Auth AuthVerifier `embed:"" prefix:"auth."`
+ Store struct {
+ Type string `enum:"memcached" default:"memcached" help:"Agent access store. One of: [memcached]"`
+ Memcached MemcachedConfig `embed:"" prefix:"memcached."`
+ } `embed:"" prefix:"store."`
+}
+
+type AgentCmd struct {
+ AgentClient struct {
+ ServerAddress string `default:"localhost:4242" help:"Address of the Agent server."`
+ HostWhitelist []string `placeholder:"PATTERNS" help:"List of whitelisted hosts. Empty list allows all destinations."`
+ TLS TLSClientConfig `embed:"" prefix:"tls."`
+ } `embed:"" prefix:"agent-client."`
+ Auth AgentAuth `embed:"" prefix:"auth."`
+}
+
+type AuthCmd struct {
+ KeyCmd AuthKeyCmd `name:"key" cmd:"" help:"Key generator."`
+ JwtCmd AuthJwtCmd `name:"jwt" cmd:"" help:"JWT tools."`
+}
+
+type AuthKeyCmd struct {
+ PrivateCmd AuthKeyPrivateCmd `name:"private" cmd:"" help:"Generate private key."`
+ PublicCmd AuthKeyPublicCmd `name:"public" cmd:"" help:"Generate public key."`
+}
+
+type AuthKeyPrivateCmd struct {
+ Algo string `enum:"RS256,ES256" default:"ES256" help:"Private key type. One of: [RS256, ES256]"`
+ OutputFile string `name:"out" short:"o" default:"auth-key-private.pem" placeholder:"FILE" help:"Path to the generated private key file. Use '-' for stdout."`
+}
+
+type AuthKeyPublicCmd struct {
+ InputFile string `name:"in" short:"i" default:"auth-key-private.pem" placeholder:"FILE" help:"Path to the private key file. Use '-' for stdin."`
+ OutputFile string `name:"out" short:"o" default:"auth-key-public.pem" placeholder:"FILE" help:"Path to the generated public key file. Use '-' for stdout."`
+}
+
+type AuthJwtCmd struct {
+ TokenCmd AuthJwtTokenCmd `name:"token" cmd:"" help:"Generate jwt token."`
+}
+
+type AuthJwtTokenCmd struct {
+ AgentID string `help:"Agent ID." required:""`
+ Role string `enum:"client,agent" default:"client" help:"Role. One of: [client, agent]"`
+ Audience string `help:"Audience."`
+ Duration time.Duration `default:"24h" help:"Token duration."`
+ InputFile string `name:"in" short:"i" default:"auth-key-private.pem" placeholder:"FILE" help:"Path to the private key file. Use '-' for stdin."`
+ OutputFile string `name:"out" short:"o" default:"jwt.b64" placeholder:"FILE" help:"Path to the generated jwt token. Use '-' for stdout."`
+}
+
+type TLSServerConfig struct {
+ Refresh time.Duration `default:"0s" help:"Interval for refreshing server TLS certificates."`
+ File certconfig.TLSServerFiles `embed:"" prefix:"file."`
+}
+
+type TLSClientConfig struct {
+ Refresh time.Duration `default:"0s" help:"Interval for refreshing client TLS certificates."`
+ InsecureSkipVerify bool `help:"Skip TLS verification on client side."`
+ File certconfig.TLSClientFiles `embed:"" prefix:"file."`
+}
+
+type AgentAuth struct {
+ Type string `enum:"noauth,jwt" default:"noauth" help:"Authentication type. One of: [noauth, jwt]"`
+ NoAuth struct {
+ AgentID string `help:"Agent ID."`
+ } `embed:"" prefix:"noauth."`
+ JWTAuth struct {
+ Token string `placeholder:"SOURCE" help:"JWT token or 'file:'"`
+ } `embed:"" prefix:"jwt."`
+}
+
+type AuthVerifier struct {
+ Type string `enum:"noauth,jwt" default:"noauth" help:"Authentication verifier. One of: [noauth, jwt]"`
+ JWTVerifier struct {
+ PublicKey string `placeholder:"FILE" default:"auth-key-public.pem" help:"Path to the public key."`
+ Audience string `help:"JWT audience."`
+ } `embed:"" prefix:"jwt."`
+}
+
+type MemcachedConfig struct {
+ Address string `default:"localhost:11211" help:"Memcached server address."`
+ Timeout time.Duration `default:"1s" help:"Dial timeout."`
+}
diff --git a/docker-compose.ha-tls.yml b/docker-compose.ha-tls.yml
new file mode 100644
index 0000000..1ebc2f4
--- /dev/null
+++ b/docker-compose.ha-tls.yml
@@ -0,0 +1,135 @@
+---
+version: '3'
+services:
+ memcached-server:
+ image: memcached:1.6.23-alpine3.19
+ networks:
+ - reverse-http-net
+ ports:
+ - 11211:11211
+ restart: unless-stopped
+ proxy-lb:
+ image: nginx:1.25-alpine
+ volumes:
+ - ./tests/ha/nginx-proxy.conf:/etc/nginx/nginx.conf:ro
+ networks:
+ - reverse-http-net
+ proxy-1:
+ hostname: proxy-1
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - proxy
+ - '--agent-server.listen-address=:4242'
+ - '--agent-server.tls.file.key=/certs/proxy-key.pem'
+ - '--agent-server.tls.file.cert=/certs/proxy.pem'
+ - '--agent-server.tls.refresh=1s'
+ - '--http-proxy.listen-address=:3128'
+ - '--http-proxy.tls.enable'
+ - '--http-proxy.tls.file.key=/certs/proxy-key.pem'
+ - '--http-proxy.tls.file.cert=/certs/proxy.pem'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.http-proxy-address=proxy-1:3128'
+ - '--store.memcached.address=memcached-server:11211'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ proxy-2:
+ hostname: proxy-2
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - proxy
+ - '--agent-server.listen-address=:4242'
+ - '--agent-server.tls.file.key=/certs/proxy-key.pem'
+ - '--agent-server.tls.file.cert=/certs/proxy.pem'
+ - '--agent-server.tls.refresh=1s'
+ - '--http-proxy.listen-address=:3128'
+ - '--http-proxy.tls.enable'
+ - '--http-proxy.tls.file.key=/certs/proxy-key.pem'
+ - '--http-proxy.tls.file.cert=/certs/proxy.pem'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.http-proxy-address=proxy-2:3128'
+ - '--store.memcached.address=memcached-server:11211'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ agent-4711:
+ hostname: agent-4711
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy-lb:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.noauth.agent-id=4711'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ agent-4712:
+ hostname: agent-4712
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy-lb:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.noauth.agent-id=4712'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ lb-1:
+ hostname: lb-1
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - lb
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.memcached.address=memcached-server:11211'
+ - '--http-connector.tls.enable'
+ - '--http-connector.tls.insecure-skip-verify'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ lb-2:
+ hostname: lb-2
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - lb
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.memcached.address=memcached-server:11211'
+ - '--http-connector.tls.enable'
+ - '--http-connector.tls.insecure-skip-verify'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ http-proxy:
+ image: nginx:1.25-alpine
+ volumes:
+ - ./tests/ha/nginx-client.conf:/etc/nginx/nginx.conf:ro
+ ports:
+ - "3128:3128/tcp"
+ networks:
+ - reverse-http-net
+
+networks:
+ reverse-http-net:
diff --git a/docker-compose.ha.yml b/docker-compose.ha.yml
new file mode 100644
index 0000000..11191cd
--- /dev/null
+++ b/docker-compose.ha.yml
@@ -0,0 +1,121 @@
+---
+version: '3'
+services:
+ memcached-server:
+ image: memcached:1.6.23-alpine3.19
+ networks:
+ - reverse-http-net
+ ports:
+ - 11211:11211
+ restart: unless-stopped
+ proxy-lb:
+ image: nginx:1.25-alpine
+ volumes:
+ - ./tests/ha/nginx-proxy.conf:/etc/nginx/nginx.conf:ro
+ networks:
+ - reverse-http-net
+ proxy-1:
+ hostname: proxy-1
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - proxy
+ - '--agent-server.listen-address=:4242'
+ - '--agent-server.tls.file.key=/certs/proxy-key.pem'
+ - '--agent-server.tls.file.cert=/certs/proxy.pem'
+ - '--agent-server.tls.refresh=1s'
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.http-proxy-address=proxy-1:3128'
+ - '--store.memcached.address=memcached-server:11211'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ proxy-2:
+ hostname: proxy-2
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - proxy
+ - '--agent-server.listen-address=:4242'
+ - '--agent-server.tls.file.key=/certs/proxy-key.pem'
+ - '--agent-server.tls.file.cert=/certs/proxy.pem'
+ - '--agent-server.tls.refresh=1s'
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.http-proxy-address=proxy-2:3128'
+ - '--store.memcached.address=memcached-server:11211'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ agent-4711:
+ hostname: agent-4711
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy-lb:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.noauth.agent-id=4711'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ agent-4712:
+ hostname: agent-4712
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy-lb:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.noauth.agent-id=4712'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ networks:
+ - reverse-http-net
+ lb-1:
+ hostname: lb-1
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - lb
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.memcached.address=memcached-server:11211'
+ networks:
+ - reverse-http-net
+ lb-2:
+ hostname: lb-2
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - lb
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ - '--store.type=memcached'
+ - '--store.memcached.address=memcached-server:11211'
+ networks:
+ - reverse-http-net
+ http-proxy:
+ image: nginx:1.25-alpine
+ volumes:
+ - ./tests/ha/nginx-client.conf:/etc/nginx/nginx.conf:ro
+ ports:
+ - "3128:3128/tcp"
+ networks:
+ - reverse-http-net
+
+networks:
+ reverse-http-net:
diff --git a/docker-compose.jwt.yml b/docker-compose.jwt.yml
new file mode 100644
index 0000000..c2ee179
--- /dev/null
+++ b/docker-compose.jwt.yml
@@ -0,0 +1,49 @@
+---
+version: '3'
+services:
+ proxy:
+ hostname: proxy
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - proxy
+ - '--agent-server.listen-address=:4242'
+ - '--agent-server.tls.file.key=/certs/proxy-key.pem'
+ - '--agent-server.tls.file.cert=/certs/proxy.pem'
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=jwt'
+ - '--auth.jwt.public-key=/jwt/auth-key-public.pem'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ - ./tests/jwt:/jwt:ro
+ ports:
+ - "3128:3128/tcp"
+ agent-4711:
+ hostname: agent-4711
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.type=jwt'
+ - '--auth.jwt.token=file:/jwt/auth-agent-jwt-4711.b64'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ - ./tests/jwt:/jwt:ro
+ agent-4712:
+ hostname: agent-4712
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.type=jwt'
+ - '--auth.jwt.token=file:/jwt/auth-agent-jwt-4712.b64'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ - ./tests/jwt:/jwt:ro
diff --git a/docker-compose.noauth.yml b/docker-compose.noauth.yml
new file mode 100644
index 0000000..e57f7f7
--- /dev/null
+++ b/docker-compose.noauth.yml
@@ -0,0 +1,44 @@
+---
+version: '3'
+services:
+ proxy:
+ hostname: proxy
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - proxy
+ - '--agent-server.listen-address=:4242'
+ - '--agent-server.tls.file.key=/certs/proxy-key.pem'
+ - '--agent-server.tls.file.cert=/certs/proxy.pem'
+ - '--agent-server.tls.refresh=1s'
+ - '--http-proxy.listen-address=:3128'
+ - '--auth.type=noauth'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ ports:
+ - "3128:3128/tcp"
+ agent-4711:
+ hostname: agent-4711
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.noauth.agent-id=4711'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
+ agent-4712:
+ hostname: agent-4712
+ build:
+ context: .
+ dockerfile: Dockerfile
+ command:
+ - agent
+ - '--agent-client.server-address=proxy:4242'
+ - '--agent-client.tls.file.root-ca=/certs/ca.pem'
+ - '--auth.noauth.agent-id=4712'
+ volumes:
+ - ./tests/cfssl/certs:/certs:ro
diff --git a/docs/reverse-http-arch.svg b/docs/reverse-http-arch.svg
new file mode 100644
index 0000000..81b9c99
--- /dev/null
+++ b/docs/reverse-http-arch.svg
@@ -0,0 +1,3 @@
+
+
+Quic Server HTTP Proxy... Quic Client HTTP Proxy... Client HTTP Conne... Agent 1 Proxy UDP Quic Client HTTP Proxy... Agent N UDP Text is not SVG - cannot display
\ No newline at end of file
diff --git a/docs/reverse-http-ha.svg b/docs/reverse-http-ha.svg
new file mode 100644
index 0000000..81ce686
--- /dev/null
+++ b/docs/reverse-http-ha.svg
@@ -0,0 +1,3 @@
+
+
+reverse-http... Client HTTP Conne... reverse-http... quic UDP reverse-http... Memcached UDP... TCP... reverse-http... reverse-http... HTTP Conne... write the proxy address where an agent is connected
write the proxy address... locate agent proxy UDP Text is not SVG - cannot display
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b86c33c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,36 @@
+module github.com/grepplabs/reverse-http
+
+go 1.21
+
+require (
+ github.com/alecthomas/kong v0.8.1
+ github.com/alecthomas/kong-yaml v0.2.0
+ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
+ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874
+ github.com/golang-jwt/jwt/v5 v5.2.0
+ github.com/google/uuid v1.6.0
+ github.com/grepplabs/cert-source v0.0.3
+ github.com/oklog/run v1.1.0
+ github.com/quic-go/quic-go v0.41.0
+ github.com/stretchr/testify v1.8.4
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
+ github.com/google/pprof v0.0.0-20240125082051-42cd04596328 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
+ github.com/onsi/ginkgo/v2 v2.15.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rogpeppe/go-internal v1.10.0 // indirect
+ go.uber.org/mock v0.4.0 // indirect
+ golang.org/x/crypto v0.18.0 // indirect
+ golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
+ golang.org/x/mod v0.14.0 // indirect
+ golang.org/x/net v0.20.0 // indirect
+ golang.org/x/sys v0.16.0 // indirect
+ golang.org/x/tools v0.17.0 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..86b75e4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,87 @@
+github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
+github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
+github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY=
+github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
+github.com/alecthomas/kong-yaml v0.2.0 h1:iiVVqVttmOsHKawlaW/TljPsjaEv1O4ODx6dloSA58Y=
+github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfRP03Q1StN69/g=
+github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
+github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
+github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
+github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
+github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20240125082051-42cd04596328 h1:oI+lCI2DY1BsRrdzMJBhIMxBBdlZJl31YNQC11EiyvA=
+github.com/google/pprof v0.0.0-20240125082051-42cd04596328/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
+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/grepplabs/cert-source v0.0.3 h1:R6MQWUHca5jjtKs3Hwv1fel1iZFek3N/GaCd9dDOHgU=
+github.com/grepplabs/cert-source v0.0.3/go.mod h1:jZoEGDOnQ2bVuvoeexW058PGQFMKqNHij5lJgVu3kYs=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY=
+github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM=
+github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
+github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/quic-go v0.41.0 h1:aD8MmHfgqTURWNJy48IYFg2OnxwHT3JL7ahGs73lb4k=
+github.com/quic-go/quic-go v0.41.0/go.mod h1:qCkNjqczPEvgsOnxZ0eCD14lv+B2LHlFAB++CNOh9hA=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
+go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
+golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
+golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
+golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
+golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
+golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..bf3931b
--- /dev/null
+++ b/main.go
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "github.com/grepplabs/reverse-http/cmd"
+)
+
+func main() {
+ cmd.Execute()
+}
diff --git a/pkg/agent/auth.go b/pkg/agent/auth.go
new file mode 100644
index 0000000..c454b38
--- /dev/null
+++ b/pkg/agent/auth.go
@@ -0,0 +1,23 @@
+package agent
+
+import (
+ "context"
+ "time"
+
+ "github.com/quic-go/quic-go"
+)
+
+const defaultTimeout = 3 * time.Second
+
+type Authenticator interface {
+ Authenticate(ctx context.Context, conn quic.Connection) error
+}
+
+type Attributes struct {
+ AgentID string
+ Role string
+}
+
+type Verifier interface {
+ Verify(ctx context.Context, conn quic.Connection) (*Attributes, error)
+}
diff --git a/pkg/agent/auth_jwt.go b/pkg/agent/auth_jwt.go
new file mode 100644
index 0000000..e89ee64
--- /dev/null
+++ b/pkg/agent/auth_jwt.go
@@ -0,0 +1,67 @@
+package agent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/jwtutil"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/quic-go/quic-go"
+)
+
+type JWTAuthenticator struct {
+ authFlow *authFlow
+ token string
+}
+
+func NewJWTAuthenticator(token string) (Authenticator, error) {
+ if token == "" {
+ return nil, errors.New("jwt auth: empty token")
+ }
+ return &JWTAuthenticator{
+ token: token,
+ authFlow: &authFlow{
+ timeout: defaultTimeout,
+ logger: logger.GetInstance(),
+ },
+ }, nil
+}
+
+func (r *JWTAuthenticator) Authenticate(ctx context.Context, conn quic.Connection) error {
+ return r.authFlow.authenticate(ctx, conn, r.token)
+}
+
+type JWTVerifier struct {
+ authFlow *authFlow
+ tokenVerifier jwtutil.TokenVerifier
+}
+
+func NewJWTVerifier(tokenVerifier jwtutil.TokenVerifier) Verifier {
+ return &JWTVerifier{
+ authFlow: &authFlow{
+ timeout: defaultTimeout,
+ logger: logger.GetInstance(),
+ },
+ tokenVerifier: tokenVerifier,
+ }
+}
+
+func (r *JWTVerifier) Verify(ctx context.Context, conn quic.Connection) (*Attributes, error) {
+ return r.authFlow.verify(ctx, conn, r.verifyToken)
+}
+
+func (r *JWTVerifier) verifyToken(token string) (*Attributes, error) {
+ claims, err := r.tokenVerifier.VerifyToken(token)
+ if err != nil {
+ return nil, err
+ }
+ if claims.Role != config.RoleAgent {
+ return nil, fmt.Errorf("role mismatch: role %s vs claim %s", config.RoleAgent, claims.Role)
+ }
+ return &Attributes{
+ AgentID: claims.AgentID,
+ Role: claims.Role,
+ }, nil
+}
diff --git a/pkg/agent/auth_noauth.go b/pkg/agent/auth_noauth.go
new file mode 100644
index 0000000..5c66594
--- /dev/null
+++ b/pkg/agent/auth_noauth.go
@@ -0,0 +1,49 @@
+package agent
+
+import (
+ "context"
+ "errors"
+
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/quic-go/quic-go"
+)
+
+type NoAuthAuthenticator struct {
+ authFlow *authFlow
+ agentID string
+}
+
+func NewNoAuthAuthenticator(agentID string) (Authenticator, error) {
+ if agentID == "" {
+ return nil, errors.New("noauth: empty agent-id")
+ }
+ return &NoAuthAuthenticator{
+ agentID: agentID,
+ authFlow: &authFlow{timeout: defaultTimeout},
+ }, nil
+}
+
+func (r *NoAuthAuthenticator) Authenticate(ctx context.Context, conn quic.Connection) error {
+ return r.authFlow.authenticate(ctx, conn, r.agentID)
+}
+
+type NoAuthVerifier struct {
+ authFlow *authFlow
+}
+
+func NewNoAuthVerifier() Verifier {
+ return &NoAuthVerifier{
+ authFlow: &authFlow{timeout: defaultTimeout},
+ }
+}
+
+func (r *NoAuthVerifier) Verify(ctx context.Context, conn quic.Connection) (*Attributes, error) {
+ return r.authFlow.verify(ctx, conn, r.verifyToken)
+}
+
+func (r *NoAuthVerifier) verifyToken(agentID string) (*Attributes, error) {
+ return &Attributes{
+ AgentID: agentID,
+ Role: config.RoleAgent,
+ }, nil
+}
diff --git a/pkg/agent/authflow.go b/pkg/agent/authflow.go
new file mode 100644
index 0000000..b44779f
--- /dev/null
+++ b/pkg/agent/authflow.go
@@ -0,0 +1,103 @@
+package agent
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/quic-go/quic-go"
+)
+
+const MaxAuthMessageLength = 1024 * 1024
+
+type authFlow struct {
+ timeout time.Duration
+ logger *logger.Logger
+}
+
+func (r *authFlow) authenticate(ctx context.Context, conn quic.Connection, token string) error {
+ deadline := time.Now().Add(r.timeout)
+ ctx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+
+ stream, err := conn.OpenStreamSync(ctx)
+ if err != nil {
+ return fmt.Errorf("auth open failed: %v", err)
+ }
+ defer stream.Close()
+ _ = stream.SetDeadline(deadline)
+
+ err = writeString(stream, token)
+ if err != nil {
+ return fmt.Errorf("auth write failed: %v", err)
+ }
+ _, err = readString(stream)
+ if err != nil {
+ return fmt.Errorf("auth read failed: %v", err)
+ }
+ return nil
+}
+
+func (r *authFlow) verify(ctx context.Context, conn quic.Connection, verifier func(token string) (*Attributes, error)) (*Attributes, error) {
+ deadline := time.Now().Add(r.timeout)
+ ctx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+
+ stream, err := conn.AcceptStream(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("verify accept failed: %v", err)
+ }
+ defer stream.Close()
+
+ token, err := readString(stream)
+ if err != nil {
+ return nil, fmt.Errorf("verify read failed: %v", err)
+ }
+ attrs, err := verifier(token)
+ if err != nil {
+ return nil, err
+ }
+ err = writeString(stream, "authenticated")
+ if err != nil {
+ return nil, fmt.Errorf("verify write failed: %v", err)
+ }
+ return attrs, nil
+}
+
+func writeString(stream quic.Stream, message string) error {
+ bs := []byte(message)
+ length := uint32(len(bs))
+ if length > MaxAuthMessageLength {
+ return fmt.Errorf("write message too long: %d", length)
+ }
+ err := binary.Write(stream, binary.BigEndian, length)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(stream, bytes.NewReader(bs))
+ if err != nil {
+ return err
+ }
+ return err
+}
+
+func readString(stream quic.Stream) (string, error) {
+ var length uint32
+ err := binary.Read(stream, binary.BigEndian, &length)
+ if err != nil {
+ return "", err
+ }
+ if length > MaxAuthMessageLength {
+ return "", fmt.Errorf("read message too long: %d", length)
+ }
+ buf := make([]byte, length)
+ _, err = io.ReadFull(stream, buf)
+ if err != nil {
+ return "", err
+ }
+ return string(buf), nil
+}
diff --git a/pkg/agent/quic.go b/pkg/agent/quic.go
new file mode 100644
index 0000000..5e6a0d2
--- /dev/null
+++ b/pkg/agent/quic.go
@@ -0,0 +1,189 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "time"
+
+ tlsconfig "github.com/grepplabs/cert-source/config"
+ tlsclient "github.com/grepplabs/cert-source/tls/client"
+ tlsclientconfig "github.com/grepplabs/cert-source/tls/client/config"
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/gost"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/util"
+ "github.com/oklog/run"
+ "github.com/quic-go/quic-go"
+)
+
+func RunAgentClient(conf *config.AgentCmd) error {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "agent"})
+ group := new(run.Group)
+
+ util.AddQuitSignal(group)
+ addAgentClient(conf, group)
+
+ err := group.Run()
+ if err != nil {
+ log.Error("client exiting", slog.String("error", err.Error()))
+ } else {
+ log.Info("client exiting")
+ }
+ return nil
+}
+
+func addAgentClient(conf *config.AgentCmd, group *run.Group) {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "agent"})
+ ctx, cancel := context.WithCancel(context.Background())
+ group.Add(func() error {
+ log.Info("starting quick client")
+ authenticator, err := getAuthenticator(conf)
+ if err != nil {
+ return err
+ }
+ client, err := NewQuickClient(ctx, conf.AgentClient.ServerAddress, authenticator, log, conf.AgentClient.HostWhitelist, conf.AgentClient.TLS)
+ if err != nil {
+ return err
+ }
+ client.keepConnected()
+ return nil
+ }, func(error) {
+ cancel()
+ })
+}
+
+func getAuthenticator(conf *config.AgentCmd) (Authenticator, error) {
+ switch conf.Auth.Type {
+ case config.AuthNoAuth:
+ return NewNoAuthAuthenticator(conf.Auth.NoAuth.AgentID)
+ case config.AuthJWT:
+ token, err := getJWTToken(conf)
+ if err != nil {
+ return nil, fmt.Errorf("get jwt token failed: %v", err)
+ }
+ return NewJWTAuthenticator(token)
+ default:
+ return nil, fmt.Errorf("unsupported auth type: %s", conf.Auth.Type)
+ }
+}
+
+func getJWTToken(conf *config.AgentCmd) (string, error) {
+ token := conf.Auth.JWTAuth.Token
+
+ if strings.HasPrefix(token, config.TokenFromFilePrefix) {
+ filename := strings.TrimLeft(token, config.TokenFromFilePrefix)
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ return "", err
+ }
+ return string(content), nil
+ }
+ return token, nil
+}
+
+type QuickClient struct {
+ parent context.Context
+ address string
+ proxyHandler gost.Handler
+ authenticator Authenticator
+ logger *logger.Logger
+ tlsConfigFunc tlsclient.TLSClientConfigFunc
+}
+
+func NewQuickClient(parent context.Context, address string, authenticator Authenticator, logger *logger.Logger, whitelist []string, tlsClientConfig config.TLSClientConfig) (*QuickClient, error) {
+ tlsConfigFunc, err := tlsclientconfig.GetTLSClientConfigFunc(logger.Logger, &tlsconfig.TLSClientConfig{
+ Enable: true,
+ Refresh: tlsClientConfig.Refresh,
+ InsecureSkipVerify: tlsClientConfig.InsecureSkipVerify,
+ File: tlsClientConfig.File,
+ }, tlsclient.WithTLSClientNextProtos([]string{config.ReverseHttpProto}))
+ if err != nil {
+ return nil, err
+ }
+ return &QuickClient{
+ parent: parent,
+ address: address,
+ proxyHandler: httpProxyHandler(util.WhitelistFromStrings(whitelist)),
+ authenticator: authenticator,
+ logger: logger,
+ tlsConfigFunc: tlsConfigFunc,
+ }, nil
+}
+
+func (c *QuickClient) keepConnected() {
+ err := c.connectForHttpProxy()
+ if err != nil {
+ c.logger.Error("agent dial: " + err.Error())
+ }
+ ticker := time.NewTicker(1 * time.Second)
+ for {
+ select {
+ case <-c.parent.Done():
+ c.logger.Debug("context closed")
+ return
+ case <-ticker.C:
+ err = c.connectForHttpProxy()
+ if err != nil {
+ c.logger.Error("agent dial: " + err.Error())
+ }
+ }
+ }
+}
+
+func (c *QuickClient) connectForHttpProxy() error {
+ c.logger.Info("connecting to " + c.address)
+
+ tlsConf := c.tlsConfigFunc()
+ conn, err := quic.DialAddr(c.parent, c.address, tlsConf, &quic.Config{
+ KeepAlivePeriod: config.DefaultKeepAlivePeriod,
+ })
+ if err != nil {
+ return err
+ }
+ defer conn.CloseWithError(0, "client connection closed")
+
+ c.logger.Info("sending authenticate")
+ err = c.authenticator.Authenticate(c.parent, conn)
+ if err != nil {
+ return err
+ }
+ for {
+ c.logger.Info("waiting for clients")
+ stream, err := conn.AcceptStream(c.parent)
+ if err != nil {
+ return fmt.Errorf("stream accept failure: %v", err)
+ }
+ log := c.logger.With(slog.Int64("stream", int64(stream.StreamID())))
+ log.Info("stream accepted")
+
+ go func() {
+ defer func() {
+ _ = stream.Close()
+ log.Info("stream closed")
+ }()
+
+ err := c.proxyHandler.Handle(c.parent, &util.QuicConn{
+ Stream: stream,
+ LAddr: conn.LocalAddr(),
+ RAddr: conn.RemoteAddr(),
+ })
+ if err != nil {
+ log.Error("serve conn failure", slog.String("error", err.Error()))
+ }
+ }()
+ }
+}
+
+func httpProxyHandler(bypass *util.Whitelist) gost.Handler {
+ router := gost.NewRouter()
+ httpHandlerOpts := []gost.HandlerOption{
+ gost.WithHandlerRouter(router),
+ }
+ if bypass != nil {
+ httpHandlerOpts = append(httpHandlerOpts, gost.WithHandlerBypass(bypass))
+ }
+ return gost.NewHttpHandler(httpHandlerOpts...)
+}
diff --git a/pkg/gost/auth.go b/pkg/gost/auth.go
new file mode 100644
index 0000000..08a340c
--- /dev/null
+++ b/pkg/gost/auth.go
@@ -0,0 +1,11 @@
+package gost
+
+import "context"
+
+type authOptions struct{}
+
+type AuthOption func(opts *authOptions)
+
+type Authenticator interface {
+ Authenticate(ctx context.Context, user, password string, opts ...AuthOption) (id string, ok bool)
+}
diff --git a/pkg/gost/bypass.go b/pkg/gost/bypass.go
new file mode 100644
index 0000000..363f64c
--- /dev/null
+++ b/pkg/gost/bypass.go
@@ -0,0 +1,45 @@
+package gost
+
+import "context"
+
+type Bypass interface {
+ Contains(ctx context.Context, network, addr string, opts ...BypassOption) bool
+}
+
+type BypassOptions struct {
+ Host string
+ Path string
+}
+
+type BypassOption func(opts *BypassOptions)
+
+func WithBypassHost(host string) BypassOption {
+ return func(opts *BypassOptions) {
+ opts.Host = host
+ }
+}
+
+func WithBypassPath(path string) BypassOption {
+ return func(opts *BypassOptions) {
+ opts.Path = path
+ }
+}
+
+type bypassGroup struct {
+ bypasses []Bypass
+}
+
+func BypassGroup(bypasses ...Bypass) Bypass {
+ return &bypassGroup{
+ bypasses: bypasses,
+ }
+}
+
+func (p *bypassGroup) Contains(ctx context.Context, network, addr string, opts ...BypassOption) bool {
+ for _, bypass := range p.bypasses {
+ if bypass != nil && bypass.Contains(ctx, network, addr, opts...) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/gost/chain.go b/pkg/gost/chain.go
new file mode 100644
index 0000000..d757c47
--- /dev/null
+++ b/pkg/gost/chain.go
@@ -0,0 +1,64 @@
+package gost
+
+import (
+ "context"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type chainOptions struct {
+ logger *logger.Logger
+}
+
+type ChainOption func(*chainOptions)
+
+type Chain struct {
+ name string
+ nodes []*Node
+ logger *logger.Logger
+}
+
+func NewChain(name string, opts ...ChainOption) *Chain {
+ options := chainOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "chain"}),
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(&options)
+ }
+ }
+ return &Chain{
+ name: name,
+ logger: options.logger,
+ }
+}
+
+func (c *Chain) AddNode(node *Node) {
+ if node != nil {
+ c.nodes = append(c.nodes, node)
+ }
+}
+
+func (c *Chain) Name() string {
+ return c.name
+}
+
+func (c *Chain) Route(ctx context.Context, network, address string, opts ...ChainerOption) Route {
+ if c == nil || len(c.nodes) == 0 {
+ return nil
+ }
+
+ var options ChainerOptions
+ for _, opt := range opts {
+ opt(&options)
+ }
+ rt := newChainRoute(WithChainRouteChainerOption(c))
+ rt.addNode(c.nodes...)
+ return rt
+}
+
+func WithChainLogger(logger *logger.Logger) ChainOption {
+ return func(opts *chainOptions) {
+ opts.logger = logger
+ }
+}
diff --git a/pkg/gost/chain_route.go b/pkg/gost/chain_route.go
new file mode 100644
index 0000000..ec2d4d6
--- /dev/null
+++ b/pkg/gost/chain_route.go
@@ -0,0 +1,116 @@
+package gost
+
+import (
+ "context"
+ "net"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type chainRouteOptions struct {
+ chain Chainer
+}
+
+type ChainRouteOption func(*chainRouteOptions)
+
+type chainRoute struct {
+ nodes []*Node
+ options chainRouteOptions
+}
+
+func newChainRoute(opts ...ChainRouteOption) *chainRoute {
+ var options chainRouteOptions
+ for _, opt := range opts {
+ if opt != nil {
+ opt(&options)
+ }
+ }
+
+ return &chainRoute{
+ options: options,
+ }
+}
+
+func (r *chainRoute) addNode(nodes ...*Node) {
+ r.nodes = append(r.nodes, nodes...)
+}
+
+func (r *chainRoute) Dial(ctx context.Context, network, address string, opts ...RouteDialOption) (net.Conn, error) {
+ if len(r.Nodes()) == 0 {
+ return DefaultRoute.Dial(ctx, network, address, opts...)
+ }
+
+ var options routeDialOptions
+ for _, opt := range opts {
+ if opt != nil {
+ opt(&options)
+ }
+ }
+ conn, err := r.connect(ctx, options.logger)
+ if err != nil {
+ return nil, err
+ }
+
+ cc, err := r.getNode(len(r.Nodes())-1).Options().Transport.Connect(ctx, conn, network, address)
+ if err != nil {
+ if conn != nil {
+ conn.Close()
+ }
+ return nil, err
+ }
+ return cc, nil
+}
+
+func (r *chainRoute) connect(ctx context.Context, logger *logger.Logger) (conn net.Conn, err error) {
+ network := "ip"
+ node := r.nodes[0]
+
+ addr, err := Resolve(ctx, network, node.Addr, node.Options().Resolver, node.Options().HostMapper, logger)
+ if err != nil {
+ return
+ }
+
+ cc, err := node.Options().Transport.Dial(ctx, addr)
+ if err != nil {
+ return
+ }
+
+ cn := cc
+ preNode := node
+ for _, node := range r.nodes[1:] {
+ addr, err = Resolve(ctx, network, node.Addr, node.Options().Resolver, node.Options().HostMapper, logger)
+ if err != nil {
+ cn.Close()
+ return
+ }
+ cc, err = preNode.Options().Transport.Connect(ctx, cn, "tcp", addr)
+ if err != nil {
+ cn.Close()
+ return
+ }
+ cn = cc
+ preNode = node
+ }
+ conn = cn
+ return
+}
+
+func (r *chainRoute) getNode(index int) *Node {
+ if r == nil || len(r.Nodes()) == 0 || index < 0 || index >= len(r.Nodes()) {
+ return nil
+ }
+ return r.nodes[index]
+}
+
+func (r *chainRoute) Nodes() []*Node {
+ if r != nil {
+ return r.nodes
+ }
+ return nil
+}
+
+func WithChainRouteChainerOption(c Chainer) ChainRouteOption {
+ return func(o *chainRouteOptions) {
+ o.chain = c
+ }
+}
diff --git a/pkg/gost/chainer.go b/pkg/gost/chainer.go
new file mode 100644
index 0000000..d26b31a
--- /dev/null
+++ b/pkg/gost/chainer.go
@@ -0,0 +1,21 @@
+package gost
+
+import (
+ "context"
+)
+
+type ChainerOptions struct {
+ Host string
+}
+
+type ChainerOption func(opts *ChainerOptions)
+
+func ChainerHostOption(host string) ChainerOption {
+ return func(opts *ChainerOptions) {
+ opts.Host = host
+ }
+}
+
+type Chainer interface {
+ Route(ctx context.Context, network, address string, opts ...ChainerOption) Route
+}
diff --git a/pkg/gost/connector.go b/pkg/gost/connector.go
new file mode 100644
index 0000000..fc50ba2
--- /dev/null
+++ b/pkg/gost/connector.go
@@ -0,0 +1,157 @@
+package gost
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type Connector interface {
+ Connect(ctx context.Context, conn net.Conn, network, address string, opts ...ConnectOption) (net.Conn, error)
+}
+
+type ConnectorAuth interface {
+ Auth(ctx context.Context) *url.Userinfo
+}
+
+type connectorOptions struct {
+ auth ConnectorAuth
+ tlsConfig *tls.Config
+ logger *logger.Logger
+ connectTimeout time.Duration
+}
+
+type ConnectorOption func(opts *connectorOptions)
+
+type connectOptions struct {
+ netDialer *NetDialer
+}
+
+type ConnectOption func(opts *connectOptions)
+
+type httpConnector struct {
+ options connectorOptions
+}
+
+func NewHttpConnector(opts ...ConnectorOption) Connector {
+ options := connectorOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "connector"}),
+ }
+ for _, opt := range opts {
+ opt(&options)
+ }
+
+ return &httpConnector{
+ options: options,
+ }
+}
+
+func (c *httpConnector) Connect(ctx context.Context, conn net.Conn, network, address string, opts ...ConnectOption) (net.Conn, error) {
+ log := c.options.logger.WithFields(map[string]any{
+ "local": conn.LocalAddr().String(),
+ "remote": conn.RemoteAddr().String(),
+ "network": network,
+ "address": address,
+ })
+ log.Debugf("connect %s/%s", address, network)
+
+ req := &http.Request{
+ Method: http.MethodConnect,
+ URL: &url.URL{Host: address},
+ Host: address,
+ ProtoMajor: 1,
+ ProtoMinor: 1,
+ Header: http.Header{},
+ }
+ req.Header.Set("Proxy-Connection", "keep-alive")
+
+ if c.options.auth != nil {
+ if user := c.options.auth.Auth(ctx); user != nil {
+ u := user.Username()
+ p, _ := user.Password()
+ req.Header.Set("Proxy-Authorization",
+ "Basic "+base64.StdEncoding.EncodeToString([]byte(u+":"+p)))
+ }
+ }
+
+ switch network {
+ case "tcp", "tcp4", "tcp6":
+ if _, ok := conn.(net.PacketConn); ok {
+ err := fmt.Errorf("tcp over udp is unsupported")
+ log.Error(err.Error())
+ return nil, err
+ }
+ default:
+ err := fmt.Errorf("network %s is unsupported", network)
+ log.Error(err.Error())
+ return nil, err
+ }
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpRequest(req, false)
+ log.Trace(string(dump))
+ }
+ if c.options.connectTimeout > 0 {
+ _ = conn.SetDeadline(time.Now().Add(c.options.connectTimeout))
+ defer conn.SetDeadline(time.Time{})
+ }
+
+ req = req.WithContext(ctx)
+ if err := req.Write(conn); err != nil {
+ return nil, err
+ }
+
+ resp, err := http.ReadResponse(bufio.NewReader(conn), req)
+ if err != nil {
+ return nil, err
+ }
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpResponse(resp, false)
+ log.Trace(string(dump))
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s", resp.Status)
+ }
+ return conn, nil
+}
+
+func WithConnectorAuth(auth ConnectorAuth) ConnectorOption {
+ return func(opts *connectorOptions) {
+ opts.auth = auth
+ }
+}
+
+func WithConnectorTLSConfig(tlsConfig *tls.Config) ConnectorOption {
+ return func(opts *connectorOptions) {
+ opts.tlsConfig = tlsConfig
+ }
+}
+
+func WithConnectorLogger(logger *logger.Logger) ConnectorOption {
+ return func(opts *connectorOptions) {
+ opts.logger = logger
+ }
+}
+
+func WithConnectorConnectTimeout(connectTimeout time.Duration) ConnectorOption {
+ return func(opts *connectorOptions) {
+ opts.connectTimeout = connectTimeout
+ }
+}
+
+func NetDialerConnectOption(netd *NetDialer) ConnectOption {
+ return func(opts *connectOptions) {
+ opts.netDialer = netd
+ }
+}
diff --git a/pkg/gost/ctxvalue.go b/pkg/gost/ctxvalue.go
new file mode 100644
index 0000000..a28120f
--- /dev/null
+++ b/pkg/gost/ctxvalue.go
@@ -0,0 +1,37 @@
+package gost
+
+import (
+ "context"
+ "net/url"
+)
+
+type clientIDKey struct{}
+type ClientID string
+
+var (
+ keyClientID = &clientIDKey{}
+)
+
+func ContextWithClientID(ctx context.Context, clientID ClientID) context.Context {
+ return context.WithValue(ctx, keyClientID, clientID)
+}
+
+func ClientIDFromContext(ctx context.Context) ClientID {
+ v, _ := ctx.Value(keyClientID).(ClientID)
+ return v
+}
+
+type proxyAuthorizationKey struct{}
+
+var (
+ keyProxyAuthorization = &proxyAuthorizationKey{}
+)
+
+func ContextWithProxyAuthorization(ctx context.Context, auth *url.Userinfo) context.Context {
+ return context.WithValue(ctx, keyProxyAuthorization, auth)
+}
+
+func ProxyAuthorizationFromContext(ctx context.Context) *url.Userinfo {
+ v, _ := ctx.Value(keyProxyAuthorization).(*url.Userinfo)
+ return v
+}
diff --git a/pkg/gost/dialer.go b/pkg/gost/dialer.go
new file mode 100644
index 0000000..98d4d4c
--- /dev/null
+++ b/pkg/gost/dialer.go
@@ -0,0 +1,29 @@
+package gost
+
+import (
+ "context"
+ "net"
+)
+
+type Dialer interface {
+ Dial(ctx context.Context, addr string, opts ...DialerOption) (net.Conn, error)
+}
+
+type DialOptions struct {
+ Host string
+ NetDialer *NetDialer
+}
+
+type DialerOption func(opts *DialOptions)
+
+func WithDialerHostOption(host string) DialerOption {
+ return func(opts *DialOptions) {
+ opts.Host = host
+ }
+}
+
+func WithDialerNetDialer(netd *NetDialer) DialerOption {
+ return func(opts *DialOptions) {
+ opts.NetDialer = netd
+ }
+}
diff --git a/pkg/gost/dialer_net.go b/pkg/gost/dialer_net.go
new file mode 100644
index 0000000..d6619cc
--- /dev/null
+++ b/pkg/gost/dialer_net.go
@@ -0,0 +1,50 @@
+package gost
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+const (
+ DefaultTimeout = 10 * time.Second
+)
+
+var (
+ DefaultNetDialer = &NetDialer{}
+)
+
+type NetDialer struct {
+ Timeout time.Duration
+ DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
+ Logger *logger.Logger
+}
+
+func (d *NetDialer) Dial(ctx context.Context, network, addr string) (conn net.Conn, err error) {
+ if d == nil {
+ d = DefaultNetDialer
+ }
+ timeout := d.Timeout
+ if timeout <= 0 {
+ timeout = DefaultTimeout
+ }
+
+ if d.DialFunc != nil {
+ return d.DialFunc(ctx, network, addr)
+ }
+ netd := net.Dialer{
+ Timeout: timeout,
+ }
+ conn, err = netd.DialContext(ctx, network, addr)
+ if err != nil {
+ log := d.Logger
+ if log == nil {
+ log = logger.GetInstance().WithFields(map[string]any{"kind": "net-dialer"})
+ }
+ log.Debugf("dial %s failed: %s", network, err)
+ return nil, err
+ }
+ return conn, err
+}
diff --git a/pkg/gost/dialer_tcp.go b/pkg/gost/dialer_tcp.go
new file mode 100644
index 0000000..3567322
--- /dev/null
+++ b/pkg/gost/dialer_tcp.go
@@ -0,0 +1,49 @@
+package gost
+
+import (
+ "context"
+ "net"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type tcpDialerOptions struct {
+ logger *logger.Logger
+}
+
+type TcpDialerOption func(opts *tcpDialerOptions)
+
+type tcpDialer struct {
+ logger *logger.Logger
+}
+
+func NewTcpDialer(opts ...TcpDialerOption) Dialer {
+ options := &tcpDialerOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "tcp-dialer"}),
+ }
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ return &tcpDialer{
+ logger: options.logger,
+ }
+}
+
+func (d *tcpDialer) Dial(ctx context.Context, addr string, opts ...DialerOption) (net.Conn, error) {
+ var options DialOptions
+ for _, opt := range opts {
+ opt(&options)
+ }
+ conn, err := options.NetDialer.Dial(ctx, "tcp", addr)
+ if err != nil {
+ d.logger.Error(err.Error())
+ }
+ return conn, err
+}
+
+func WithTcpDialerLogger(logger *logger.Logger) TcpDialerOption {
+ return func(opts *tcpDialerOptions) {
+ opts.logger = logger
+ }
+}
diff --git a/pkg/gost/dialer_tls.go b/pkg/gost/dialer_tls.go
new file mode 100644
index 0000000..554002f
--- /dev/null
+++ b/pkg/gost/dialer_tls.go
@@ -0,0 +1,55 @@
+package gost
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+var (
+ DefaultTlsDialer = &TlsDialer{}
+)
+
+type TlsDialer struct {
+ Timeout time.Duration
+ Logger *logger.Logger
+ TLSConfigFunc func() *tls.Config
+}
+
+func (d *TlsDialer) Dial(ctx context.Context, network, addr string) (conn net.Conn, err error) {
+ if d == nil {
+ d = DefaultTlsDialer
+ }
+ timeout := d.Timeout
+ if timeout <= 0 {
+ timeout = DefaultTimeout
+ }
+ netd := net.Dialer{
+ Timeout: timeout,
+ }
+ var tlsConfig *tls.Config
+
+ if d.TLSConfigFunc != nil {
+ tlsConfig = d.TLSConfigFunc()
+ }
+ if tlsConfig == nil {
+ tlsConfig = &tls.Config{InsecureSkipVerify: true}
+ }
+ tlsd := tls.Dialer{
+ NetDialer: &netd,
+ Config: tlsConfig,
+ }
+ conn, err = tlsd.DialContext(ctx, network, addr)
+ if err != nil {
+ log := d.Logger
+ if log == nil {
+ log = logger.GetInstance().WithFields(map[string]any{"kind": "tls-dialer"})
+ }
+ log.Debugf("dial %s failed: %s", network, err)
+ return nil, err
+ }
+ return conn, err
+}
diff --git a/pkg/gost/handler.go b/pkg/gost/handler.go
new file mode 100644
index 0000000..b22f630
--- /dev/null
+++ b/pkg/gost/handler.go
@@ -0,0 +1,311 @@
+package gost
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/asaskevich/govalidator"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+const (
+ defaultRealm = "reverse-http"
+)
+
+type Handler interface {
+ Handle(context.Context, net.Conn, ...HandleOption) error
+}
+
+type handlerOptions struct {
+ logger *logger.Logger
+
+ bypass Bypass
+ router *Router
+ auth *url.Userinfo
+ auther Authenticator
+ tlsConfig *tls.Config
+ proxyOnly bool
+}
+
+type HandlerOption func(opts *handlerOptions)
+
+type httpHandler struct {
+ router *Router
+ options handlerOptions
+}
+
+func NewHttpHandler(opts ...HandlerOption) Handler {
+ options := handlerOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "handler"}),
+ }
+ for _, opt := range opts {
+ opt(&options)
+ }
+ h := &httpHandler{
+ options: options,
+ }
+ h.router = h.options.router
+ if h.router == nil {
+ h.router = NewRouter()
+ }
+ return h
+}
+
+func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...HandleOption) error {
+ defer conn.Close()
+ start := time.Now()
+
+ log := h.options.logger.WithFields(map[string]any{
+ "remote": conn.RemoteAddr().String(),
+ "local": conn.LocalAddr().String(),
+ })
+ log.Infof("handle http %s -> %s", conn.RemoteAddr(), conn.LocalAddr())
+ defer func() {
+ log.With(slog.Duration("duration", time.Since(start))).
+ Infof("handle http %s <- %s", conn.RemoteAddr(), conn.LocalAddr())
+ }()
+
+ req, err := http.ReadRequest(bufio.NewReader(conn))
+ if err != nil {
+ log.Error(err.Error())
+ return err
+ }
+ defer req.Body.Close()
+
+ return h.handleRequest(ctx, conn, req, log)
+}
+
+func (h *httpHandler) Close() error {
+ return nil
+}
+
+func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *http.Request, log *logger.Logger) error {
+ if h.options.proxyOnly && req.Method != http.MethodConnect {
+ resp := &http.Response{
+ ProtoMajor: 1,
+ ProtoMinor: 1,
+ Header: http.Header{},
+ StatusCode: http.StatusMethodNotAllowed,
+ Body: io.NopCloser(strings.NewReader(fmt.Sprintf("Non-proxy request '%s' is not supported", req.Method))),
+ }
+ return resp.Write(conn)
+ }
+
+ if !req.URL.IsAbs() && govalidator.IsDNSName(req.Host) {
+ req.URL.Scheme = "http"
+ }
+ network := "tcp"
+ addr := req.Host
+ if _, port, _ := net.SplitHostPort(addr); port == "" {
+ addr = net.JoinHostPort(addr, "80")
+ }
+
+ fields := map[string]any{
+ "dst": addr,
+ }
+ if u, p, _ := h.basicProxyAuth(req.Header.Get("Proxy-Authorization"), log); u != "" {
+ fields["user"] = u
+ ctx = ContextWithProxyAuthorization(ctx, url.UserPassword(u, p))
+ }
+ log = log.WithFields(fields)
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpRequest(req, false)
+ log.Trace(string(dump))
+ }
+ log.Debugf("%s >> %s", conn.RemoteAddr(), addr)
+
+ resp := &http.Response{
+ ProtoMajor: 1,
+ ProtoMinor: 1,
+ Header: http.Header{},
+ }
+
+ clientID, ok := h.authenticate(ctx, conn, req, resp, log)
+ if !ok {
+ return nil
+ }
+ ctx = ContextWithClientID(ctx, ClientID(clientID))
+
+ if h.options.bypass != nil && h.options.bypass.Contains(ctx, network, addr) {
+ resp.StatusCode = http.StatusForbidden
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpResponse(resp, false)
+ log.Trace(string(dump))
+ }
+ log.Debugf("bypass: %s", addr)
+
+ return resp.Write(conn)
+ }
+
+ if req.Method == "PRI" ||
+ (req.Method != http.MethodConnect && req.URL.Scheme != "http") {
+ resp.StatusCode = http.StatusBadRequest
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpResponse(resp, false)
+ log.Trace(string(dump))
+ }
+
+ return resp.Write(conn)
+ }
+
+ req.Header.Del("Proxy-Authorization")
+
+ cc, err := h.router.Dial(ctx, network, addr)
+ if err != nil {
+ resp.StatusCode = http.StatusServiceUnavailable
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpResponse(resp, false)
+ log.Trace(string(dump))
+ }
+ _ = resp.Write(conn)
+ return err
+ }
+ defer cc.Close()
+
+ if req.Method == http.MethodConnect {
+ resp.StatusCode = http.StatusOK
+ resp.Status = "200 Connection established"
+
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpResponse(resp, false)
+ log.Trace(string(dump))
+ }
+ if err = resp.Write(conn); err != nil {
+ log.Error(err.Error())
+ return err
+ }
+ } else {
+ req.Header.Del("Proxy-Connection")
+ if err = req.Write(cc); err != nil {
+ log.Error(err.Error())
+ return err
+ }
+ }
+
+ start := time.Now()
+ log.Infof("%s -> %s", conn.RemoteAddr(), addr)
+ _ = NetTransport(conn, cc)
+ log.WithFields(map[string]any{
+ "duration": time.Since(start),
+ }).Infof("%s <- %s", conn.RemoteAddr(), addr)
+
+ return nil
+}
+
+func (h *httpHandler) basicProxyAuth(proxyAuth string, _ *logger.Logger) (username, password string, ok bool) {
+ if proxyAuth == "" {
+ return
+ }
+
+ if !strings.HasPrefix(proxyAuth, "Basic ") {
+ return
+ }
+ c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(proxyAuth, "Basic "))
+ if err != nil {
+ return
+ }
+ cs := string(c)
+ s := strings.IndexByte(cs, ':')
+ if s < 0 {
+ return
+ }
+
+ return cs[:s], cs[s+1:], true
+}
+
+func (h *httpHandler) authenticate(ctx context.Context, conn net.Conn, req *http.Request, resp *http.Response, log *logger.Logger) (id string, ok bool) {
+ u, p, _ := h.basicProxyAuth(req.Header.Get("Proxy-Authorization"), log)
+ if h.options.auther == nil {
+ return "", true
+ }
+ if id, ok = h.options.auther.Authenticate(ctx, u, p); ok {
+ return
+ }
+ if resp.Header == nil {
+ resp.Header = http.Header{}
+ }
+ if resp.StatusCode == 0 {
+ realm := defaultRealm
+ resp.StatusCode = http.StatusProxyAuthRequired
+ resp.Header.Add("Proxy-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
+ if strings.ToLower(req.Header.Get("Proxy-Connection")) == "keep-alive" {
+ resp.Header.Set("Connection", "close")
+ resp.Header.Set("Proxy-Connection", "close")
+ }
+
+ log.Debug("proxy authentication required")
+ } else {
+ if resp.StatusCode == http.StatusOK {
+ resp.Header.Set("Connection", "keep-alive")
+ }
+ }
+ if log.IsLevelEnabled(logger.LevelTrace) {
+ dump, _ := httputil.DumpResponse(resp, false)
+ log.Trace(string(dump))
+ }
+
+ _ = resp.Write(conn)
+ return
+}
+
+func WithHandlerBypass(bypass Bypass) HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.bypass = bypass
+ }
+}
+
+func WithHandlerRouter(router *Router) HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.router = router
+ }
+}
+
+func WithHandlerAuth(auth *url.Userinfo) HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.auth = auth
+ }
+}
+
+func WithHandlerAuther(auther Authenticator) HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.auther = auther
+ }
+}
+
+func WithHandlerTLSConfig(tlsConfig *tls.Config) HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.tlsConfig = tlsConfig
+ }
+}
+
+func WithHandlerLogger(logger *logger.Logger) HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.logger = logger
+ }
+}
+
+func WithProxyOnly() HandlerOption {
+ return func(opts *handlerOptions) {
+ opts.proxyOnly = true
+ }
+}
+
+type HandleOptions struct {
+}
+
+type HandleOption func(opts *HandleOptions)
diff --git a/pkg/gost/hosts.go b/pkg/gost/hosts.go
new file mode 100644
index 0000000..33bbd6a
--- /dev/null
+++ b/pkg/gost/hosts.go
@@ -0,0 +1,14 @@
+package gost
+
+import (
+ "context"
+ "net"
+)
+
+type hostsOptions struct{}
+
+type HostsOption func(opts *hostsOptions)
+
+type HostMapper interface {
+ Lookup(ctx context.Context, network, host string, opts ...HostsOption) ([]net.IP, bool)
+}
diff --git a/pkg/gost/listener.go b/pkg/gost/listener.go
new file mode 100644
index 0000000..9d8e327
--- /dev/null
+++ b/pkg/gost/listener.go
@@ -0,0 +1,87 @@
+package gost
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type Listener interface {
+ Init(ctx context.Context) error
+ Accept() (net.Conn, error)
+ Addr() net.Addr
+ Close() error
+}
+
+type listenerOptions struct {
+ addr string
+ logger *logger.Logger
+ tlsConfig *tls.Config
+}
+
+type ListenerOption func(opts *listenerOptions)
+
+type tcpListener struct {
+ ln net.Listener
+ logger *logger.Logger
+ options listenerOptions
+}
+
+func NewTcpListener(opts ...ListenerOption) Listener {
+ options := listenerOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "listener"}),
+ }
+ for _, opt := range opts {
+ opt(&options)
+ }
+ return &tcpListener{
+ logger: options.logger,
+ options: options,
+ }
+}
+
+func (l *tcpListener) Init(ctx context.Context) (err error) {
+ network := "tcp"
+ lc := net.ListenConfig{}
+
+ ln, err := lc.Listen(ctx, network, l.options.addr)
+ if err != nil {
+ return
+ }
+ if l.options.tlsConfig != nil {
+ ln = tls.NewListener(ln, l.options.tlsConfig)
+ }
+ l.ln = ln
+ return
+}
+
+func (l *tcpListener) Accept() (conn net.Conn, err error) {
+ return l.ln.Accept()
+}
+
+func (l *tcpListener) Addr() net.Addr {
+ return l.ln.Addr()
+}
+
+func (l *tcpListener) Close() error {
+ return l.ln.Close()
+}
+
+func WithListenerAddr(addr string) ListenerOption {
+ return func(opts *listenerOptions) {
+ opts.addr = addr
+ }
+}
+func WithListenerLogger(logger *logger.Logger) ListenerOption {
+ return func(opts *listenerOptions) {
+ opts.logger = logger
+ }
+}
+
+func WithListenerTLSConfig(tlsConfig *tls.Config) ListenerOption {
+ return func(opts *listenerOptions) {
+ opts.tlsConfig = tlsConfig
+ }
+}
diff --git a/pkg/gost/nettransport.go b/pkg/gost/nettransport.go
new file mode 100644
index 0000000..bf9a2f5
--- /dev/null
+++ b/pkg/gost/nettransport.go
@@ -0,0 +1,41 @@
+package gost
+
+import (
+ "io"
+ "sync"
+)
+
+const (
+ bufferSize = 64 * 1024
+)
+
+func NetTransport(rw1, rw2 io.ReadWriter) error {
+ errc := make(chan error, 1)
+ go func() {
+ errc <- copyBuffer(rw1, rw2)
+ }()
+
+ go func() {
+ errc <- copyBuffer(rw2, rw1)
+ }()
+
+ if err := <-errc; err != nil && err != io.EOF {
+ return err
+ }
+
+ return nil
+}
+
+func copyBuffer(dst io.Writer, src io.Reader) error {
+ buf := bufPool.Get().(*[]byte)
+ defer bufPool.Put(buf)
+ _, err := io.CopyBuffer(dst, src, *buf)
+ return err
+}
+
+var bufPool = sync.Pool{
+ New: func() any {
+ b := make([]byte, bufferSize)
+ return &b
+ },
+}
diff --git a/pkg/gost/node.go b/pkg/gost/node.go
new file mode 100644
index 0000000..32c79b6
--- /dev/null
+++ b/pkg/gost/node.go
@@ -0,0 +1,57 @@
+package gost
+
+type NodeOptions struct {
+ Transport *Transport
+ Resolver Resolver
+ HostMapper HostMapper
+}
+
+type NodeOption func(*NodeOptions)
+
+type Node struct {
+ Name string
+ Addr string
+ options NodeOptions
+}
+
+func NewNode(name string, addr string, opts ...NodeOption) *Node {
+ var options NodeOptions
+ for _, opt := range opts {
+ if opt != nil {
+ opt(&options)
+ }
+ }
+ return &Node{
+ Name: name,
+ Addr: addr,
+ options: options,
+ }
+}
+
+func (node *Node) Options() *NodeOptions {
+ return &node.options
+}
+
+func (node *Node) Copy() *Node {
+ n := &Node{}
+ *n = *node
+ return n
+}
+
+func WithNodeTransport(tr *Transport) NodeOption {
+ return func(o *NodeOptions) {
+ o.Transport = tr
+ }
+}
+
+func WithNodeResolver(resolver Resolver) NodeOption {
+ return func(o *NodeOptions) {
+ o.Resolver = resolver
+ }
+}
+
+func WithNodeHostMapper(m HostMapper) NodeOption {
+ return func(o *NodeOptions) {
+ o.HostMapper = m
+ }
+}
diff --git a/pkg/gost/resolver.go b/pkg/gost/resolver.go
new file mode 100644
index 0000000..25ba1b3
--- /dev/null
+++ b/pkg/gost/resolver.go
@@ -0,0 +1,55 @@
+package gost
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+var (
+ ErrInvalidResolver = errors.New("invalid resolver")
+)
+
+type resolverOptions struct{}
+
+type ResolverOption func(opts *resolverOptions)
+
+type Resolver interface {
+ Resolve(ctx context.Context, network, host string, opts ...ResolverOption) ([]net.IP, error)
+}
+
+func Resolve(ctx context.Context, network, addr string, r Resolver, hosts HostMapper, log *logger.Logger) (string, error) {
+ if addr == "" {
+ return addr, nil
+ }
+
+ host, port, _ := net.SplitHostPort(addr)
+ if host == "" {
+ return addr, nil
+ }
+
+ if hosts != nil {
+ if ips, _ := hosts.Lookup(ctx, network, host); len(ips) > 0 {
+ log.Debugf("hit host mapper: %s -> %s", host, ips)
+ return net.JoinHostPort(ips[0].String(), port), nil
+ }
+ }
+
+ if r != nil {
+ ips, err := r.Resolve(ctx, network, host)
+ if err != nil {
+ if errors.Is(err, ErrInvalidResolver) {
+ return addr, nil
+ }
+ log.Error(err.Error())
+ }
+ if len(ips) == 0 {
+ return "", fmt.Errorf("resolver: domain %s does not exist", host)
+ }
+ return net.JoinHostPort(ips[0].String(), port), nil
+ }
+ return addr, nil
+}
diff --git a/pkg/gost/route.go b/pkg/gost/route.go
new file mode 100644
index 0000000..41b9fde
--- /dev/null
+++ b/pkg/gost/route.go
@@ -0,0 +1,56 @@
+package gost
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+var (
+ DefaultRoute Route = &defaultRoute{}
+)
+
+type Route interface {
+ Dial(ctx context.Context, network, address string, opts ...RouteDialOption) (net.Conn, error)
+ Nodes() []*Node
+}
+
+type routeDialOptions struct {
+ timeout time.Duration
+ logger *logger.Logger
+}
+
+type RouteDialOption func(opts *routeDialOptions)
+
+type defaultRoute struct{}
+
+func (*defaultRoute) Dial(ctx context.Context, network, address string, opts ...RouteDialOption) (net.Conn, error) {
+ options := routeDialOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "route"}),
+ }
+ for _, opt := range opts {
+ opt(&options)
+ }
+ netd := NetDialer{
+ Timeout: options.timeout,
+ Logger: options.logger,
+ }
+ return netd.Dial(ctx, network, address)
+}
+
+func (r *defaultRoute) Nodes() []*Node {
+ return nil
+}
+
+func WithRouteDialTimeout(d time.Duration) RouteDialOption {
+ return func(opts *routeDialOptions) {
+ opts.timeout = d
+ }
+}
+func WithRouteDialLogger(logger *logger.Logger) RouteDialOption {
+ return func(opts *routeDialOptions) {
+ opts.logger = logger
+ }
+}
diff --git a/pkg/gost/router.go b/pkg/gost/router.go
new file mode 100644
index 0000000..6c6b2b7
--- /dev/null
+++ b/pkg/gost/router.go
@@ -0,0 +1,123 @@
+package gost
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type RouterOptions struct {
+ Retries int
+ Timeout time.Duration
+ Chain Chainer
+ Resolver Resolver
+ HostMapper HostMapper
+ Logger *logger.Logger
+}
+
+type RouterOption func(*RouterOptions)
+
+type Router struct {
+ options RouterOptions
+}
+
+func NewRouter(opts ...RouterOption) *Router {
+ r := &Router{
+ options: RouterOptions{
+ Logger: logger.GetInstance().WithFields(map[string]any{"kind": "router"}),
+ },
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(&r.options)
+ }
+ }
+ return r
+}
+
+func (r *Router) Options() *RouterOptions {
+ if r == nil {
+ return nil
+ }
+ return &r.options
+}
+
+func (r *Router) Dial(ctx context.Context, network, address string) (conn net.Conn, err error) {
+ conn, err = r.dial(ctx, network, address)
+ if err != nil {
+ return
+ }
+ return
+}
+
+func (r *Router) dial(ctx context.Context, network, address string) (conn net.Conn, err error) {
+ count := r.options.Retries + 1
+ if count <= 0 {
+ count = 1
+ }
+ r.options.Logger.Debugf("dial %s/%s", address, network)
+
+ for i := 0; i < count; i++ {
+ var ipAddr string
+ ipAddr, err = Resolve(ctx, "ip", address, r.options.Resolver, r.options.HostMapper, r.options.Logger)
+ if err != nil {
+ r.options.Logger.Error(err.Error())
+ break
+ }
+
+ var route Route
+ if r.options.Chain != nil {
+ route = r.options.Chain.Route(ctx, network, ipAddr, ChainerHostOption(address))
+ }
+ if route == nil {
+ route = DefaultRoute
+ }
+ conn, err = route.Dial(ctx, network, ipAddr,
+ WithRouteDialLogger(r.options.Logger),
+ WithRouteDialTimeout(r.options.Timeout),
+ )
+ if err == nil {
+ break
+ }
+ r.options.Logger.Errorf("route(retry=%d) %s", i, err)
+ }
+ return
+}
+
+func WithRouterTimeout(timeout time.Duration) RouterOption {
+ return func(o *RouterOptions) {
+ o.Timeout = timeout
+ }
+}
+
+func WithRouterRetriesOption(retries int) RouterOption {
+ return func(o *RouterOptions) {
+ o.Retries = retries
+ }
+}
+
+func WithRouterChainer(chain Chainer) RouterOption {
+ return func(o *RouterOptions) {
+ o.Chain = chain
+ }
+}
+
+func WithRouterResolver(resolver Resolver) RouterOption {
+ return func(o *RouterOptions) {
+ o.Resolver = resolver
+ }
+}
+
+func WithRouterHostMapper(m HostMapper) RouterOption {
+ return func(o *RouterOptions) {
+ o.HostMapper = m
+ }
+}
+
+func WithRouterLogger(logger *logger.Logger) RouterOption {
+ return func(o *RouterOptions) {
+ o.Logger = logger
+ }
+}
diff --git a/pkg/gost/service.go b/pkg/gost/service.go
new file mode 100644
index 0000000..27c0a52
--- /dev/null
+++ b/pkg/gost/service.go
@@ -0,0 +1,97 @@
+package gost
+
+import (
+ "context"
+ "io"
+ "net"
+ "time"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type Service interface {
+ Serve() error
+ Addr() net.Addr
+ Close() error
+}
+
+type serviceOptions struct {
+ logger *logger.Logger
+}
+
+type ServiceOption func(opts *serviceOptions)
+
+type defaultService struct {
+ listener Listener
+ handler Handler
+ options serviceOptions
+}
+
+func NewService(ln Listener, h Handler, opts ...ServiceOption) Service {
+ options := serviceOptions{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "service"}),
+ }
+ for _, opt := range opts {
+ opt(&options)
+ }
+ s := &defaultService{
+ listener: ln,
+ handler: h,
+ options: options,
+ }
+ return s
+}
+
+func (s *defaultService) Addr() net.Addr {
+ return s.listener.Addr()
+}
+
+func (s *defaultService) Serve() error {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ var tempDelay time.Duration
+ for {
+ conn, e := s.listener.Accept()
+ if e != nil {
+ //nolint:staticcheck // SA1019 ignore this!
+ if ne, ok := e.(net.Error); ok && ne.Temporary() {
+ if tempDelay == 0 {
+ tempDelay = 5 * time.Millisecond
+ } else {
+ tempDelay *= 2
+ }
+ if maxDelay := 5 * time.Second; tempDelay > maxDelay {
+ tempDelay = maxDelay
+ }
+ s.options.logger.Warnf("accept: %v, retrying in %v", e, tempDelay)
+ time.Sleep(tempDelay)
+ continue
+ }
+ s.options.logger.Errorf("accept: %v", e)
+ return e
+ }
+
+ if tempDelay > 0 {
+ tempDelay = 0
+ }
+ go func() {
+ if err := s.handler.Handle(ctx, conn); err != nil {
+ s.options.logger.Error(err.Error())
+ }
+ }()
+ }
+}
+
+func (s *defaultService) Close() error {
+ if closer, ok := s.handler.(io.Closer); ok {
+ _ = closer.Close()
+ }
+ return s.listener.Close()
+}
+
+func WithServiceLogger(logger *logger.Logger) ServiceOption {
+ return func(opts *serviceOptions) {
+ opts.logger = logger
+ }
+}
diff --git a/pkg/gost/transport.go b/pkg/gost/transport.go
new file mode 100644
index 0000000..10caa77
--- /dev/null
+++ b/pkg/gost/transport.go
@@ -0,0 +1,90 @@
+package gost
+
+import (
+ "context"
+ "net"
+ "time"
+)
+
+type TransportOptions struct {
+ Addr string
+ Route Route
+ Timeout time.Duration
+}
+
+type TransportOption func(*TransportOptions)
+
+type Transport struct {
+ dialer Dialer
+ connector Connector
+ options TransportOptions
+}
+
+func NewTransport(d Dialer, c Connector, opts ...TransportOption) *Transport {
+ tr := &Transport{
+ dialer: d,
+ connector: c,
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(&tr.options)
+ }
+ }
+ return tr
+}
+
+func (tr *Transport) Dial(ctx context.Context, addr string) (net.Conn, error) {
+ netd := &NetDialer{
+ Timeout: tr.options.Timeout,
+ }
+ if tr.options.Route != nil && len(tr.options.Route.Nodes()) > 0 {
+ netd.DialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) {
+ return tr.options.Route.Dial(ctx, network, addr)
+ }
+ }
+ opts := []DialerOption{
+ WithDialerHostOption(tr.options.Addr),
+ WithDialerNetDialer(netd),
+ }
+ return tr.dialer.Dial(ctx, addr, opts...)
+}
+
+func (tr *Transport) Connect(ctx context.Context, conn net.Conn, network, address string) (net.Conn, error) {
+ netd := &NetDialer{
+ Timeout: tr.options.Timeout,
+ }
+ return tr.connector.Connect(ctx, conn, network, address,
+ NetDialerConnectOption(netd),
+ )
+}
+
+func (tr *Transport) Options() *TransportOptions {
+ if tr != nil {
+ return &tr.options
+ }
+ return nil
+}
+
+func (tr *Transport) Copy() *Transport {
+ tr2 := &Transport{}
+ *tr2 = *tr
+ return tr
+}
+
+func WithTransportAddr(addr string) TransportOption {
+ return func(o *TransportOptions) {
+ o.Addr = addr
+ }
+}
+
+func WithTransportRoute(route Route) TransportOption {
+ return func(o *TransportOptions) {
+ o.Route = route
+ }
+}
+
+func WithTransportTimeout(timeout time.Duration) TransportOption {
+ return func(o *TransportOptions) {
+ o.Timeout = timeout
+ }
+}
diff --git a/pkg/jwtutil/cmd.go b/pkg/jwtutil/cmd.go
new file mode 100644
index 0000000..68268fa
--- /dev/null
+++ b/pkg/jwtutil/cmd.go
@@ -0,0 +1,105 @@
+package jwtutil
+
+import (
+ "bytes"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/grepplabs/cert-source/tls/keyutil"
+ "github.com/grepplabs/reverse-http/config"
+)
+
+func GeneratePrivateKey(conf *config.AuthKeyPrivateCmd) error {
+ _, privateKeyPEM, _, _, err := generateKeys(TokenAlg(conf.Algo))
+ if err != nil {
+ return err
+ }
+ return writeToFile(conf.OutputFile, privateKeyPEM)
+}
+
+func GeneratePublicKey(conf *config.AuthKeyPublicCmd) error {
+ privateKey, err := keyutil.ReadPrivateKeyFile(conf.InputFile)
+ if err != nil {
+ return err
+ }
+ key, ok := privateKey.(interface {
+ Public() crypto.PublicKey
+ })
+ if !ok {
+ return fmt.Errorf("invalid private key type: %T", key)
+ }
+ publicKeyPEM, err := keyutil.MarshalPublicKeyToPEM(key.Public())
+ if err != nil {
+ return err
+ }
+ return writeToFile(conf.OutputFile, publicKeyPEM)
+}
+func GenerateJWTToken(conf *config.AuthJwtTokenCmd) error {
+ privateKey, err := keyutil.ReadPrivateKeyFile(conf.InputFile)
+ if err != nil {
+ return err
+ }
+ alg, err := tokenAlgFromPrivateKey(privateKey)
+ if err != nil {
+ return err
+ }
+ signer := NewTokenSigner(alg, privateKey, conf.AgentID,
+ WithSignerAudience(conf.Audience),
+ WithTokenDuration(conf.Duration),
+ WithRole(Role(conf.Role)))
+
+ tokenString, err := signer.SignToken()
+ if err != nil {
+ return err
+ }
+ return writeToFile(conf.OutputFile, []byte(tokenString))
+}
+
+func writeToFile(outputFile string, content []byte) error {
+ var output io.Writer
+ if outputFile == "-" {
+ output = os.Stdout
+ } else {
+ f, err := os.OpenFile(outputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := f.Close(); err != nil {
+ _, _ = fmt.Fprint(os.Stderr, err.Error())
+ }
+ }()
+ output = f
+ }
+ _, err := io.Copy(output, bytes.NewReader(content))
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func generateKeys(alg TokenAlg) (crypto.PrivateKey, []byte, crypto.PublicKey, []byte, error) {
+ switch alg {
+ case ES256:
+ return keyutil.GenerateECKeys()
+ case RS256:
+ return keyutil.GenerateRSAKeys()
+ default:
+ return nil, nil, nil, nil, fmt.Errorf("unsupported alg: %s", alg)
+ }
+}
+
+func tokenAlgFromPrivateKey(privateKey crypto.PrivateKey) (TokenAlg, error) {
+ switch privateKey.(type) {
+ case *ecdsa.PrivateKey:
+ return ES256, nil
+ case *rsa.PrivateKey:
+ return RS256, nil
+ default:
+ return "", fmt.Errorf("private key is not a recognized type: %T", privateKey)
+ }
+}
diff --git a/pkg/jwtutil/jwt.go b/pkg/jwtutil/jwt.go
new file mode 100644
index 0000000..373636a
--- /dev/null
+++ b/pkg/jwtutil/jwt.go
@@ -0,0 +1,156 @@
+package jwtutil
+
+import (
+ "crypto"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+ "github.com/grepplabs/reverse-http/config"
+)
+
+type TokenAlg string
+
+const (
+ RS256 TokenAlg = "RS256"
+ ES256 TokenAlg = "ES256"
+)
+
+type Role string
+
+const (
+ RoleClient Role = Role(config.RoleClient)
+ RoleAgent Role = Role(config.RoleAgent)
+)
+
+const DefaultTokenDuration = 30 * 24 * time.Hour
+
+type TokenClaims struct {
+ AgentID string `json:"agent_id"`
+ Role string `json:"role"`
+ jwt.RegisteredClaims
+}
+
+type TokenSignerOption func(*tokenSigner)
+
+func WithTokenDuration(duration time.Duration) func(*tokenSigner) {
+ return func(s *tokenSigner) {
+ s.duration = duration
+ }
+}
+func WithRole(role Role) func(*tokenSigner) {
+ return func(s *tokenSigner) {
+ s.role = role
+ }
+}
+
+func WithSignerAudience(audience string) func(*tokenSigner) {
+ return func(s *tokenSigner) {
+ s.audience = audience
+ }
+}
+
+type tokenSigner struct {
+ privateKey crypto.PrivateKey
+ alg TokenAlg
+ agentID string
+ duration time.Duration
+ audience string
+ role Role
+}
+
+type TokenSigner interface {
+ SignToken() (string, error)
+}
+
+func NewTokenSigner(alg TokenAlg, privateKey crypto.PrivateKey, agentID string, opts ...TokenSignerOption) TokenSigner {
+ t := &tokenSigner{
+ alg: alg,
+ agentID: agentID,
+ privateKey: privateKey,
+ role: RoleClient,
+ duration: DefaultTokenDuration,
+ }
+ for _, opt := range opts {
+ opt(t)
+ }
+ return t
+}
+
+func (s tokenSigner) SignToken() (string, error) {
+ method := jwt.GetSigningMethod(string(s.alg))
+ if method == nil {
+ return "", fmt.Errorf("unknown signing method: %s", s.alg)
+ }
+ if s.agentID == "" {
+ return "", errors.New("agentID is empty")
+ }
+ now := time.Now()
+ claims := TokenClaims{
+ AgentID: s.agentID,
+ Role: string(s.role),
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(now.Add(s.duration)),
+ IssuedAt: jwt.NewNumericDate(now),
+ NotBefore: jwt.NewNumericDate(now),
+ Issuer: "reverse-http",
+ Subject: s.agentID,
+ ID: uuid.New().String(),
+ },
+ }
+ if s.audience != "" {
+ claims.RegisteredClaims.Audience = []string{s.audience}
+ }
+ token := jwt.NewWithClaims(method, claims)
+ return token.SignedString(s.privateKey)
+}
+
+type TokenVerifierOption func(*tokenVerifier)
+
+func WithVerifierAudience(audience string) func(*tokenVerifier) {
+ return func(s *tokenVerifier) {
+ s.audience = audience
+ }
+}
+
+type tokenVerifier struct {
+ publicKey crypto.PublicKey
+ audience string
+}
+
+type TokenVerifier interface {
+ VerifyToken(tokenString string) (*TokenClaims, error)
+}
+
+func NewTokenVerifier(publicKey crypto.PublicKey, opts ...TokenVerifierOption) TokenVerifier {
+ t := &tokenVerifier{
+ publicKey: publicKey,
+ }
+ for _, opt := range opts {
+ opt(t)
+ }
+ return t
+}
+
+func (s tokenVerifier) VerifyToken(tokenString string) (*TokenClaims, error) {
+ keyFunc := func(*jwt.Token) (interface{}, error) {
+ return s.publicKey, nil
+ }
+ opts := []jwt.ParserOption{
+ jwt.WithValidMethods([]string{string(RS256), string(ES256)}),
+ jwt.WithLeeway(5 * time.Second),
+ }
+ if s.audience != "" {
+ opts = append(opts, jwt.WithAudience(s.audience))
+ }
+ token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, keyFunc, opts...)
+ if err != nil {
+ return nil, err
+ }
+ if claims, ok := token.Claims.(*TokenClaims); ok {
+ return claims, nil
+ }
+ return nil, errors.New("unknown claims type")
+}
diff --git a/pkg/jwtutil/jwt_test.go b/pkg/jwtutil/jwt_test.go
new file mode 100644
index 0000000..6200563
--- /dev/null
+++ b/pkg/jwtutil/jwt_test.go
@@ -0,0 +1,91 @@
+package jwtutil
+
+import (
+ "crypto"
+ "testing"
+ "time"
+
+ "github.com/grepplabs/cert-source/tls/keyutil"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRSASignAndVerify(t *testing.T) {
+ privKey, _, pubKey, _, err := keyutil.GenerateRSAKeys()
+ require.NoError(t, err)
+ _, _, pubKeyOther, _, err := keyutil.GenerateRSAKeys()
+ require.NoError(t, err)
+ testSignAndVerify(t, RS256, privKey, pubKey, pubKeyOther)
+}
+
+func TestECSignAndVerify(t *testing.T) {
+ privKey, _, pubKey, _, err := keyutil.GenerateECKeys()
+ require.NoError(t, err)
+ _, _, pubKeyOther, _, err := keyutil.GenerateECKeys()
+ require.NoError(t, err)
+ testSignAndVerify(t, ES256, privKey, pubKey, pubKeyOther)
+}
+
+func testSignAndVerify(t *testing.T, alg TokenAlg, privKey crypto.PrivateKey, pubKey crypto.PublicKey, pubKeyOther crypto.PublicKey) {
+ tests := []struct {
+ name string
+ signer *tokenSigner
+ verifier *tokenVerifier
+ verifyError string
+ }{
+ {
+ name: "sign",
+ signer: NewTokenSigner(alg, privKey, "4711").(*tokenSigner),
+ verifier: NewTokenVerifier(pubKey).(*tokenVerifier),
+ },
+ {
+ name: "sign with audience",
+ signer: NewTokenSigner(alg, privKey, "4711", WithSignerAudience("www.example.com")).(*tokenSigner),
+ verifier: NewTokenVerifier(pubKey, WithVerifierAudience("www.example.com")).(*tokenVerifier),
+ },
+ {
+ name: "sign with role",
+ signer: NewTokenSigner(alg, privKey, "4711", WithRole(RoleAgent)).(*tokenSigner),
+ verifier: NewTokenVerifier(pubKey).(*tokenVerifier),
+ },
+ {
+ name: "sign with duration",
+ signer: NewTokenSigner(alg, privKey, "4711", WithTokenDuration(60*time.Second)).(*tokenSigner),
+ verifier: NewTokenVerifier(pubKey).(*tokenVerifier),
+ },
+ {
+ name: "sign with missing audience",
+ signer: NewTokenSigner(alg, privKey, "4711").(*tokenSigner),
+ verifier: NewTokenVerifier(pubKey, WithVerifierAudience("www.example.com")).(*tokenVerifier),
+ verifyError: "aud claim is required",
+ },
+ {
+ name: "verify invalid key",
+ signer: NewTokenSigner(alg, privKey, "4711").(*tokenSigner),
+ verifier: NewTokenVerifier(pubKeyOther).(*tokenVerifier),
+ verifyError: "token signature is invalid",
+ },
+ {
+ name: "expired token",
+ signer: NewTokenSigner(alg, privKey, "4711", WithTokenDuration(-60*time.Second)).(*tokenSigner),
+ verifier: NewTokenVerifier(pubKey).(*tokenVerifier),
+ verifyError: "token is expired",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ tokenString, err := tc.signer.SignToken()
+ require.NoError(t, err)
+
+ claims, err := tc.verifier.VerifyToken(tokenString)
+ if tc.verifyError != "" {
+ require.NotNil(t, err)
+ require.Contains(t, err.Error(), tc.verifyError)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, claims)
+ require.Equal(t, claims.AgentID, tc.signer.agentID)
+ require.Equal(t, claims.Role, string(tc.signer.role))
+ })
+ }
+}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
new file mode 100644
index 0000000..95473ba
--- /dev/null
+++ b/pkg/logger/logger.go
@@ -0,0 +1,186 @@
+package logger
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ LevelTrace = slog.Level(-8)
+ LevelFatal = slog.Level(12)
+)
+
+var LevelNames = map[slog.Leveler]string{
+ LevelTrace: "TRACE",
+ LevelFatal: "FATAL",
+}
+var LevelMap = invertMap(LevelNames)
+
+const (
+ EnvLogFormat = "LOG_FORMAT"
+ EnvLogLevel = "LOG_LEVEL"
+)
+
+const (
+ LogFormatJson = "json"
+ LogFormatText = "text"
+)
+
+type LogConfig struct {
+ Format string `env:"LOG_FORMAT" enum:"json, text" default:"text" help:"Log format. One of: [json, text]"`
+ Level string `env:"LOG_LEVEL" enum:"trace, debug, info, warn, error" default:"info" help:"Log level. One of: [trace, debug, info, warn, error]"`
+}
+
+var (
+ instance *Logger
+ once sync.Once
+)
+
+func InitInstance(config LogConfig) {
+ once.Do(func() {
+ instance = NewLogger(config)
+ slog.SetDefault(instance.Logger)
+ })
+}
+
+func GetInstance() *Logger {
+ once.Do(func() {
+ if instance == nil {
+ instance = NewDefaultLogger()
+ }
+ })
+ return instance
+}
+
+func NewLogger(config LogConfig) *Logger {
+ var level slog.Level
+
+ if l, ok := LevelMap[strings.ToUpper(config.Level)]; ok {
+ level = l
+ } else {
+ if err := level.UnmarshalText([]byte(config.Level)); err != nil {
+ level = slog.LevelInfo
+ }
+ }
+ opts := &slog.HandlerOptions{
+ Level: level,
+ ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
+ if a.Key == slog.LevelKey {
+ if l, ok := a.Value.Any().(slog.Level); ok {
+ if levelLabel, levelExists := LevelNames[l]; levelExists {
+ a.Value = slog.StringValue(levelLabel)
+ }
+ }
+ }
+ if a.Key == slog.TimeKey {
+ a.Key = "timestamp"
+ if t, ok := a.Value.Any().(time.Time); ok {
+ a.Value = slog.StringValue(t.Format(time.RFC3339))
+ }
+ }
+ return a
+ },
+ }
+ var handler slog.Handler
+ switch config.Format {
+ case LogFormatJson:
+ handler = slog.NewJSONHandler(os.Stderr, opts)
+ case LogFormatText:
+ handler = slog.NewTextHandler(os.Stderr, opts)
+ default:
+ handler = slog.NewTextHandler(os.Stderr, opts)
+ }
+ return &Logger{
+ slog.New(handler),
+ }
+}
+
+func NewDefaultLogger() *Logger {
+ config := LogConfig{
+ Format: getEnv(EnvLogFormat, LogFormatText),
+ Level: getEnv(EnvLogLevel, slog.LevelInfo.String()),
+ }
+ return NewLogger(config)
+}
+
+func getEnv(key, defaultValue string) string {
+ value := os.Getenv(key)
+ if len(value) == 0 {
+ return defaultValue
+ }
+ return value
+}
+
+func invertMap(m map[slog.Leveler]string) map[string]slog.Level {
+ inverted := make(map[string]slog.Level)
+ for k, v := range m {
+ inverted[v] = k.Level()
+ }
+ return inverted
+}
+
+type Logger struct {
+ *slog.Logger
+}
+
+func (l *Logger) Trace(msg string, args ...any) {
+ l.Logger.Log(context.Background(), LevelTrace, msg, args...)
+}
+
+func (l *Logger) Tracef(format string, args ...any) {
+ l.Logger.Log(context.Background(), LevelTrace, fmt.Sprintf(format, args...))
+}
+
+func (l *Logger) Fatal(msg string, args ...any) {
+ l.Logger.Log(context.Background(), LevelFatal, msg, args...)
+ os.Exit(1)
+}
+
+func (l *Logger) Fatalf(format string, args ...any) {
+ l.Logger.Log(context.Background(), LevelFatal, fmt.Sprintf(format, args...))
+ os.Exit(1)
+}
+
+func (l *Logger) Infof(format string, args ...any) {
+ l.Logger.Info(fmt.Sprintf(format, args...))
+}
+
+func (l *Logger) Debugf(format string, args ...any) {
+ l.Logger.Debug(fmt.Sprintf(format, args...))
+}
+
+func (l *Logger) Warnf(format string, args ...any) {
+ l.Logger.Warn(fmt.Sprintf(format, args...))
+}
+
+func (l *Logger) Errorf(format string, args ...any) {
+ l.Logger.Error(fmt.Sprintf(format, args...))
+}
+
+func (l *Logger) IsLevelEnabled(level slog.Level) bool {
+ return l.Enabled(context.Background(), level)
+}
+
+func (l *Logger) With(args ...any) *Logger {
+ return &Logger{
+ Logger: l.Logger.With(args...),
+ }
+}
+
+func (l *Logger) WithFields(fields map[string]any) *Logger {
+ if len(fields) == 0 {
+ return l
+ }
+ args := make([]any, 0, len(fields))
+ for k, v := range fields {
+ args = append(args, slog.Any(k, v))
+ }
+ return &Logger{
+ Logger: l.Logger.With(args...),
+ }
+}
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
new file mode 100644
index 0000000..d940faf
--- /dev/null
+++ b/pkg/logger/logger_test.go
@@ -0,0 +1,39 @@
+package logger
+
+import (
+ "context"
+ "log/slog"
+ "testing"
+)
+
+func TestTraceLevel(t *testing.T) {
+ InitInstance(LogConfig{
+ Level: "trace",
+ })
+ log := GetInstance()
+ ctx := context.Background()
+ log.Log(ctx, LevelTrace, "Trace message")
+ log.Log(ctx, slog.LevelInfo, "Info message")
+}
+
+func TestLoggerIntf(t *testing.T) {
+ InitInstance(LogConfig{
+ Level: "trace",
+ })
+ log := GetInstance()
+
+ log.Trace("Hello logger - trace")
+ log.Trace("Hello logger - trace", slog.String("tag", "value"))
+ log.Error("Hello logger - error")
+
+ log.Tracef("Tracef %s -> %s", "from", "to")
+ log.Debugf("Debugf %s -> %s", "from", "to")
+ log.Infof("Infof %s -> %s", "from", "to")
+ log.Warnf("Warnf %s -> %s", "from", "to")
+ log.Errorf("Errorf %s -> %s", "from", "to")
+
+ log.WithFields(nil).Info("Hello fields")
+ log.WithFields(map[string]any{}).Info("Hello fields")
+ log.WithFields(map[string]any{"tag1": "a"}).Info("Hello fields")
+ log.WithFields(map[string]any{"tag1": "a", "tag2": 1}).Info("Hello fields")
+}
diff --git a/pkg/proxy/auth_jwt.go b/pkg/proxy/auth_jwt.go
new file mode 100644
index 0000000..aa3f218
--- /dev/null
+++ b/pkg/proxy/auth_jwt.go
@@ -0,0 +1,43 @@
+package proxy
+
+import (
+ "context"
+ "log/slog"
+
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/gost"
+ "github.com/grepplabs/reverse-http/pkg/jwtutil"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type ClientJwtAuthenticator struct {
+ tokenVerifier jwtutil.TokenVerifier
+ logger *logger.Logger
+}
+
+func NewClientJwtAuthenticator(tokenVerifier jwtutil.TokenVerifier) gost.Authenticator {
+ return &ClientJwtAuthenticator{
+ tokenVerifier: tokenVerifier,
+ logger: logger.GetInstance(),
+ }
+}
+
+func (a *ClientJwtAuthenticator) Authenticate(ctx context.Context, agentID, password string, opts ...gost.AuthOption) (id string, ok bool) {
+ claims, err := a.tokenVerifier.VerifyToken(password)
+ if err != nil {
+ a.logger.Warn("token verification failure", slog.String("agentID", agentID), slog.String("error", err.Error()))
+ return "", false
+ }
+ if agentID != claims.AgentID {
+ a.logger.Warnf("agentID mismatch: user %s vs claim %s", agentID, claims.AgentID)
+ return "", false
+ }
+ if config.RoleClient != claims.Role {
+ a.logger.Warnf("role mismatch: role %s vs claim %s", config.RoleClient, claims.Role)
+ return "", false
+ }
+ if agentID == "" {
+ return "", false
+ }
+ return claims.AgentID, true
+}
diff --git a/pkg/proxy/auth_noauth.go b/pkg/proxy/auth_noauth.go
new file mode 100644
index 0000000..ec58e97
--- /dev/null
+++ b/pkg/proxy/auth_noauth.go
@@ -0,0 +1,21 @@
+package proxy
+
+import (
+ "context"
+
+ "github.com/grepplabs/reverse-http/pkg/gost"
+)
+
+type ClientNoAuthAuthenticator struct {
+}
+
+func NewClientNoAuthAuthenticator() gost.Authenticator {
+ return &ClientNoAuthAuthenticator{}
+}
+
+func (a *ClientNoAuthAuthenticator) Authenticate(ctx context.Context, agentID, password string, opts ...gost.AuthOption) (id string, ok bool) {
+ if agentID == "" {
+ return "", false
+ }
+ return agentID, true
+}
diff --git a/pkg/proxy/conntrack.go b/pkg/proxy/conntrack.go
new file mode 100644
index 0000000..cd22452
--- /dev/null
+++ b/pkg/proxy/conntrack.go
@@ -0,0 +1,100 @@
+package proxy
+
+import (
+ "fmt"
+ "log/slog"
+ "reflect"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/store"
+ "github.com/quic-go/quic-go"
+)
+
+type ConnTrack struct {
+ trackedConns *SyncedMap[string, AgentID] // quic.ConnectionID => AgentID
+ agentConns *SyncedMap[AgentID, quic.Connection]
+ logger *logger.Logger
+
+ storeClient store.Client
+ httpProxyAddress string
+}
+
+func NewConnTrack(storeClient store.Client, httpProxyAddress string) *ConnTrack {
+ return &ConnTrack{
+ trackedConns: NewSyncedMap[string, AgentID](),
+ agentConns: NewSyncedMap[AgentID, quic.Connection](),
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "conntrack"}),
+
+ storeClient: storeClient,
+ httpProxyAddress: httpProxyAddress,
+ }
+}
+
+func (ct *ConnTrack) OnConnStarted(connID string) {
+ ct.trackedConns.Set(connID, "")
+}
+
+func (ct *ConnTrack) OnConnClose(connID string) {
+ if oldAgentID, ok := ct.trackedConns.GetAndDelete(connID); ok && oldAgentID != "" {
+ if oldConn, ok2 := ct.agentConns.Get(oldAgentID); ok2 && oldConn != nil {
+ oldConnID := getConnID(oldConn)
+ if oldConnID == connID {
+ if ct.agentConns.CompareAndDelete(oldAgentID, oldConn) {
+ ct.logger.Info("removed connection", slog.String("agentID", string(oldAgentID)), slog.String("connID", connID))
+ if err := ct.storeClient.Delete(string(oldAgentID), ct.httpProxyAddress); err != nil {
+ ct.logger.Warn("delete failure", slog.String("agentID", string(oldAgentID)), slog.String("error", err.Error()))
+ }
+ }
+ }
+ }
+ }
+}
+
+func (ct *ConnTrack) PutConn(agentID AgentID, conn quic.Connection) error {
+ connID := getConnID(conn)
+ if connID != "" {
+ if oldAgentID, ok := ct.trackedConns.Get(connID); ok && oldAgentID == "" {
+ if ct.trackedConns.CompareAndSwap(connID, oldAgentID, agentID) {
+ ct.logger.Info("add agent connection", slog.String("agentID", string(agentID)), slog.String("connID", connID))
+ }
+ }
+ }
+ if oldConn, ok := ct.agentConns.Swap(agentID, conn); ok && oldConn != nil {
+ ct.logger.Info("closing old connection", slog.String("connID", connID))
+ _ = oldConn.CloseWithError(409, "closing old connection")
+ }
+ // write "own" http proxy address to the store to be found by LB
+ return ct.storeClient.Set(string(agentID), ct.httpProxyAddress)
+}
+
+func (ct *ConnTrack) GetConn(agentID AgentID) (previous quic.Connection, loaded bool) {
+ conn, ok := ct.agentConns.Get(agentID)
+ return conn, ok
+}
+
+func (ct *ConnTrack) Shutdown() {
+ agentIDs, conns := ct.agentConns.Entries()
+ for _, conn := range conns {
+ _ = conn.CloseWithError(0, "proxy server shutdown")
+ }
+ for _, agentID := range agentIDs {
+ err := ct.storeClient.Delete(string(agentID), ct.httpProxyAddress)
+ if err != nil {
+ ct.logger.Warn(fmt.Sprintf("store delete failed: %s", agentID), slog.String("error", err.Error()))
+ }
+ }
+}
+
+func getConnID(conn quic.Connection) string {
+ if conn == nil {
+ return ""
+ }
+ reflectValue := reflect.Indirect(reflect.ValueOf(conn))
+ if reflectValue.Kind() == reflect.Struct {
+ fieldVal := reflectValue.FieldByName("logID")
+ if fieldVal.IsValid() {
+ return fieldVal.String()
+ }
+ }
+ return ""
+}
diff --git a/pkg/proxy/loadbalancer.go b/pkg/proxy/loadbalancer.go
new file mode 100644
index 0000000..962e7c2
--- /dev/null
+++ b/pkg/proxy/loadbalancer.go
@@ -0,0 +1,52 @@
+package proxy
+
+import (
+ "context"
+ "fmt"
+ "net"
+
+ tlsclient "github.com/grepplabs/cert-source/tls/client"
+ "github.com/grepplabs/reverse-http/pkg/gost"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/store"
+)
+
+type LoadBalancerDialer struct {
+ tcpDialer gost.Dialer
+ storeClient store.Client
+ netDialer *gost.NetDialer
+}
+
+func NewLoadBalancerDialer(storeClient store.Client, tlsConfigFunc tlsclient.TLSClientConfigFunc) *LoadBalancerDialer {
+ return &LoadBalancerDialer{
+ tcpDialer: gost.NewTcpDialer(),
+ storeClient: storeClient,
+ netDialer: newLoadBalancerNetNetDialer(tlsConfigFunc),
+ }
+}
+
+func (lb *LoadBalancerDialer) Dial(ctx context.Context, agentID AgentID) (net.Conn, error) {
+ addr, err := lb.storeClient.Get(string(agentID))
+ if err != nil {
+ return nil, err
+ }
+ if addr == "" {
+ return nil, fmt.Errorf("target addr for agentID %s is empty", agentID)
+ }
+ return lb.tcpDialer.Dial(ctx, addr, gost.WithDialerNetDialer(lb.netDialer))
+}
+
+func newLoadBalancerNetNetDialer(tlsConfigFunc tlsclient.TLSClientConfigFunc) *gost.NetDialer {
+ if tlsConfigFunc != nil {
+ tlsDialer := gost.TlsDialer{
+ Timeout: gost.DefaultTimeout,
+ Logger: logger.GetInstance().WithFields(map[string]any{"kind": "tls-dialer"}),
+ TLSConfigFunc: tlsConfigFunc,
+ }
+ return &gost.NetDialer{
+ DialFunc: tlsDialer.Dial,
+ }
+ } else {
+ return nil
+ }
+}
diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go
new file mode 100644
index 0000000..216e150
--- /dev/null
+++ b/pkg/proxy/proxy.go
@@ -0,0 +1,119 @@
+package proxy
+
+import (
+ "context"
+ "net"
+ "net/url"
+ "time"
+
+ certconfig "github.com/grepplabs/cert-source/config"
+ tlsserverconfig "github.com/grepplabs/cert-source/tls/server/config"
+ "github.com/grepplabs/reverse-http/pkg/gost"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/util"
+)
+
+type AgentDialer struct {
+ agentDialFunc AgentDialFunc
+}
+
+func NewAgentDialer(dialAgentFunc AgentDialFunc) gost.Dialer {
+ return &AgentDialer{
+ agentDialFunc: dialAgentFunc,
+ }
+}
+func (cd *AgentDialer) Dial(ctx context.Context, addr string, opts ...gost.DialerOption) (net.Conn, error) {
+ agentId := gost.ClientIDFromContext(ctx)
+ return cd.agentDialFunc(ctx, AgentID(agentId))
+}
+
+type HttpProxyServer struct {
+ ln gost.Listener
+ dialAgentFunc AgentDialFunc
+ clientAuthenticator gost.Authenticator
+ bypass *util.Whitelist
+ forwardAuth bool
+}
+
+func NewHttpProxyServer(listenAddr string, tlsServerConfig certconfig.TLSServerConfig, dialAgentFunc AgentDialFunc, clientAuthenticator gost.Authenticator, bypass *util.Whitelist, forwardAuth bool) (*HttpProxyServer, error) {
+ listenOpts := []gost.ListenerOption{
+ gost.WithListenerAddr(listenAddr),
+ }
+ if tlsServerConfig.Enable {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "http-proxy"})
+ tlsConfig, err := tlsserverconfig.GetServerTLSConfig(log.Logger, &tlsServerConfig)
+ if err != nil {
+ return nil, err
+ }
+ listenOpts = append(listenOpts, gost.WithListenerTLSConfig(tlsConfig))
+ }
+
+ ln := gost.NewTcpListener(listenOpts...)
+ err := ln.Init(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ return &HttpProxyServer{
+ ln: ln,
+ dialAgentFunc: dialAgentFunc,
+ clientAuthenticator: clientAuthenticator,
+ bypass: bypass,
+ forwardAuth: forwardAuth,
+ }, nil
+}
+
+func (p *HttpProxyServer) Shutdown(_ context.Context) error {
+ p.ln.Close()
+ return nil
+}
+
+func (p *HttpProxyServer) ListenAndServe() error {
+ httpProxyChain := p.getHttpProxyChain("localhost:3129", p.dialAgentFunc, p.forwardAuth)
+ routerOpts := []gost.RouterOption{
+ gost.WithRouterChainer(httpProxyChain),
+ }
+ router := gost.NewRouter(routerOpts...)
+ httpHandlerOpts := []gost.HandlerOption{
+ gost.WithHandlerRouter(router),
+ gost.WithHandlerAuther(p.clientAuthenticator),
+ gost.WithProxyOnly(), // block non-CONNECT request
+ }
+ if p.bypass != nil {
+ httpHandlerOpts = append(httpHandlerOpts, gost.WithHandlerBypass(p.bypass))
+ }
+ httpHandler := gost.NewHttpHandler(httpHandlerOpts...)
+ service := gost.NewService(p.ln, httpHandler)
+ return service.Serve()
+}
+
+func (p *HttpProxyServer) getHttpProxyChain(addr string, dialAgentFunc AgentDialFunc, forwardAuth bool) *gost.Chain {
+ c := gost.NewChain("agent-chain")
+
+ var connectorOpts []gost.ConnectorOption
+ if forwardAuth {
+ connectorOpts = append(connectorOpts, gost.WithConnectorAuth(NewHttpProxyForwardAuth()))
+ }
+ httpConnector := gost.NewHttpConnector(connectorOpts...)
+ agentDialer := NewAgentDialer(dialAgentFunc)
+ tr := gost.NewTransport(agentDialer, httpConnector,
+ gost.WithTransportAddr(addr),
+ gost.WithTransportTimeout(10*time.Second),
+ )
+ nodeOpts := []gost.NodeOption{
+ gost.WithNodeTransport(tr),
+ }
+ node := gost.NewNode("agent-node", addr, nodeOpts...)
+ c.AddNode(node)
+ return c
+}
+
+type HttpProxyForwardAuth struct {
+}
+
+func NewHttpProxyForwardAuth() *HttpProxyForwardAuth {
+ return &HttpProxyForwardAuth{}
+}
+
+func (a *HttpProxyForwardAuth) Auth(ctx context.Context) *url.Userinfo {
+ return gost.ProxyAuthorizationFromContext(ctx)
+}
diff --git a/pkg/proxy/quic.go b/pkg/proxy/quic.go
new file mode 100644
index 0000000..5133f6f
--- /dev/null
+++ b/pkg/proxy/quic.go
@@ -0,0 +1,94 @@
+package proxy
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+ "time"
+
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/agent"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/util"
+ "github.com/quic-go/quic-go"
+)
+
+type AgentID string
+type AgentDialFunc func(ctx context.Context, agentID AgentID) (net.Conn, error)
+
+type QuicServer struct {
+ conf *config.ProxyCmd
+ logger *logger.Logger
+ agentDialTimeout time.Duration
+ agentVerifier agent.Verifier
+ connTrack *ConnTrack
+}
+
+func NewQuicServer(conf *config.ProxyCmd, agentVerifier agent.Verifier, connTrack *ConnTrack, logger *logger.Logger) *QuicServer {
+ return &QuicServer{
+ conf: conf,
+ agentVerifier: agentVerifier,
+ agentDialTimeout: conf.AgentServer.Agent.DialTimeout,
+ connTrack: connTrack,
+ logger: logger,
+ }
+}
+func (qs *QuicServer) Close() {
+ qs.connTrack.Shutdown()
+}
+
+func (qs *QuicServer) listenForAgents(ctx context.Context, ln *quic.Listener) error {
+ qs.logger.Info("waiting for agents ...")
+
+ for {
+ conn, err := ln.Accept(ctx)
+ if err != nil {
+ return err
+ }
+ go func(conn quic.Connection) {
+ log := qs.logger.With(slog.String("connID", getConnID(conn)))
+ log.Info(fmt.Sprintf("got a connection from: %s ", conn.RemoteAddr().String()))
+ attrs, err := qs.agentVerifier.Verify(ctx, conn)
+ if err != nil {
+ log.Error("agent auth failure", slog.String("error", err.Error()))
+ _ = conn.CloseWithError(500, err.Error())
+ return
+ }
+ if attrs.AgentID == "" {
+ log.Warn("empty agent id")
+ _ = conn.CloseWithError(400, "empty agent id")
+ return
+ }
+ agentID := AgentID(attrs.AgentID)
+ log.Info(fmt.Sprintf("authenticated agent %s", agentID))
+ err = qs.connTrack.PutConn(agentID, conn)
+ if err != nil {
+ log.Error("conn track put failed", slog.String("error", err.Error()))
+ _ = conn.CloseWithError(500, "conn track put failure")
+ return
+ }
+ }(conn)
+ }
+}
+
+func (qs *QuicServer) DialAgent(ctx context.Context, agentID AgentID) (net.Conn, error) {
+ conn, ok := qs.connTrack.GetConn(agentID)
+ if !ok {
+ return nil, fmt.Errorf("connection for agent %s not found", agentID)
+ }
+ if qs.agentDialTimeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, qs.agentDialTimeout)
+ defer cancel()
+ }
+ stream, err := conn.OpenStreamSync(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &util.QuicConn{
+ Stream: stream,
+ LAddr: conn.LocalAddr(),
+ RAddr: conn.RemoteAddr(),
+ }, err
+}
diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go
new file mode 100644
index 0000000..9d5a770
--- /dev/null
+++ b/pkg/proxy/server.go
@@ -0,0 +1,256 @@
+package proxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "time"
+
+ tlsconfig "github.com/grepplabs/cert-source/config"
+ tlsclientconfig "github.com/grepplabs/cert-source/tls/client/config"
+ "github.com/grepplabs/cert-source/tls/keyutil"
+ tlsserver "github.com/grepplabs/cert-source/tls/server"
+ tlsserverconfig "github.com/grepplabs/cert-source/tls/server/config"
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/agent"
+ "github.com/grepplabs/reverse-http/pkg/gost"
+ "github.com/grepplabs/reverse-http/pkg/jwtutil"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/store"
+ storememcached "github.com/grepplabs/reverse-http/pkg/store/memcached"
+ storenone "github.com/grepplabs/reverse-http/pkg/store/none"
+ "github.com/grepplabs/reverse-http/pkg/util"
+ "github.com/oklog/run"
+ "github.com/quic-go/quic-go"
+ "github.com/quic-go/quic-go/logging"
+)
+
+func RunProxyServer(conf *config.ProxyCmd) {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "proxy"})
+ group := new(run.Group)
+
+ util.AddQuitSignal(group)
+ dialAgentFunc := addQuicServer(conf, group)
+ addProxyHttpServer(conf, group, dialAgentFunc)
+
+ err := group.Run()
+ if err != nil {
+ log.Error("server exiting", slog.String("error", err.Error()))
+ } else {
+ log.Info("server exiting")
+ }
+}
+
+func RunLoadBalancerServer(conf *config.LoadBalancerCmd) {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "lb-server"})
+ group := new(run.Group)
+
+ util.AddQuitSignal(group)
+ addLoadBalancerServer(conf, group)
+
+ err := group.Run()
+ if err != nil {
+ log.Error("server exiting", slog.String("error", err.Error()))
+ } else {
+ log.Info("server exiting")
+ }
+}
+
+func Tracer(connTrack *ConnTrack) func(_ context.Context, perspective logging.Perspective, connID quic.ConnectionID) *logging.ConnectionTracer {
+ return func(_ context.Context, perspective logging.Perspective, connID quic.ConnectionID) *logging.ConnectionTracer {
+ ct := logging.ConnectionTracer{
+ StartedConnection: func(local, remote net.Addr, srcConnID, destConnID logging.ConnectionID) {
+ connTrack.logger.Info("connection start", slog.String("connID", connID.String()))
+ if perspective == logging.PerspectiveServer && connID.Len() != 0 {
+ connTrack.OnConnStarted(connID.String())
+ }
+ },
+ Close: func() {
+ connTrack.logger.Info("connection close", slog.String("connID", connID.String()))
+ if perspective == logging.PerspectiveServer && connID.Len() != 0 {
+ connTrack.OnConnClose(connID.String())
+ }
+ },
+ }
+ return logging.NewMultiplexedConnectionTracer(&ct)
+ }
+}
+
+func addQuicServer(conf *config.ProxyCmd, group *run.Group) AgentDialFunc {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "quic-server"})
+ tlsConfig, err := tlsserverconfig.GetServerTLSConfig(log.Logger, &tlsconfig.TLSServerConfig{
+ Enable: true,
+ Refresh: conf.AgentServer.TLS.Refresh,
+ File: conf.AgentServer.TLS.File,
+ }, tlsserver.WithTLSServerNextProtos([]string{config.ReverseHttpProto}))
+ if err != nil {
+ log.Error("error while during server tls config setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ agentVerifier, err := getAgentVerifier(&conf.Auth)
+ if err != nil {
+ log.Error("error while agent verifier setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ storeClient, err := getProxyStoreClient(conf, log)
+ if err != nil {
+ log.Error("error while store client setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ httpProxyAddress := conf.Store.HttpProxyAddress
+ if httpProxyAddress == "" {
+ httpProxyAddress = conf.HttpProxyServer.ListenAddress
+ }
+ log.Info(fmt.Sprintf("store http proxy address %s", httpProxyAddress))
+ connTrack := NewConnTrack(storeClient, httpProxyAddress)
+ listenAddr := conf.AgentServer.ListenAddress
+ log.Info(fmt.Sprintf("starting UDP agent server on %s", listenAddr))
+ ln, err := quic.ListenAddr(listenAddr, tlsConfig, &quic.Config{
+ KeepAlivePeriod: config.DefaultKeepAlivePeriod,
+ Tracer: Tracer(connTrack),
+ })
+ if err != nil {
+ log.Error("error while starting agent server", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ quicServer := NewQuicServer(conf, agentVerifier, connTrack, log)
+ group.Add(func() error {
+ return quicServer.listenForAgents(context.Background(), ln)
+ }, func(error) {
+ log.Info("shutdown agent server ...")
+ quicServer.Close()
+ storeClient.Close()
+ _ = ln.Close()
+ })
+ return quicServer.DialAgent
+}
+
+func getProxyStoreClient(conf *config.ProxyCmd, log *logger.Logger) (store.Client, error) {
+ switch conf.Store.Type {
+ case config.StoreNone:
+ return storenone.NewClient(), nil
+ case config.StoreMemcached:
+ log.Infof("memcached server %s", conf.Store.Memcached.Address)
+ return storememcached.NewClient(conf.Store.Memcached), nil
+ default:
+ return nil, fmt.Errorf("unsupported store type: %s", conf.Store.Type)
+ }
+}
+
+func getAgentVerifier(conf *config.AuthVerifier) (agent.Verifier, error) {
+ switch conf.Type {
+ case config.AuthNoAuth:
+ return agent.NewNoAuthVerifier(), nil
+ case config.AuthJWT:
+ publicKey, err := keyutil.ReadPublicKeyFile(conf.JWTVerifier.PublicKey)
+ if err != nil {
+ return nil, err
+ }
+ tokenVerifier := jwtutil.NewTokenVerifier(publicKey, jwtutil.WithVerifierAudience(conf.JWTVerifier.Audience))
+ return agent.NewJWTVerifier(tokenVerifier), nil
+ default:
+ return nil, fmt.Errorf("unsupported agent verifier type: %s", conf.Type)
+ }
+}
+
+func getClientVerifier(conf *config.AuthVerifier) (gost.Authenticator, error) {
+ switch conf.Type {
+ case config.AuthNoAuth:
+ return NewClientNoAuthAuthenticator(), nil
+ case config.AuthJWT:
+ publicKey, err := keyutil.ReadPublicKeyFile(conf.JWTVerifier.PublicKey)
+ if err != nil {
+ return nil, err
+ }
+ tokenVerifier := jwtutil.NewTokenVerifier(publicKey, jwtutil.WithVerifierAudience(conf.JWTVerifier.Audience))
+ return NewClientJwtAuthenticator(tokenVerifier), nil
+ default:
+ return nil, fmt.Errorf("unsupported client verifier type: %s", conf.Type)
+ }
+}
+
+func addProxyHttpServer(conf *config.ProxyCmd, group *run.Group, dialAgentFunc AgentDialFunc) {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "http-proxy"})
+ clientVerifier, err := getClientVerifier(&conf.Auth)
+ if err != nil {
+ log.Error("error while client verifier setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ const forwardAuth = false
+ listenAddr := conf.HttpProxyServer.ListenAddress
+ srv, err := NewHttpProxyServer(listenAddr, conf.HttpProxyServer.TLS, dialAgentFunc, clientVerifier, util.WhitelistFromStrings(conf.HttpProxyServer.HostWhitelist), forwardAuth)
+ if err != nil {
+ log.Error("error while starting http proxy server", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ group.Add(func() error {
+ log.Infof("starting TCP http proxy server on %s", listenAddr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+ }, func(error) {
+ log.Info("shutdown http proxy server ...")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ log.Error("server http proxy shutdown", slog.String("error", err.Error()))
+ }
+ })
+}
+
+func addLoadBalancerServer(conf *config.LoadBalancerCmd, group *run.Group) {
+ log := logger.GetInstance().WithFields(map[string]any{"kind": "lb-server"})
+ clientVerifier, err := getClientVerifier(&conf.Auth)
+ if err != nil {
+ log.Error("error while client verifier setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ storeClient, err := getLoadBalancerStoreClient(conf, log)
+ if err != nil {
+ log.Error("error while store client setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ tlsConfigFunc, err := tlsclientconfig.GetTLSClientConfigFunc(log.Logger, &conf.HttpConnector.TLS)
+ if err != nil {
+ log.Error("error while connector tls setup", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ const forwardAuth = true
+ dialAgentFunc := NewLoadBalancerDialer(storeClient, tlsConfigFunc)
+ listenAddr := conf.HttpProxyServer.ListenAddress
+ srv, err := NewHttpProxyServer(listenAddr, conf.HttpProxyServer.TLS, dialAgentFunc.Dial, clientVerifier, util.WhitelistFromStrings(conf.HttpProxyServer.HostWhitelist), forwardAuth)
+ if err != nil {
+ log.Error("error while starting lb proxy server", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ group.Add(func() error {
+ log.Infof("starting TCP lb proxy server on %s", listenAddr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+ }, func(error) {
+ log.Info("shutdown lb proxy server ...")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ log.Error("server lb proxy shutdown", slog.String("error", err.Error()))
+ }
+ })
+}
+
+func getLoadBalancerStoreClient(conf *config.LoadBalancerCmd, log *logger.Logger) (store.Client, error) {
+ switch conf.Store.Type {
+ case config.StoreMemcached:
+ log.Infof("memcached server %s", conf.Store.Memcached.Address)
+ return storememcached.NewClient(conf.Store.Memcached), nil
+ default:
+ return nil, fmt.Errorf("unsupported store type: %s", conf.Store.Type)
+ }
+}
diff --git a/pkg/proxy/syncedmap.go b/pkg/proxy/syncedmap.go
new file mode 100644
index 0000000..6f2fdb7
--- /dev/null
+++ b/pkg/proxy/syncedmap.go
@@ -0,0 +1,99 @@
+package proxy
+
+import (
+ "sync"
+)
+
+func NewSyncedMap[K comparable, V any]() *SyncedMap[K, V] {
+ return &SyncedMap[K, V]{
+ mp: new(sync.Map),
+ }
+}
+
+type SyncedMap[K comparable, V any] struct {
+ mp *sync.Map
+}
+
+func (m *SyncedMap[K, V]) Get(key K) (V, bool) {
+ v, ok := m.mp.Load(key)
+ if ok {
+ return v.(V), ok
+ }
+ var zeroV V
+ return zeroV, false
+}
+
+func (m *SyncedMap[K, V]) Set(key K, value V) {
+ m.mp.Store(key, value)
+}
+
+func (m *SyncedMap[K, V]) Swap(key K, value V) (V, bool) {
+ v, loaded := m.mp.Swap(key, value)
+ if loaded {
+ return v.(V), loaded
+ }
+ var zeroV V
+ return zeroV, false
+}
+
+func (m *SyncedMap[K, V]) Delete(key K) {
+ m.mp.Delete(key)
+}
+
+func (m *SyncedMap[K, V]) GetAndDelete(key K) (V, bool) {
+ v, ok := m.mp.LoadAndDelete(key)
+ if ok {
+ return v.(V), ok
+ }
+ var zeroV V
+ return zeroV, false
+}
+
+func (m *SyncedMap[K, V]) CompareAndDelete(key K, old V) bool {
+ return m.mp.CompareAndDelete(key, old)
+}
+
+func (m *SyncedMap[K, V]) CompareAndSwap(key K, old V, new V) bool {
+ return m.mp.CompareAndSwap(key, old, new)
+}
+
+func (m *SyncedMap[K, V]) Values() []V {
+ var vs []V
+ m.mp.Range(func(key, value any) bool {
+ v, ok := value.(V)
+ if ok {
+ vs = append(vs, v)
+ }
+ return true
+ })
+ return vs
+}
+
+func (m *SyncedMap[K, V]) Keys() []K {
+ var ks []K
+ m.mp.Range(func(key, value any) bool {
+ k, ok := key.(K)
+ if ok {
+ ks = append(ks, k)
+ }
+ return true
+ })
+ return ks
+}
+
+func (m *SyncedMap[K, V]) Entries() ([]K, []V) {
+ var ks []K
+ var vs []V
+ m.mp.Range(func(key, value any) bool {
+ k, ok := key.(K)
+ if ok {
+ ks = append(ks, k)
+ }
+ v, ok := value.(V)
+ if ok {
+ vs = append(vs, v)
+ }
+ return true
+ })
+ return ks, vs
+}
diff --git a/pkg/store/client.go b/pkg/store/client.go
new file mode 100644
index 0000000..2779b53
--- /dev/null
+++ b/pkg/store/client.go
@@ -0,0 +1,8 @@
+package store
+
+type Client interface {
+ Get(key string) (string, error)
+ Set(key, value string) error
+ Delete(key, value string) error
+ Close()
+}
diff --git a/pkg/store/memcached/client.go b/pkg/store/memcached/client.go
new file mode 100644
index 0000000..4d438e9
--- /dev/null
+++ b/pkg/store/memcached/client.go
@@ -0,0 +1,99 @@
+package memcached
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/bradfitz/gomemcache/memcache"
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/store"
+)
+
+type client struct {
+ mc *memcache.Client
+ logger *logger.Logger
+}
+
+func NewClient(conf config.MemcachedConfig) store.Client {
+ mc := memcache.New(conf.Address)
+ if conf.Timeout > 0 {
+ mc.Timeout = conf.Timeout
+ }
+ return &client{
+ mc: mc,
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "memcached"}),
+ }
+}
+
+func (c *client) Get(key string) (string, error) {
+ value, err := c.mc.Get(key)
+ if err != nil {
+ if errors.Is(err, memcache.ErrCacheMiss) {
+ return "", nil
+ }
+ return "", err
+ }
+ c.logger.Infof("get %s result %s", key, string(value.Value))
+ return string(value.Value), nil
+}
+
+func (c *client) Set(key, value string) error {
+ c.logger.Infof("set %s to %s", key, value)
+
+ oldItem, err := c.mc.Get(key)
+ if err != nil {
+ if !errors.Is(err, memcache.ErrCacheMiss) {
+ return err
+ }
+ err = c.mc.Set(&memcache.Item{Key: key, Value: []byte(value)})
+ if err != nil {
+ return err
+ }
+ setItem, err := c.mc.Get(key)
+ if err != nil {
+ return err
+ }
+ setValue := string(setItem.Value)
+ if setValue != value {
+ return fmt.Errorf("set and get difference")
+ }
+ return nil
+ } else {
+ oldItem.Value = []byte(value)
+ err = c.mc.CompareAndSwap(oldItem)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+}
+
+func (c *client) Delete(key, value string) error {
+ c.logger.Infof("delete %s value %s", key, value)
+
+ oldItem, err := c.mc.Get(key)
+ if err != nil {
+ if errors.Is(err, memcache.ErrCacheMiss) {
+ return nil
+ }
+ return err
+ }
+ oldValue := string(oldItem.Value)
+ if oldValue != value {
+ return fmt.Errorf("delete and get difference")
+ }
+ err = c.mc.Delete(key)
+ if err != nil {
+ if errors.Is(err, memcache.ErrCacheMiss) {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (c *client) Close() {
+ c.logger.Info("close client")
+ _ = c.mc.Close()
+}
diff --git a/pkg/store/memcached/client_test.go b/pkg/store/memcached/client_test.go
new file mode 100644
index 0000000..3a00d2d
--- /dev/null
+++ b/pkg/store/memcached/client_test.go
@@ -0,0 +1,47 @@
+package memcached
+
+import (
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/grepplabs/reverse-http/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMemcached(t *testing.T) {
+ t.Skip("Integration test")
+
+ key := uuid.NewString()
+
+ client := NewClient(config.MemcachedConfig{
+ Address: "localhost:11211",
+ Timeout: 1 * time.Second,
+ })
+ defer client.Close()
+
+ for i := 0; i < 3; i++ {
+ v, err := client.Get(key)
+ require.NoError(t, err)
+ require.Equal(t, "", v)
+ }
+ for i := 0; i < 3; i++ {
+ v := strconv.Itoa(i)
+ err := client.Set(key, v)
+ require.NoError(t, err)
+
+ value, err := client.Get(key)
+ require.NoError(t, err)
+ require.Equal(t, v, value)
+ }
+ for i := 0; i < 3; i++ {
+ err := client.Delete(key, "2")
+ require.NoError(t, err)
+
+ v, err := client.Get(key)
+ require.NoError(t, err)
+ require.Equal(t, "", v)
+
+ }
+}
diff --git a/pkg/store/none/client.go b/pkg/store/none/client.go
new file mode 100644
index 0000000..46ab547
--- /dev/null
+++ b/pkg/store/none/client.go
@@ -0,0 +1,34 @@
+package none
+
+import (
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/grepplabs/reverse-http/pkg/store"
+)
+
+type client struct {
+ logger *logger.Logger
+}
+
+func NewClient() store.Client {
+ return &client{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "store-none"}),
+ }
+}
+
+func (c *client) Get(key string) (string, error) {
+ c.logger.Debugf("get %s", key)
+ return "", nil
+}
+
+func (c *client) Set(key, value string) error {
+ c.logger.Debugf("set %s to %s", key, value)
+ return nil
+}
+
+func (c *client) Delete(key string, value string) error {
+ c.logger.Debugf("delete %s value %s", key, value)
+ return nil
+}
+
+func (c *client) Close() {
+}
diff --git a/pkg/util/quic.go b/pkg/util/quic.go
new file mode 100644
index 0000000..1ed850e
--- /dev/null
+++ b/pkg/util/quic.go
@@ -0,0 +1,21 @@
+package util
+
+import (
+ "net"
+
+ "github.com/quic-go/quic-go"
+)
+
+type QuicConn struct {
+ quic.Stream
+ LAddr net.Addr
+ RAddr net.Addr
+}
+
+func (c *QuicConn) LocalAddr() net.Addr {
+ return c.LAddr
+}
+
+func (c *QuicConn) RemoteAddr() net.Addr {
+ return c.RAddr
+}
diff --git a/pkg/util/signal.go b/pkg/util/signal.go
new file mode 100644
index 0000000..19b9c65
--- /dev/null
+++ b/pkg/util/signal.go
@@ -0,0 +1,27 @@
+package util
+
+import (
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/grepplabs/reverse-http/pkg/logger"
+ "github.com/oklog/run"
+)
+
+func AddQuitSignal(group *run.Group) {
+ quit := make(chan os.Signal, 1)
+ group.Add(func() error {
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
+ select {
+ case sig := <-c:
+ logger.GetInstance().Infof("received signal %s", sig)
+ return nil
+ case <-quit:
+ return nil
+ }
+ }, func(error) {
+ close(quit)
+ })
+}
diff --git a/pkg/util/whitelist.go b/pkg/util/whitelist.go
new file mode 100644
index 0000000..3d2dc4c
--- /dev/null
+++ b/pkg/util/whitelist.go
@@ -0,0 +1,294 @@
+package util
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+ "strconv"
+ "strings"
+
+ "github.com/grepplabs/reverse-http/pkg/gost"
+ "github.com/grepplabs/reverse-http/pkg/logger"
+)
+
+type Entry[T any] struct {
+ Value T
+ MinPort int
+ MaxPort int
+}
+
+func (e Entry[T]) String() string {
+ if e.MinPort == 0 && e.MaxPort == 0 {
+ return fmt.Sprintf("%v", e.Value)
+ } else {
+ return fmt.Sprintf("%v (%d,%d)", e.Value, e.MinPort, e.MaxPort)
+ }
+}
+
+func (e Entry[T]) IsPortAllowed(port int) bool {
+ if e.MinPort == 0 && e.MaxPort == 0 {
+ return true
+ }
+ return e.MinPort <= port && port <= e.MaxPort
+}
+
+type Whitelist struct {
+ Networks []Entry[*net.IPNet]
+ IPs []Entry[net.IP]
+ Zones []Entry[string]
+ Hosts []Entry[string]
+ logger *logger.Logger
+}
+
+func NewWhitelist() *Whitelist {
+ return &Whitelist{
+ logger: logger.GetInstance().WithFields(map[string]any{"kind": "whitelist"}),
+ }
+}
+
+// WhitelistFromStrings creates a Whitelist if the host list is not empty, nil otherwise.
+func WhitelistFromStrings(strings []string) *Whitelist {
+ if len(strings) == 0 {
+ return nil
+ }
+ wl := NewWhitelist()
+ for _, s := range strings {
+ wl.AddFromString(s)
+ }
+ return wl
+}
+
+// Contains returns bypass result, `true` blocks the request, `false` pass the request through.
+func (p *Whitelist) Contains(ctx context.Context, network, addr string, opts ...gost.BypassOption) bool {
+ // localhost
+ // localhost:80
+ // localhost:1000-2000
+ // *.zone
+ // *.zone:80
+ // *.zone:1000-2000
+ // 127.0.0.1
+ // 127.0.0.1:80
+ // 127.0.0.1:1000-2000
+ // 10.0.0.1/8
+ // 10.0.0.1/8:80
+ // 10.0.0.1/8:1000-2000
+ // 1000::/16
+ // 1000::/16:80
+ // 1000::/16:1000-2000
+ // [2001:db8::1]/64
+ // [2001:db8::1]/64:80
+ // [2001:db8::1]/64:1000-2000
+ // 2001:db8::1
+ // [2001:db8::1]
+ // [2001:db8::1]:80
+ // [2001:db8::1]:1000-2000
+ host, destPort, err := net.SplitHostPort(addr)
+ if err != nil {
+ p.logger.Error("blocked", slog.String("error", err.Error()))
+ return true // block
+ }
+ port, err := strconv.Atoi(destPort)
+ if err != nil {
+ port = -1
+ }
+ blocked := !p.IsAddrAllowed(host, port)
+ if blocked {
+ p.logger.Infof("blocked %s", addr)
+ }
+ return blocked
+}
+
+func (p *Whitelist) IsPortAllowed(port int, entry *Entry[any]) bool {
+ if entry.MinPort == 0 && entry.MaxPort == 0 {
+ return true
+ }
+ return entry.MinPort <= port && port <= entry.MaxPort
+}
+
+func (p *Whitelist) IsAddrAllowed(host string, port int) bool {
+ if ip := net.ParseIP(host); ip != nil {
+ for _, ipNet := range p.Networks {
+ if ipNet.Value.Contains(ip) {
+ // return true
+ return ipNet.IsPortAllowed(port)
+ }
+ }
+ for _, bypassIP := range p.IPs {
+ if bypassIP.Value.Equal(ip) {
+ // return true
+ return bypassIP.IsPortAllowed(port)
+ }
+ }
+ return false
+ }
+
+ for _, zone := range p.Zones {
+ if strings.HasSuffix(host, zone.Value) {
+ // return true
+ return zone.IsPortAllowed(port)
+ }
+ if host == zone.Value[1:] {
+ // For a zone ".example.com", we match "example.com" too.
+ // return true
+ return zone.IsPortAllowed(port)
+ }
+ }
+ for _, bypassHost := range p.Hosts {
+ if bypassHost.Value == host {
+ //return true
+ return bypassHost.IsPortAllowed(port)
+ }
+ }
+ return false
+}
+
+func (p *Whitelist) AddFromString(s string) {
+ entries := strings.Split(s, ",")
+ for _, entry := range entries {
+ host, minPort, maxPort, err := toAddrPorts(entry)
+ if err != nil {
+ continue
+ }
+ host = strings.TrimSpace(host)
+ if len(host) == 0 {
+ continue
+ }
+ if strings.Contains(host, "/") {
+ // We assume that it's a CIDR address like 127.0.0.0/8
+ if _, ipNet, err := net.ParseCIDR(host); err == nil {
+ p.AddNetwork(ipNet, minPort, maxPort)
+ }
+ continue
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ p.AddIP(ip, minPort, maxPort)
+ continue
+ }
+ if strings.HasPrefix(host, "*.") {
+ p.AddZone(host[1:], minPort, maxPort)
+ continue
+ }
+ p.AddHost(host, minPort, maxPort)
+ }
+}
+
+// AddIP specifies an IP address that will use the bypass proxy. Note that
+// this will only take effect if a literal IP address is dialed. A connection
+// to a named host will never match an IP.
+func (p *Whitelist) AddIP(ip net.IP, minPort, maxPort int) {
+ p.IPs = append(p.IPs, Entry[net.IP]{
+ Value: ip,
+ MinPort: minPort,
+ MaxPort: maxPort,
+ })
+}
+
+// AddNetwork specifies an IP range that will use the bypass proxy. Note that
+// this will only take effect if a literal IP address is dialed. A connection
+// to a named host will never match.
+func (p *Whitelist) AddNetwork(ipNet *net.IPNet, minPort, maxPort int) {
+ p.Networks = append(p.Networks, Entry[*net.IPNet]{
+ Value: ipNet,
+ MinPort: minPort,
+ MaxPort: maxPort,
+ })
+}
+
+// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of
+// "example.com" matches "example.com" and all of its subdomains.
+func (p *Whitelist) AddZone(zone string, minPort, maxPort int) {
+ zone = strings.TrimSuffix(zone, ".")
+ if !strings.HasPrefix(zone, ".") {
+ zone = "." + zone
+ }
+ p.Zones = append(p.Zones, Entry[string]{
+ Value: zone,
+ MinPort: minPort,
+ MaxPort: maxPort,
+ })
+}
+
+// AddHost specifies a host name that will use the bypass proxy.
+func (p *Whitelist) AddHost(host string, minPort, maxPort int) {
+ host = strings.TrimSuffix(host, ".")
+ p.Hosts = append(p.Hosts, Entry[string]{
+ Value: host,
+ MinPort: minPort,
+ MaxPort: maxPort,
+ })
+}
+
+func toAddrPorts(input string) (string, int, int, error) {
+ addr, ports := parseAddrPorts(input)
+ addr = strings.TrimPrefix(addr, "[")
+ addr = strings.Replace(addr, "]", "", 1)
+ if addr == "" {
+ return "", 0, 0, fmt.Errorf("invalid address: input %s", input)
+ }
+ if ports != "" {
+ parts := strings.Split(ports, "-")
+ switch len(parts) {
+ case 1:
+ port, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return "", 0, 0, fmt.Errorf("invalid port: input %s: %w", input, err)
+ }
+ return addr, port, port, nil
+ case 2:
+ port1, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return "", 0, 0, fmt.Errorf("invalid port: input %s: %w", input, err)
+ }
+ port2, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return "", 0, 0, fmt.Errorf("invalid port: input %s: %w", input, err)
+ }
+ return addr, port1, port2, nil
+ default:
+ return "", 0, 0, fmt.Errorf("invalid ports: input %s", input)
+ }
+ }
+ return addr, 0, 0, nil
+}
+
+func parseAddrPorts(input string) (string, string) {
+ if strings.HasPrefix(input, "[") {
+ idx := strings.Index(input, "]")
+ if idx == -1 {
+ return "", ""
+ }
+ if len(input) > idx+2 {
+ idx2 := strings.LastIndex(input, ":")
+ if idx2 > idx {
+ return input[:idx2], input[idx2+1:]
+ } else {
+ return input, ""
+ }
+ }
+ return input[:idx+1], ""
+ } else {
+ idx := strings.Index(input, "/")
+ if idx != -1 {
+ idx2 := strings.LastIndex(input, ":")
+ if idx2 > idx {
+ return input[:idx2], input[idx2+1:]
+ } else {
+ return input, ""
+ }
+ } else {
+ ip := net.ParseIP(input)
+ // ipv6
+ if ip != nil && strings.Contains(input, ":") {
+ return ip.String(), ""
+ } else {
+ idx2 := strings.LastIndex(input, ":")
+ if idx2 != -1 {
+ return input[:idx2], input[idx2+1:]
+ } else {
+ return input, ""
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/util/whitelist_test.go b/pkg/util/whitelist_test.go
new file mode 100644
index 0000000..230da17
--- /dev/null
+++ b/pkg/util/whitelist_test.go
@@ -0,0 +1,379 @@
+package util
+
+import (
+ "context"
+ "net"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestToAddrPorts(t *testing.T) {
+ tests := []struct {
+ input string
+ addr string
+ minPort int
+ maxPort int
+ }{
+ {
+ input: "localhost",
+ addr: "localhost",
+ },
+ {
+ input: "localhost:80",
+ addr: "localhost",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "localhost:1000-2000",
+ addr: "localhost",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ {
+ input: "*.zone",
+ addr: "*.zone",
+ },
+ {
+ input: "*.zone:80",
+ addr: "*.zone",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "*.zone:1000-2000",
+ addr: "*.zone",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ {
+ input: "127.0.0.1",
+ addr: "127.0.0.1",
+ },
+ {
+ input: "127.0.0.1:80",
+ addr: "127.0.0.1",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "127.0.0.1:1000-2000",
+ addr: "127.0.0.1",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ {
+ input: "10.0.0.1/8",
+ addr: "10.0.0.1/8",
+ },
+ {
+ input: "10.0.0.1/8:80",
+ addr: "10.0.0.1/8",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "10.0.0.1/8:1000-2000",
+ addr: "10.0.0.1/8",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ {
+ input: "1000::/16",
+ addr: "1000::/16",
+ },
+ {
+ input: "1000::/16:80",
+ addr: "1000::/16",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "1000::/16:1000-2000",
+ addr: "1000::/16",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ {
+ input: "[2001:db8::1]/64",
+ addr: "2001:db8::1/64",
+ },
+ {
+ input: "[2001:db8::1]/64:80",
+ addr: "2001:db8::1/64",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "[2001:db8::1]/64:1000-2000",
+ addr: "2001:db8::1/64",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ {
+ input: "2001:db8::1",
+ addr: "2001:db8::1",
+ },
+ {
+ input: "[2001:db8::1]",
+ addr: "2001:db8::1",
+ },
+ {
+ input: "[2001:db8::1]:80",
+ addr: "2001:db8::1",
+ minPort: 80,
+ maxPort: 80,
+ },
+ {
+ input: "[2001:db8::1]:1000-2000",
+ addr: "2001:db8::1",
+ minPort: 1000,
+ maxPort: 2000,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.input, func(t *testing.T) {
+ addr, minPort, maxPort, err := toAddrPorts(tc.input)
+ require.Nil(t, err)
+ require.Equal(t, tc.addr, addr)
+ require.Equal(t, tc.minPort, minPort)
+ require.Equal(t, tc.maxPort, maxPort)
+ })
+ }
+}
+
+func TestWhitelist(t *testing.T) {
+ tests := []struct {
+ name string
+ list string
+ allowed []string
+ blocked []string
+ }{
+
+ {
+ name: "host while list",
+ list: "localhost,*.zone,127.0.0.1,10.0.0.1/8,1000::/16",
+ allowed: []string{
+ "localhost:123",
+ "zone:123",
+ "foo.zone:123",
+ "127.0.0.1:123",
+ "10.1.2.3:123",
+ "[1000::]:123",
+ },
+ blocked: []string{
+ "example.com:123",
+ "1.2.3.4:123",
+ "[1001::]:123",
+ "172.217.7.14:443",
+ "[2607:f8b0:4006:800::200e]:443",
+ "example.com:80",
+ },
+ },
+ {
+ name: "all IPv4 networks",
+ list: "0.0.0.0/0",
+ allowed: []string{
+ "0.0.0.0:6443",
+ "0.0.0.0:6444",
+ "1.2.3.4:123",
+ "172.217.7.14:443",
+ },
+ blocked: []string{
+ "example.com:123",
+ "[1001::]:123",
+ "[2607:f8b0:4006:800::200e]:443",
+ "example.com:80",
+ "localhost:123",
+ "zone:123",
+ "foo.zone:123",
+ },
+ },
+ {
+ name: "all IPv6 networks",
+ list: "0::/0",
+ allowed: []string{
+ "[1001::]:123",
+ "[2607:f8b0:4006:800::200e]:443",
+ },
+ blocked: []string{
+ "0.0.0.0:6443",
+ "1.2.3.4:123",
+ "172.217.7.14:443",
+ "example.com:123",
+ "example.com:80",
+ "localhost:123",
+ "zone:123",
+ "foo.zone:123",
+ },
+ },
+ {
+ name: "port while list",
+ list: "localhost:8080,*.zone:8080,127.0.0.1:8080,10.0.0.1/8:8080,1000::/16:8080",
+ allowed: []string{
+ "10.1.2.3:8080",
+ "127.0.0.1:8080",
+ "localhost:8080",
+ "zone:8080",
+ "foo.zone:8080",
+ "[1000::]:8080",
+ },
+ blocked: []string{
+ "10.1.2.3:123",
+ "127.0.0.1:123",
+ "localhost:123",
+ "zone:123",
+ "foo.zone:123",
+ "[1000::]:123",
+ "example.com:123",
+ "1.2.3.4:123",
+ "[1001::]:123",
+ "172.217.7.14:443",
+ "[2607:f8b0:4006:800::200e]:443",
+ "example.com:80",
+ },
+ },
+ {
+ name: "port 8080 on all IPv4 networks",
+ list: "0.0.0.0/0:8080",
+ allowed: []string{
+ "0.0.0.0:8080",
+ "1.2.3.4:8080",
+ "172.217.7.14:8080",
+ },
+ blocked: []string{
+ "0.0.0.0:6443",
+ "0.0.0.0:6444",
+ "1.2.3.4:123",
+ "172.217.7.14:443",
+ "example.com:123",
+ "[1001::]:123",
+ "[2607:f8b0:4006:800::200e]:443",
+ "example.com:80",
+ "localhost:123",
+ "zone:123",
+ "foo.zone:123",
+ },
+ },
+ {
+ name: "port range while list",
+ list: "localhost:4000-5000,*.zone:4000-5000,127.0.0.1:4000-5000,10.0.0.1/8:4000-5000,1000::/16:4000-5000",
+ allowed: []string{
+
+ "10.1.2.3:4000",
+ "127.0.0.1:4000",
+ "localhost:4000",
+ "zone:4000",
+ "foo.zone:4000",
+ "[1000::]:4000",
+
+ "10.1.2.3:4500",
+ "127.0.0.1:4500",
+ "localhost:4500",
+ "zone:4500",
+ "foo.zone:4500",
+ "[1000::]:4500",
+
+ "10.1.2.3:5000",
+ "127.0.0.1:5000",
+ "localhost:5000",
+ "zone:5000",
+ "foo.zone:5000",
+ "[1000::]:5000",
+ },
+ blocked: []string{
+ "10.1.2.3:3999",
+ "127.0.0.1:3999",
+ "localhost:3999",
+ "zone:3999",
+ "foo.zone:3999",
+ "[1000::]:3999",
+ "example.com:3999",
+ "1.2.3.4:3999",
+ "[1001::]:3999",
+ "172.217.7.14:3999",
+ "[2607:f8b0:4006:800::200e]:3999",
+ "example.com:3999",
+
+ "10.1.2.3:5001",
+ "127.0.0.1:5001",
+ "localhost:5001",
+ "zone:5001",
+ "foo.zone:5001",
+ "[1000::]:5001",
+ "example.com:5001",
+ "1.2.3.4:5001",
+ "[1001::]:5001",
+ "172.217.7.14:5001",
+ "[2607:f8b0:4006:800::200e]:5001",
+ "example.com:5001",
+ },
+ },
+ {
+ name: "port range 4000-5000 on all IPv4 networks",
+ list: "0.0.0.0/0:4000-5000",
+ allowed: []string{
+ "0.0.0.0:4000",
+ "1.2.3.4:4000",
+ "172.217.7.14:4000",
+
+ "0.0.0.0:4500",
+ "1.2.3.4:4500",
+ "172.217.7.14:4500",
+
+ "0.0.0.0:5000",
+ "1.2.3.4:5000",
+ "172.217.7.14:5000",
+ },
+ blocked: []string{
+ "0.0.0.0:6443",
+ "0.0.0.0:6444",
+ "1.2.3.4:123",
+ "172.217.7.14:443",
+ "example.com:123",
+ "[1001::]:123",
+ "[2607:f8b0:4006:800::200e]:443",
+ "example.com:80",
+ "localhost:123",
+ "zone:123",
+ "foo.zone:123",
+
+ "0.0.0.0:3999",
+ "1.2.3.4:3999",
+ "172.217.7.14:3999",
+
+ "0.0.0.0:5001",
+ "1.2.3.4:5001",
+ "172.217.7.14:5001",
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ wl := NewWhitelist()
+ wl.AddFromString(tc.list)
+
+ for _, addr := range tc.allowed {
+ host, sport, err := net.SplitHostPort(addr)
+ require.Nil(t, err)
+ port, err := strconv.Atoi(sport)
+ require.Nil(t, err)
+ allowed := wl.IsAddrAllowed(host, port)
+ require.True(t, allowed, "addr %s should be allowed but it is blocked", addr)
+ require.False(t, wl.Contains(context.Background(), "tcp", addr), "bypass for %s should not be blocked", addr)
+
+ }
+ for _, addr := range tc.blocked {
+ host, sport, err := net.SplitHostPort(addr)
+ require.Nil(t, err)
+ port, err := strconv.Atoi(sport)
+ require.Nil(t, err)
+ allowed := wl.IsAddrAllowed(host, port)
+ require.False(t, allowed, "addr %s should be blocked but it is allowed", addr)
+ require.True(t, wl.Contains(context.Background(), "tcp", addr), "bypass for %s should be blocked", addr)
+ }
+ })
+ }
+}
diff --git a/tests/cfssl/Earthfile b/tests/cfssl/Earthfile
new file mode 100644
index 0000000..213a5b4
--- /dev/null
+++ b/tests/cfssl/Earthfile
@@ -0,0 +1,36 @@
+VERSION 0.7
+FROM cfssl/cfssl:v1.6.4
+WORKDIR "/workdir"
+RUN apt-get update && apt-get install -y --no-install-recommends openssl bc && rm -rf /var/lib/apt/lists/*
+
+ARG --global GEN_DIR = certs
+
+version:
+ RUN --no-cache cfssl version
+
+clean:
+ LOCALLY
+ RUN rm -f *.pem
+ RUN rm -f *.csr
+ RUN rm -rf certs/
+
+sources:
+ COPY *.json .
+
+ca:
+ FROM +sources
+ RUN cfssl gencert -initca ca.json | cfssljson -bare ca
+
+ RUN chmod +r *.pem
+
+ SAVE ARTIFACT ca-key.pem AS LOCAL ${GEN_DIR}/ca-key.pem
+ SAVE ARTIFACT ca.pem AS LOCAL ${GEN_DIR}/ca.pem
+
+hosts:
+ FROM +ca
+
+ RUN cfssl gencert -ca ca.pem -ca-key ca-key.pem -config cfssl.json -profile=proxy proxy.json | cfssljson -bare proxy
+ RUN cfssl gencert -ca ca.pem -ca-key ca-key.pem -config cfssl.json -profile=agent agent.json | cfssljson -bare agent
+
+ RUN chmod +r *.pem
+ SAVE ARTIFACT *.pem AS LOCAL ${GEN_DIR}/
diff --git a/tests/cfssl/agent.json b/tests/cfssl/agent.json
new file mode 100644
index 0000000..f4b5833
--- /dev/null
+++ b/tests/cfssl/agent.json
@@ -0,0 +1,12 @@
+{
+ "CN": "4711",
+ "key": {
+ "algo": "rsa",
+ "size": 4096
+ },
+ "names": [
+ {
+ "O": "Agent"
+ }
+ ]
+}
diff --git a/tests/cfssl/ca.json b/tests/cfssl/ca.json
new file mode 100644
index 0000000..5d48db0
--- /dev/null
+++ b/tests/cfssl/ca.json
@@ -0,0 +1,13 @@
+{
+ "CN": "Test Root CA",
+ "key": {
+ "algo": "rsa",
+ "size": 4096
+ },
+ "names": [
+ {
+ "O": "Test",
+ "OU": "Test Root CA"
+ }
+ ]
+}
diff --git a/tests/cfssl/certs/agent-key.pem b/tests/cfssl/certs/agent-key.pem
new file mode 100644
index 0000000..91fd255
--- /dev/null
+++ b/tests/cfssl/certs/agent-key.pem
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIJJwIBAAKCAgEAvqRltxqLDSI68nJspeYSlANWAwOltBkvooRa1AmQZhr69an7
+YRWU/b8637+rDsPaFU+N6trPL0iyfYnpq/DBYNCtRCSMh458NStF1HzggZfdQ7sR
+cyVa6y/pvYEtiKf5wQXsI0EFL4fOCC5ucvtk0mLxAVGZP5c2l8RPrrhiWaU6t06R
+tjZoXw/bNHYb5dzyA+pq1vxwWrZJkWmuFqVDY/2fhPunr62mwmwQ8nnODaFOXZk0
+fRGs2IY3DzkN6pRPvQ8zm2YRW06JbOWWFhN3DDjar1rHbFJ99Q0Pw/ifdLGl4wlC
+sYbXVC4oEzmYKLgaxCTmO47rpxmsgIjuVMV/YXz8WdClMmn8i4t2ZDZEydrJ3bxf
+GXX51V2nvs+UQrtYrUNNz1w7zmSDWyekS08vBV4sHn6/Ul3wEuX7+7SEz0yCjU4T
+tgM6mXZuyHvvAxxD2ltwHNwc8Uh5njkIb8k2ry1cNUBFFiuFX7VgpLuzJvyInG5v
+hiEVjZkkACNAKT73zj2TXbd2WkNSh79x2Y9Sy4BFsQg1l0VPvEkv6UH+8UHuvunQ
+IAQRbKcLGt3gAsLQ9ERgqH1RdQZX0N0tzJAPrTlz7rf1+hTEy93aA7vhNbc5powL
+1T5Dv09PZtrE116SLKWyMTK4LCYiDd2qM2HlL/laRH9p4xqUFFsB43OK8PsCAwEA
+AQKCAgBgQCAakgwiVWXtglfYapB2qjiCzRScGRszsh8pbqq44mZSIcAJBBx1AFd5
+IAv9KGSy3beJG2//L3TubPLNHICFoNXZ0Zoh1o5fSbm3zlSLGWFdENV+jR2aIFai
+ltWmaShvi83s/qbfmHEtMEQTSVld3xZO1CPLN00Y0sRoMi91kzZR1hk7Jb0MQbUq
+h3cOVr7Zu0C1yj3vjpkWEYUTadzcXvBq86N1zvaismzb/yNJPmeSWgdmHcZmi1zB
+Es0z4i1gBozHqICa2MwJbuCXxLwWL545alvFFOEDF9Ud3Cilggt+1O4XMz0EA0m4
+axunc0wQO5ECAOZ5Nz0gqAGsSwpEDtS/Zapl4hTgasbmk+x6t/EMYd3I8a8+1mV2
+Q9iaVgO9ZNyF0VJw4DBscXRQAtWqti+Y6vjzbu/xCdgPZ3dL38LWHZX+zIB03Q8P
+4LGvU0KoAQMLBNbl5j5TC2Mcrmc1M+WFzJqYh1tBeymAe9hpMG1ZlsgrGoDsACbE
+9TsFuf045dfU3IXy7srY1VfFZn1mIVoqUWJe+cl69Ru7eEmo0vp68HFLjxCPkdkW
+bpbpCEp9B8Y4RQn+kSSWWl/+SYg2LBjOVi4HbRD7wq40tPX31NJaxsHhvfjHKNtt
+VCx1ir/M9JrDWygm56FafBtCYQwSD3t3td8LxS0HH5zzIAxAAQKCAQEA5gPm/Krr
+Ei4LH5PgwZLWXYz0D5IRE9ZsgbnvDjCZTSc4z4YlvXIUJQzN5qspfmGxB34aJJA1
+JbPxZtnMPAwJGyuV5Vo02sOQCAHyS1Z6E71UqKF/3Y+WscnQcKmGyNYNYu+8lB+r
+F4tHslgBBgpUaYz6zn7Mev65lRC0pl/l8GWiZhfLT8bvjjsf8VdlBktuYrAlltwk
+5aVUpM6LMVtZ5l4OstJGrGYcTvpwMKVeQwyjFLNSgWpX4bCKDOhrmlFMvNmrBoRl
+CRPWpQUb2rxXLHvkhON26cImdWi5NAccbR56P/E/7IxOtUpud2FTz5UpFiCbECD5
+YVTZF3fC5nzigQKCAQEA1C3Q89cIXE29VE7R+ry7QPwDRvdJIVULuXtUopL2z4IE
+OyFzya37N8cqdDeC77S2yNvzT6W35cOO+d0piEPjuWVnj4BXTB9h1mEZqGndqwxA
+15yCMclu6ZgWfymeGJxFev2ac3Jgarmnb6MqGvm+NDtuGH/QKHjSBuawx6Vgm0x0
+XA6tO+dANdnyM7stPruBhRI8X+ukKt1+bjtb7fAV5fAlu1p5xd+QcvwNpnDtisHM
+1gb11cHy2CBJTCMFh0WPH2VTLMSKcAVQrN7gLg87DeGJYoqXxTLTg/hVJM78X3X9
+QctnM4N+P0MOlWeV0hO3crffhWQiYyTkgyL87CGdewKCAQBtBLB4VTIxXa1b/CTs
+2oHLuUD8GEkL0/d9zPinCxW94bcldPlyPx2ZKeZ1S+7QvdDAMl6FsevewNjL3LLW
+SwYN4KydFhIzFbmwceu7FXOq43O6sUD/bE0KWxL2MwN1MS8LE4GX2yKmeBc1SkzB
+5id4F5/QO30DVrPzGQXmTVGYjTNZnDF60Fk+WnubUcbKIvpgwAgw5op+ZcwmiNak
+QB3t9+qTiuKAV61XKY//HoH82YJ6Dzwtpo+coqXr1EUb0SjD4Y8T+bBBiyuS70by
+d44BwX7gFUeJJ8I+p7IQHMa2WVmr8NZRcXbkqjCNWI32t0XP/QhBrr9ECIkUfGje
+AZcBAoIBAHjK9JA9NRdHcqfSj5xNYdVnI359PXbqcdhQrCg5vqT4Aeyf6MlCcSia
+DENJbxOEMCM9hNEtKPp3UKhTDlfzPmvHnSOHDyvZGdvwP6kvS/Ea8rdM9JnfcXMv
+EG+og8bDAJM6WXmr/dQEiZv2qfvdfjlCNDViXmEMF5WyM6YVMmB3MC9Qc7MMvfNq
+doaY4vM50EyvywtYnYeBvX6H8JgO/IiBJRn6MiVCV3v+ns7Ir5M1LaYTJFVjKxt7
+lf7wWS9fnFFlX+q2oZGQlRM1dy8BoL8QTSR6fljXzK7u65oe9HJsO5f/cmtTSsY9
+KMpJxHNqoh9/KpNIJIOI90bo3lCX+o0CggEAXAQ4sAVc34J/0DutKGym326UAVWY
+AthnHemnt9v9a9EGyy/BEaow66XyBu4RoOy7RY2oPsjv426orHjnwnlYLwjQjqLt
+OIpCzWodDlRZaXFoJfwWVNY/JB90/xbPJn6p33KRFbUAc3SMYx1IDHA+UYbwhFtj
+HoknEeEE8ztBeY7QNG6pXjZVnk46xLKMWtJFdxESUy+sP6ac/LOxCulvl+3RMvX5
+mkmx33+1tFrYWPkFUKDNmTo/8BuQq0YrtOSPf3LhNAUFGbXBs78II8l9/OU+12MX
+QbjPeUsMaeo+HNPa+nfEzK/k/MIFlvXQkSe6NeesA+36/iYwLjt6Nv3ysg==
+-----END RSA PRIVATE KEY-----
diff --git a/tests/cfssl/certs/agent.pem b/tests/cfssl/certs/agent.pem
new file mode 100644
index 0000000..66c7147
--- /dev/null
+++ b/tests/cfssl/certs/agent.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFaTCCA1GgAwIBAgIUYHnpBM/kIcfbQUN2i1Z9dY9g95EwDQYJKoZIhvcNAQEN
+BQAwPTENMAsGA1UEChMEVGVzdDEVMBMGA1UECxMMVGVzdCBSb290IENBMRUwEwYD
+VQQDEwxUZXN0IFJvb3QgQ0EwHhcNMjQwMjI0MTMzNDAwWhcNMjUwMjIzMTMzNDAw
+WjAfMQ4wDAYDVQQKEwVBZ2VudDENMAsGA1UEAxMENDcxMTCCAiIwDQYJKoZIhvcN
+AQEBBQADggIPADCCAgoCggIBAL6kZbcaiw0iOvJybKXmEpQDVgMDpbQZL6KEWtQJ
+kGYa+vWp+2EVlP2/Ot+/qw7D2hVPjerazy9Isn2J6avwwWDQrUQkjIeOfDUrRdR8
+4IGX3UO7EXMlWusv6b2BLYin+cEF7CNBBS+HzggubnL7ZNJi8QFRmT+XNpfET664
+YlmlOrdOkbY2aF8P2zR2G+Xc8gPqatb8cFq2SZFprhalQ2P9n4T7p6+tpsJsEPJ5
+zg2hTl2ZNH0RrNiGNw85DeqUT70PM5tmEVtOiWzllhYTdww42q9ax2xSffUND8P4
+n3SxpeMJQrGG11QuKBM5mCi4GsQk5juO66cZrICI7lTFf2F8/FnQpTJp/IuLdmQ2
+RMnayd28Xxl1+dVdp77PlEK7WK1DTc9cO85kg1snpEtPLwVeLB5+v1Jd8BLl+/u0
+hM9Mgo1OE7YDOpl2bsh77wMcQ9pbcBzcHPFIeZ45CG/JNq8tXDVARRYrhV+1YKS7
+syb8iJxub4YhFY2ZJAAjQCk+9849k123dlpDUoe/cdmPUsuARbEINZdFT7xJL+lB
+/vFB7r7p0CAEEWynCxrd4ALC0PREYKh9UXUGV9DdLcyQD605c+639foUxMvd2gO7
+4TW3OaaMC9U+Q79PT2baxNdekiylsjEyuCwmIg3dqjNh5S/5WkR/aeMalBRbAeNz
+ivD7AgMBAAGjfzB9MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
+AgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUi2konUCOOvtKgkMH
+5+5sbxWkrWUwHwYDVR0jBBgwFoAUn0BlWU3VQ785XztLzsgimIsO5HYwDQYJKoZI
+hvcNAQENBQADggIBAGyJE8bTTEY+7uvrrk0wkRPMYFvylGyaoiYncr67UW1aInQM
+K919Me1kI4hgOS38L1tAByWVGVrusqvjg6wM0p9FaGmmWy6UdEkx8lXlzIbWNMDI
+RMw3QPKnaWAaydhXc/iVRVtWA6XIp2I2RY4by+7B4Jwd0hVOXfOmi5UGs21INDmG
+T3gPnJBhrhz7/DH8E0bsiiCT+OYY9XsPv/dzAGQR+rajsPqQMU4MzSpp/sBufBL8
+UlyNfpPeK049fiHwc3xQnm9DKtI3rvpFer51XQGa6dn6AORPSzYS0hCAyIeWDUe3
+RTgAz+9lZDbPZHKHMq6R7N9zxa3kxvCBd8lm6IiyTXrFNr4nTZdV6naPMf0Rm6h2
+UIaIXO4p2DJyaOghrgIpDZArkxQ18iRdneYz1mvD2j5RNdfNEEozYDNI5yfmXz02
+hItZZuTV3WsiFK/eZfDtuovdakbBEgeCidjb4IocpFHNVT8o9MU8DqwrFynXrKn7
+yS7MgzM++LjONTqoEw8LkgqlRVIn59EVw4y6CGyMmLltbuo3R34+z1z5lMCd7ZeE
+SYHLr32oSfnz2Lvha1kgmcIVmo7d1Eq6pb/ucoShl4PUxn2nR3SBxo7CVodiZLob
+URMSQi6R1us8ssx+mrARjQDi+COf1EZs/ylTLRmhJvDPLmsVR7LqQA088DsQ
+-----END CERTIFICATE-----
diff --git a/tests/cfssl/certs/ca-key.pem b/tests/cfssl/certs/ca-key.pem
new file mode 100644
index 0000000..7624601
--- /dev/null
+++ b/tests/cfssl/certs/ca-key.pem
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+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
+-----END RSA PRIVATE KEY-----
diff --git a/tests/cfssl/certs/ca.pem b/tests/cfssl/certs/ca.pem
new file mode 100644
index 0000000..572624d
--- /dev/null
+++ b/tests/cfssl/certs/ca.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFSjCCAzKgAwIBAgIUMAyZ/JYumJ54DWIvhlu1jHyfxQswDQYJKoZIhvcNAQEN
+BQAwPTENMAsGA1UEChMEVGVzdDEVMBMGA1UECxMMVGVzdCBSb290IENBMRUwEwYD
+VQQDEwxUZXN0IFJvb3QgQ0EwHhcNMjQwMjI0MTMzNDAwWhcNMjkwMjIyMTMzNDAw
+WjA9MQ0wCwYDVQQKEwRUZXN0MRUwEwYDVQQLEwxUZXN0IFJvb3QgQ0ExFTATBgNV
+BAMTDFRlc3QgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
+ALdirrV8h2vYWVRAlwzyIbY41Bq/3zW+2JfpL55gC4FMDHhG/sUbfFf/1rFjnsbv
+xym0SAIUDn2av1kvbhHv+gx2Mme+dCH9XWfIZfNHR005/anSxXBlijZzTb6Uu1td
+qsLgEKMmLn/G6cm/FM/yevxQc1xNv3c0DeGVwzrdT7HuUkKIjvREJA1TCwz7h4IQ
+pOamkwG8WTxA4QH0NQAc5hJuUrUODxueX3C3OKXDpLjmjRH49cIX3S3yCIJpS0uk
+WzBHIKmjWd8LsZFdKbpSeesIyi7tFMeU+c+wQ5K2mFHXyIheXshE5bS971ogE3jH
+7yJIO8FnydTAr7Hqpqgo+WKob+iw5/D/nSKR9KpSg7+yBgFQtM2Z1yO42tHtlPmU
+HTBpt3PkyLxV9eCuMqzXgRseovXMvpuvm5Kzc0dPNkr6lPqY+UNoYUxR5o/XoCHi
+ocbrvM3eWPyavFPSfRoQ6iXD6SK6wlAQYtFvMZoSurACOCrWxLVl1enF2xzzHaaK
+F5762tcYzlfqg1QGoAXzsxSg0bDhMothXd5CbhrhZQIxh5cX+hLI4S5+9wB1tYpY
+ldPKOSvI0uc7fU101uzNrp3ICP0ZRmnf0LnNkOZtpZTpr6MwJ/kkNtVgpI/PrWNE
++KyO2gryhCMEYdw3ny66oInJdgvihtvazMt+rKz7aBjTAgMBAAGjQjBAMA4GA1Ud
+DwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSfQGVZTdVDvzlf
+O0vOyCKYiw7kdjANBgkqhkiG9w0BAQ0FAAOCAgEAA/eJOhvGsK5eQ8EQRkp20d3B
+pfIanND4w0KD9ewqvQkGE9bTIJJghUl1Zkzc27tP+n+uG7D/1ncLvzIizyrvber2
+/4uWqye9oLZ3/beROX6tx3gNS22xkuHhnRIQpxOAV8Awc/0iw931KsFgO81uQOGr
+LQDP5HtlHeJS8oEd7ynYvkihowCtxz3ddPOSwcEoI/smPHDZJ2uSmBkRis0mi7SQ
+HxGKGmVmXLLyzZDNqJdTWYFi2d//IWImGd2mJn9R+pCI0g8OFYypQqpCQTHUL5Uv
+Z1CNMvvV6z+sdw9VsDAWSMQ2VoGfIYbbOROa5aXutJPtGOUCNEUZ50BUUgMgUDC6
+8NSSj/rGrI+WOnnbE5xt2Mi8dzPzMVNCZoqJkbHL+LEj+aDU1OjY8aRP0y6BVijY
+o/qYEqbwYJHgzgUnN6n+ao3ce6ahMqrjj5aMkEZ2wznFJi/9DUeyW0HbDmbvUQ7e
+z55YGrh9JLL6Q5mybjiBRPi1Ky9kcI1iDqpHn55BR3yUvOYOA5pyskM7I4NZ3yIv
+WetyokGqAjZ9tKlpamtxavrwdPMIeXIP34UoHC9z9nSXm36/hvwoEO4lIg/zA6Pw
+Tbz6hogO5zJDhcdAxCZpSsKSjE5DdsfzkqzFFtpBEFL8aIf+870wPBFbGbrRMes7
+nv72JwkxvY55hrld0Do=
+-----END CERTIFICATE-----
diff --git a/tests/cfssl/certs/proxy-key.pem b/tests/cfssl/certs/proxy-key.pem
new file mode 100644
index 0000000..8504295
--- /dev/null
+++ b/tests/cfssl/certs/proxy-key.pem
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+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
+-----END RSA PRIVATE KEY-----
diff --git a/tests/cfssl/certs/proxy.pem b/tests/cfssl/certs/proxy.pem
new file mode 100644
index 0000000..155d283
--- /dev/null
+++ b/tests/cfssl/certs/proxy.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFnTCCA4WgAwIBAgIUKuWJ/2GY1G5LbHVylESqpEKKZa0wDQYJKoZIhvcNAQEN
+BQAwPTENMAsGA1UEChMEVGVzdDEVMBMGA1UECxMMVGVzdCBSb290IENBMRUwEwYD
+VQQDEwxUZXN0IFJvb3QgQ0EwHhcNMjQwMjI0MTMzNDAwWhcNMjUwMjIzMTMzNDAw
+WjAgMQ4wDAYDVQQKEwVQcm94eTEOMAwGA1UEAxMFcHJveHkwggIiMA0GCSqGSIb3
+DQEBAQUAA4ICDwAwggIKAoICAQDItCaKTdKsa68cbF1hmpt+LGkDC+JKyu8OkgPq
+qysccLKR8F/8Evn6L52g4ePvDiMtZAOASXi9p3Jk+tEFyY0YjQC4qKjSpY6wmX5H
+xPXV0j5gWtYVzz6RFZugN9r2SUo7u1UGKOc9pRgb1dfHISn4gfRpe2+RUDj9zUAl
+4rJPEpYcDW5w+Di91OGKGghFUWOpgvpvpBYpNmquIdX612LLQNaTR8GGdF1+MOP6
+vOoJARPARh+ScaDi7th6Zhc61HAYA58acFeWnXPhESpNUdtbN/CqpN75GeTmQ+9B
+NcVWkE/mNNcitxFt/FyT0qrFLQPY9pUNoktJKmp0aEWu1V3sv6Da9zmLDBHVmeww
+3xoPKMMCL0u9lT755r2MduOkpcIRCFZn1Oawj0jIwmGku3WNe/dAcrUajhymSdW0
+Pza8ahvdDc4o5H6jf5Dsn5WeTHatSkaVRFgwvur5B3e85nNQ9LXt2EXDHYuCwt8x
+pBFGSJeb0eR8fbjyPTzcjp09PBSKzHtfocLhVyv8PTvWaSVmjZXPZzVn7uiT9b/7
+KxtOQ5zjjquyZSFpM80Xx7PB+z9GU6qj0G5Z55kdApfXMskmPwin4qjAbj/FnkoA
+VblmDzEcofPFk4yD93Xl7Ed13eCMH73D9XxlLZsovPO0dHKpAaDSWI4Zd5HQZk0a
+CoUFwQIDAQABo4GxMIGuMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEF
+BQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSuqsjWVxmR7dhSqg6bTkS+2mva
+UjAfBgNVHSMEGDAWgBSfQGVZTdVDvzlfO0vOyCKYiw7kdjA5BgNVHREEMjAwggls
+b2NhbGhvc3SCBnNlcnZlcoIFcHJveHmCCHByb3h5LWxihwR/AAABhwQAAAAAMA0G
+CSqGSIb3DQEBDQUAA4ICAQCZx9PMEbyqCv11fvBdGIg+HkTZLLy0wfaYHZqaD4p+
+JfUq5w0gj1OqBFrd5RLhGrUC22y7qQ4fH6gezZORjP/9N8Ke5LPne/Wg1ObsWayi
+5iKNkmBXEKEEF0DZlZPpZG5O9E6fuiNkS7F3L+lP5AlmTdjIuVQCR5o9usgmWy0U
+lFto087ZDbZBlYaOkjJJFOijOvL7ftVoBds4ccKEV97aK3wy6vd5Do7ODUtMHUfV
+O0MSlIc4MLKCU9Gz14uRCE/gi2g/fVP/EUtiSIHbnKCEZNrzILjZFuesweyyV1sa
+wWctixLoV6W40uJKnPuegTjAwmO4nT3GgQZqFDZct3nfOyuI5MEF9dFAIPAXwcyY
+kkX6KnT5NTBen+5pDeK9NWWX+tF0P+O6Sm1nQA8tJ0i5vuY5dVeCrz3+7HOyw+rL
+04MRoPyvbBDg42qEEYbihFcZ0tgh7q3l58mNf7hfpm9XDEO1gYGc1MXGycHn6WFE
+Xqdq+OAD3N1XhjniupNHF6xW6WG1pxZY0IGcFxALkVgeBkXuQhYXfS6sjJfvLohw
+Eutw2Rcoud1jzgEykoZXW+OfzvRa9jhkW0pd+7Qw8Z7m7B6M+hCFZP/wiglbkYZz
+HnrHMesp0FrcaJbRxRDbYcBcAeUOxiJISv0qeYOOBY1l/GgmaaA/wYP5X8Najiw1
+iA==
+-----END CERTIFICATE-----
diff --git a/tests/cfssl/cfssl.json b/tests/cfssl/cfssl.json
new file mode 100644
index 0000000..23841c9
--- /dev/null
+++ b/tests/cfssl/cfssl.json
@@ -0,0 +1,53 @@
+{
+ "signing": {
+ "default": {
+ "expiry": "8760h"
+ },
+ "profiles": {
+ "intermediate_ca": {
+ "usages": [
+ "signing",
+ "digital signature",
+ "key encipherment",
+ "cert sign",
+ "crl sign",
+ "server auth",
+ "client auth"
+ ],
+ "expiry": "8760h",
+ "ca_constraint": {
+ "is_ca": true,
+ "max_path_len": 0,
+ "max_path_len_zero": true
+ }
+ },
+ "peer": {
+ "usages": [
+ "signing",
+ "digital signature",
+ "key encipherment",
+ "client auth",
+ "server auth"
+ ],
+ "expiry": "8760h"
+ },
+ "proxy": {
+ "usages": [
+ "signing",
+ "key encipherment",
+ "server auth"
+ ],
+ "expiry": "8760h"
+ },
+ "agent": {
+ "usages": [
+ "signing",
+ "key encipherment",
+ "client auth",
+ "server auth"
+ ],
+ "expiry": "8760h"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/cfssl/proxy.json b/tests/cfssl/proxy.json
new file mode 100644
index 0000000..8aead60
--- /dev/null
+++ b/tests/cfssl/proxy.json
@@ -0,0 +1,20 @@
+{
+ "CN": "proxy",
+ "key": {
+ "algo": "rsa",
+ "size": 4096
+ },
+ "names": [
+ {
+ "O": "Proxy"
+ }
+ ],
+ "hosts": [
+ "localhost",
+ "127.0.0.1",
+ "0.0.0.0",
+ "server",
+ "proxy",
+ "proxy-lb"
+ ]
+}
diff --git a/tests/ha/nginx-client.conf b/tests/ha/nginx-client.conf
new file mode 100644
index 0000000..61c5b0b
--- /dev/null
+++ b/tests/ha/nginx-client.conf
@@ -0,0 +1,14 @@
+events {
+}
+# https://nginx.org/en/docs/stream/ngx_stream_proxy_module.html#proxy_responses
+stream {
+ server {
+ listen 3128;
+ proxy_pass lb-servers;
+ proxy_timeout 1m;
+ }
+ upstream lb-servers {
+ server lb-1:3128;
+ server lb-2:3128;
+ }
+}
diff --git a/tests/ha/nginx-proxy.conf b/tests/ha/nginx-proxy.conf
new file mode 100644
index 0000000..24eab76
--- /dev/null
+++ b/tests/ha/nginx-proxy.conf
@@ -0,0 +1,14 @@
+events {
+}
+# https://nginx.org/en/docs/stream/ngx_stream_proxy_module.html#proxy_responses
+stream {
+ server {
+ listen 4242 udp;
+ proxy_pass proxy-servers;
+ proxy_timeout 1m;
+ }
+ upstream proxy-servers {
+ server proxy-1:4242;
+ server proxy-2:4242;
+ }
+}
diff --git a/tests/jwt/auth-agent-jwt-4711.b64 b/tests/jwt/auth-agent-jwt-4711.b64
new file mode 100644
index 0000000..76b41f3
--- /dev/null
+++ b/tests/jwt/auth-agent-jwt-4711.b64
@@ -0,0 +1 @@
+eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjQ3MTEiLCJyb2xlIjoiYWdlbnQiLCJpc3MiOiJyZXZlcnNlLWh0dHAiLCJzdWIiOiI0NzExIiwiZXhwIjoyMDIzMTMzNjE1LCJuYmYiOjE3MDc3NzM2MTUsImlhdCI6MTcwNzc3MzYxNSwianRpIjoiZWY2NGY5YjEtNmJjYS00NWI2LThjMzEtZTE4NTlhMmY3NjIzIn0.85JRhl7GE6glwNPIdUSwIIYpW0dqYmgAOYDFm1H1a7OWEJhM33KCsdVnSc67XWA28sypvNiApy3mnyKggq6gPg
\ No newline at end of file
diff --git a/tests/jwt/auth-agent-jwt-4712.b64 b/tests/jwt/auth-agent-jwt-4712.b64
new file mode 100644
index 0000000..eb2cf36
--- /dev/null
+++ b/tests/jwt/auth-agent-jwt-4712.b64
@@ -0,0 +1 @@
+eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjQ3MTIiLCJyb2xlIjoiYWdlbnQiLCJpc3MiOiJyZXZlcnNlLWh0dHAiLCJzdWIiOiI0NzEyIiwiZXhwIjoyMDIzMTMzNjE1LCJuYmYiOjE3MDc3NzM2MTUsImlhdCI6MTcwNzc3MzYxNSwianRpIjoiMzRkZDI4OGMtN2ViNS00YjhmLWI2YmUtNDgwODMzODIyYjRhIn0.ShXVUqKodv_Tx9gowBqgEWIaUNTV6Lb-QwsJ5I9D6k9i5nlxnFxpdZLH9AqiUuhhHjcV0mKfDXr45AacLAbpKw
\ No newline at end of file
diff --git a/tests/jwt/auth-client-jwt-4711.b64 b/tests/jwt/auth-client-jwt-4711.b64
new file mode 100644
index 0000000..f52cf35
--- /dev/null
+++ b/tests/jwt/auth-client-jwt-4711.b64
@@ -0,0 +1 @@
+eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjQ3MTEiLCJyb2xlIjoiY2xpZW50IiwiaXNzIjoicmV2ZXJzZS1odHRwIiwic3ViIjoiNDcxMSIsImV4cCI6MjAyMzEzMzYxNSwibmJmIjoxNzA3NzczNjE1LCJpYXQiOjE3MDc3NzM2MTUsImp0aSI6IjU3ZTIwMTk2LTg2MDAtNGI3Yi1iOGVlLWYyZmU5YmM4MDlkMiJ9.hN5axAy0vBruIpbxKWpdmQydSMkgUKT3gFRpAYGpaA7OpZbfqHxn5l0Cxh_wO5hvI_cU5LPh_gUm2tEgXZeXXQ
\ No newline at end of file
diff --git a/tests/jwt/auth-client-jwt-4712.b64 b/tests/jwt/auth-client-jwt-4712.b64
new file mode 100644
index 0000000..20174b7
--- /dev/null
+++ b/tests/jwt/auth-client-jwt-4712.b64
@@ -0,0 +1 @@
+eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjQ3MTIiLCJyb2xlIjoiY2xpZW50IiwiaXNzIjoicmV2ZXJzZS1odHRwIiwic3ViIjoiNDcxMiIsImV4cCI6MjAyMzEzMzYxNSwibmJmIjoxNzA3NzczNjE1LCJpYXQiOjE3MDc3NzM2MTUsImp0aSI6ImQ0NmUxOGVmLWJiYmQtNDIwNC1hMGI3LTA3NGIxY2Q5ZDc0MSJ9.EG6OzdpoitpP_RjqS_DI50CdY9emqaiDMzzh0oEdfnOAlRqKIq2HO4zxvGHIfsywDxjy9CY1-zNq3KG-ZWYelw
\ No newline at end of file
diff --git a/tests/jwt/auth-key-private.pem b/tests/jwt/auth-key-private.pem
new file mode 100644
index 0000000..64deeaa
--- /dev/null
+++ b/tests/jwt/auth-key-private.pem
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIH4ZcCwWtNbxCqMBI478HJTUHrfPR57tCNa9+xN+eHKJoAoGCCqGSM49
+AwEHoUQDQgAETwFQWRK6SLTMrIeS3Jq2Kg4cTF/8+q9BN6EHsWwCWWEotU5tVsks
+VMZouoGJzuL1dDY5Ul8n9ClOSNH+ebP2Qw==
+-----END EC PRIVATE KEY-----
diff --git a/tests/jwt/auth-key-public.pem b/tests/jwt/auth-key-public.pem
new file mode 100644
index 0000000..92f4293
--- /dev/null
+++ b/tests/jwt/auth-key-public.pem
@@ -0,0 +1,4 @@
+-----BEGIN EC PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETwFQWRK6SLTMrIeS3Jq2Kg4cTF/8
++q9BN6EHsWwCWWEotU5tVsksVMZouoGJzuL1dDY5Ul8n9ClOSNH+ebP2Qw==
+-----END EC PUBLIC KEY-----