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 + +

Architecture

+ +* 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 + +

HA

+ +* 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
Quic Server
HTTP Proxy
Inbound
HTTP Proxy...
Quic Client
Quic Client
HTTP Proxy
Outbound
HTTP Proxy...
Client
Client
HTTP Connect
HTTP Conne...
Agent 1
Agent 1
Proxy
Proxy
UDP
UDP
Quic Client
Quic Client
HTTP Proxy
Outbound
HTTP Proxy...
Agent N
Agent N
UDP
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
Proxy 1
reverse-http...
Client
Client
HTTP Connect
HTTP Conne...
reverse-http
Agent N
reverse-http...
quic UDP
quic UDP
reverse-http
Proxy N
reverse-http...
Memcached
Memcached
UDP
Load Balancer
UDP...
TCP
Load Balancer
TCP...
reverse-http
LB 1
reverse-http...
reverse-http
LB N
reverse-http...
HTTP Connect
HTTP Conne...
write the proxy address where an agent is connected
write the proxy address...
locate agent proxy
locate agent proxy
UDP
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----- +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 +-----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-----