diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f0fe25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +gokv +bin +build diff --git a/Earthfile b/Earthfile new file mode 100644 index 0000000..cdb8a94 --- /dev/null +++ b/Earthfile @@ -0,0 +1,12 @@ +VERSION 0.6 + +FROM golang:1.18-bullseye + +build: + WORKDIR /app + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN CGO_ENABLED=0 GOOS=linux go build -o ./build/frp-port-keeper ./main.go + + SAVE ARTIFACT build /build AS LOCAL ./build diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..242d2df --- /dev/null +++ b/Justfile @@ -0,0 +1,8 @@ +@start: + watchexec -r -e go -- go run . + +@test: + go test -v ./... + +@build: + go build -o bin/frp-port-keeper ./main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebd8bd0 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# frp-port-keeper +This is a plugin for the awesome [frp reverse proxy](https://github.com/fatedier/frp). + +| :exclamation: This is an early alpha version which needs further refactoring and improvements (see the [TODO](https://github.com/librepod/frp-port-keeper/tree/develop#todo) section) | +|------------------------------------------------------------------------------------------------------------------| + + +## What is it for? +The purpose of this plugin is to keep track of `remote_ports` that are being assigned +to frp clients upon initial connection to frp server. With this plugin, you can be +sure that whenever a client connects to an frp server, it would get the same `remote_port` +number that it was allocated initially. + +## Implementation +frp-port-keeper is a simple server that exposes a `POST /port-registrations` endpoint +that processes the *NewProxy* payload from the frp server. It is utilizing a simple +key/value store to track ports and correspinding users. Port allocation data +persists in json files under the `gokv` folder (The `gokv` folder is created in the +same directory where the frp-port-keeper executable is located). + +### Endpoint details +This handler is used to allocate ports for the proxy requests storing the mapping of +`user` param specified in frpc.ini and a free port available. + +#### Request +The hendler expects a JSON payload with the following structure: +```json +{ + "version": "0.1.0", + "op": "NewProxy", + "content": { + "user": { + "user": "myiphone", + }, + "proxy_name": "myiphone.my_wireguard_proxy", + "proxy_type": "udp" + } +} +``` +The corresponding frpc.ini config that generates this kind of payload should have +the following mandatory parameters specified: +```ini +[common] +server_addr = +server_port = 7000 +user = myiphone + +[my_wireguard_proxy] +type = udp +local_ip = 127.0.0.1 +local_port = 51820 +# remote_port may be omitted since the actual remote_port value will be assigned by the plugin +# remote_port = 1000 +``` + +#### Response +The response body will be a JSON with the following structure: + +```json +{ + "unchange": false, + "content": { + "user": { + "user": "myiphone", + }, + "proxy_name": "myiphone.my_wireguard_proxy", + "proxy_type": "udp", + "remote_port": 12345 + } +} +``` + +If the request is not valid due to missing mandatory fields in frpc.ini config, the +response will be of status 400 with the following content: +```json +{ + "error": "VALIDATEERR", + "message": "Invalid inputs. Please check your frpc.ini config", +} +``` + +In case if there is an internal error or no more free ports left to allocate, +the status would be 200 with the following content: + +```json +{ + "reject": true, + "reject_reason": "" +} +``` + +## Requirements + +frp version >= v0.48.0 + +It is possible that the plugin works for older version even though it has not been tested. + +## Usage + 1. Download the latest frp-port-keeper binary from the [releases page](https://github.com/librepod/frp-port-keeper/releases). + 2. Plugin needs to read the `allow_ports` value from the frps.ini file, + therefore you need to move the binary to the same folder where you have the + frps.ini file located and have the `allow_ports` value specified under + the `common` section. + 3. Register plugin in frps.ini like this: + ```ini + [plugin.frp-port-keeper] + addr = 127.0.0.1:8080 + path = /port-registrations + ops = NewProxy + ``` + 4. Run the frp-port-keeper plugin (preferably via a systemd service) and make + sure that it works fine (hit the `GET /ping` endpoint). + 5. Run the frp server. + 6. Profit. + +## TODO +[ ] Pass `allow_ports` param via cli +[ ] Add unit tests +[ ] Add proper error handling in case if payload is not as expected +[ ] Cross compile for other platforms (currently supports only amd64) +[ ] Refactor by improving modules/folder structure following golang best practices +[ ] Add systemd files and instructions to run the plugin as systemd service + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..e8c59fa --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,71 @@ +volumes: + minio_data: + redis_data: + +services: + + # minio: + # image: quay.io/minio/minio:RELEASE.2023-03-24T21-41-23Z + # restart: always + # ports: + # - 9000:9000 + # - 9001:9001 + # environment: + # - MINIO_ROOT_USER=minio + # - MINIO_ROOT_PASSWORD=minio123 + # - minio_data:/data + # volumes: + # command: + # - server + # - /data + # - --console-address + # - ":9001" + + redis: + image: redis:7-alpine + restart: always + ports: + - 6379:6379 + command: redis-server --save 60 1 --loglevel warning + profiles: + - redis + volumes: + - redis_data:/data + + redis-commander: + container_name: redis-commander + image: ghcr.io/joeferner/redis-commander:latest + restart: always + environment: + REDIS_HOSTS: redis + ports: + - 8081:8081 + profiles: + - redis + + frps: + image: snowdreamtech/frps:0.48.0 + container_name: frps + ports: + - 7400:7400 + - 7500:7500 + volumes: + - ./frps.ini:/etc/frp/frps.ini + network_mode: host + restart: always + + frpc: + image: snowdreamtech/frpc:0.48.0 + container_name: frpc + environment: + USER: testuser + ports: + - 7400:7400 + - 7500:7500 + depends_on: + - frps + volumes: + - ./frpc.ini:/etc/frp/frpc.ini + network_mode: host + restart: always + command: frpc -c /etc/frp/frpc.ini diff --git a/frpc.ini b/frpc.ini new file mode 100644 index 0000000..a89f21a --- /dev/null +++ b/frpc.ini @@ -0,0 +1,23 @@ +[common] +server_addr = 127.0.0.1 +server_port = 7000 +authentication_method = token +token = hello +# your proxy name will be changed to {user}.{proxy} +user = {{ .Envs.USER }} + +# communication protocol used to connect to server +# supports tcp, kcp, quic and websocket now, default is tcp +protocol = quic +# set admin address for control frpc's action by http api such as reload +admin_addr = 127.0.0.1 +admin_port = 7400 +admin_user = admin +admin_pwd = admin + +[my_wireguard_proxy] +type = udp +local_ip = 127.0.0.1 +local_port = 51820 +# The actual remote_port will be assigned by frp-port-keeper +# remote_port = 1000 diff --git a/frps.ini b/frps.ini new file mode 100644 index 0000000..07f0121 --- /dev/null +++ b/frps.ini @@ -0,0 +1,25 @@ +[common] +bind_addr = 0.0.0.0 +bind_port = 7000 +authentication_method = token +token = hello + +# only allow frpc to bind ports you list, if you don't specifyecify this, the +# frp-port-keeper will fall back to 1000-65535 port range +allow_ports = 6000-7000 + +# UDP port used for QUIC protocol. if not set, quic is disabled in frps. +quic_bind_port = 7000 +quic_keepalive_period = 10 +quic_max_idle_timeout = 30 +quic_max_incoming_streams = 100000 + +# admin UI +dashboard_port = 7500 +dashboard_user = librepod +dashboard_pwd = librepod-librepod + +[plugin.frp-port-keeper] +addr = 127.0.0.1:8080 +path = /port-registrations +ops = NewProxy diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f3ae176 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module main + +go 1.18 + +require ( + github.com/gin-gonic/gin v1.9.0 + github.com/philippgille/gokv v0.6.0 + github.com/philippgille/gokv/file v0.6.0 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + github.com/bytedance/sonic v1.8.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.11.2 // indirect + github.com/goccy/go-json v0.10.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/philippgille/gokv/encoding v0.0.0-20191011213304-eb77f15b9c61 // indirect + github.com/philippgille/gokv/util v0.0.0-20191011213304-eb77f15b9c61 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.9 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.5.0 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a15e0e2 --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA= +github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8= +github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= +github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= +github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/philippgille/gokv v0.0.0-20191001201555-5ac9a20de634/go.mod h1:OCoWPt+mbYuTO1FUVrQ2SxQU0oaaHBsn6lRhFX3JHOc= +github.com/philippgille/gokv v0.5.1-0.20191011213304-eb77f15b9c61/go.mod h1:OCoWPt+mbYuTO1FUVrQ2SxQU0oaaHBsn6lRhFX3JHOc= +github.com/philippgille/gokv v0.6.0 h1:fNEx/tSwV73nzlYd3iRYB8F+SEVJNNFzH1gsaT8SK2c= +github.com/philippgille/gokv v0.6.0/go.mod h1:tjXRFw9xDHgxLS8WJdfYotKGWp8TWqu4RdXjMDG/XBo= +github.com/philippgille/gokv/encoding v0.0.0-20191011213304-eb77f15b9c61 h1:IgQDuUPuEFVf22mBskeCLAtvd5c9XiiJG2UYud6eGHI= +github.com/philippgille/gokv/encoding v0.0.0-20191011213304-eb77f15b9c61/go.mod h1:SjxSrCoeYrYn85oTtroyG1ePY8aE72nvLQlw8IYwAN8= +github.com/philippgille/gokv/file v0.6.0 h1:ySYotRmkwaJLDkNSdT7Q0iDQzKHhSdq+ornlBXWgKzI= +github.com/philippgille/gokv/file v0.6.0/go.mod h1:L5ulK3F64mxW+8OvYFGE5bowupGO73JdQBh4qE2bgEw= +github.com/philippgille/gokv/test v0.0.0-20191011213304-eb77f15b9c61 h1:4tVyBgfpK0NSqu7tNZTwYfC/pbyWUR2y+O7mxEg5BTQ= +github.com/philippgille/gokv/test v0.0.0-20191011213304-eb77f15b9c61/go.mod h1:EUc+s9ONc1+VOr9NUEd8S0YbGRrQd/gz/p+2tvwt12s= +github.com/philippgille/gokv/util v0.0.0-20191011213304-eb77f15b9c61 h1:ril/jI0JgXNjPWwDkvcRxlZ09kgHXV2349xChjbsQ4o= +github.com/philippgille/gokv/util v0.0.0-20191011213304-eb77f15b9c61/go.mod h1:2dBhsJgY/yVIkjY5V3AnDUxUbEPzT6uQ3LvoVT8TR20= +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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= +github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..84666e5 --- /dev/null +++ b/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/ini.v1" + + "main/ports" + "main/server" + "main/store" +) + +// ServerConf contains information for a server service. It is +// recommended to use GetDefaultServerConf instead of creating this object +// directly, so that all unspecified fields have reasonable default values. +type ServerConf struct { + // Original string. + AllowPorts string `ini:"-" json:"-"` +} + +func UnmarshalServerConfFromIni(source interface{}) (ServerConf, error) { + f, err := ini.LoadSources(ini.LoadOptions{ + Insensitive: false, + InsensitiveSections: false, + InsensitiveKeys: false, + IgnoreInlineComment: true, + AllowBooleanKeys: true, + }, source) + + if err != nil { + return ServerConf{}, err + } + + s, err := f.GetSection("common") + if err != nil { + return ServerConf{}, err + } + + common := ServerConf{} + err = s.MapTo(&common) + if err != nil { + return ServerConf{}, err + } + + // allow_ports + allowPortStr := s.Key("allow_ports").String() + if allowPortStr != "" { + common.AllowPorts = allowPortStr + } else { + fmt.Println("⚠ common.allow_ports not specified in config, falling back to 1000-65535 port range") + common.AllowPorts = "1000-65535" + } + + return common, nil +} + +func init() { + fmt.Println("🐔 Initializing the plugin...") + + // Check if frps.ini exists + if _, err := os.Stat("./frps.ini"); os.IsNotExist(err) { + panic("frps.ini does not exist; move the frp-port-keeper binary to the same folder where the frps.ini located and call frp-port-keeper from there.") + } + + var commonSection, err = UnmarshalServerConfFromIni("./frps.ini") + if err != nil { + fmt.Println("got error: ", err) + } + fmt.Println("got allow_ports value: ", commonSection.AllowPorts) + + ports.InitPortsGenerator(commonSection.AllowPorts) +} + +func main() { + defer store.DB.Close() + server.Start() +} diff --git a/ports/ports.go b/ports/ports.go new file mode 100644 index 0000000..4a2e407 --- /dev/null +++ b/ports/ports.go @@ -0,0 +1,134 @@ +package ports + +import ( + "errors" + "fmt" + "main/store" + "strconv" + "strings" + "time" +) + +const NO_MORE_PORTS = "NO_MORE_FREE_PORTS_LEFT" + +var nextPort func() (int, error) + +func InitPortsGenerator(allowPorts string) { + fmt.Println("Initializing ports generator...") + nextPort = createAllowPortsGenerator(allowPorts) +} + +func GetFreePort(proxyName string) (int, error) { + fmt.Println("Looking for a free port...") + + userRecord := store.ProxyRecord{} + found, dbErr := store.DB.Get(proxyName, &userRecord) + if dbErr != nil { + fmt.Println("error occurred accessing db") + panic(dbErr) + } + if found { + fmt.Println("Found previously allocated port number: ", userRecord.Port) + return userRecord.Port, nil + } + + fmt.Printf("No record in DB for the '%s' user\n", proxyName) + fmt.Println("Allocating new port number for the user...") + + freePort := 0 + // Iterate through all the allowedPorts skeeping those that had been already + // alocated to somebody (have records in DB) + for p, err := nextPort(); err == nil; p, err = nextPort() { + fmt.Printf("Trying port %+v...\n", p) + portRecord := store.PortRecord{} + found, dbErr := store.DB.Get(strconv.Itoa(p), &portRecord) + if dbErr != nil { + fmt.Println("error occurred accessing db") + panic(dbErr) + } + if !found { + fmt.Println("Found a free port to use: ", p) + freePort = p + break + } + } + + if freePort == 0 { + // If we still have zero value port number, this means that we reached our port limits + return 0, errors.New(NO_MORE_PORTS) + } + + // Saving the port to DB + savePortNumber(proxyName, freePort) + + return freePort, nil +} + +// This is a closure that accepts port ranges in string representation like this: +// `3000-8000,60000-65000` and returns an iterator function which returns +// next port number and an error in case if no more ports left from the +// ranges of ports supplied. +func createAllowPortsGenerator(portsRange string) func() (int, error) { + rangeSlice := strings.Split(portsRange, ",") + i := 0 + ranges := make([][]int, len(rangeSlice)) + for i, r := range rangeSlice { + if strings.Contains(r, "-") { + rangeVals := strings.Split(strings.TrimSpace(r), "-") + start, _ := strconv.Atoi(rangeVals[0]) + end, _ := strconv.Atoi(rangeVals[1]) + + if start > end { + panic("😱 invalid range supplied") + } + + ranges[i] = []int{start, end} + } else { + port, _ := strconv.Atoi(r) + ranges[i] = []int{port, port} + } + } + + i = ranges[0][0] + j := 0 + + // Closure captures range variables + return func() (int, error) { + if i > ranges[j][1] { + j++ + if j >= len(ranges) { + j-- + return 0, errors.New(NO_MORE_PORTS) + } + i = ranges[j][0] + } + val := i + i++ + return val, nil + } +} + +func savePortNumber(proxyName string, port int) { + fmt.Printf("Persisting record to DB: userName=%s, port=%+v...\n", proxyName, port) + + date := time.Now().UTC() + ur := store.ProxyRecord{ + Port: port, + CreatedAt: date, + } + pr := store.PortRecord{ + Proxy: proxyName, + CreatedAt: date, + } + + err := store.DB.Set(proxyName, ur) + if err != nil { + fmt.Printf("Error setting value: %+v.\n", err) + panic(err) + } + err = store.DB.Set(strconv.Itoa(port), pr) + if err != nil { + fmt.Printf("Error setting value: %+v.\n", err) + panic(err) + } +} diff --git a/server/PortRegistrationHandler.go b/server/PortRegistrationHandler.go new file mode 100644 index 0000000..f3ec34d --- /dev/null +++ b/server/PortRegistrationHandler.go @@ -0,0 +1,110 @@ +package server + +import ( + "encoding/json" + "fmt" + "main/ports" + "net/http" + + "github.com/gin-gonic/gin" +) + +/* +NewProxy sample payload +{ + "version": "0.1.0", + "op": "NewProxy", + "content": { + "user": { + "user": "username", + "metas": null, + "run_id": "xxxxxxxxxxxxxxxx" + }, + "proxy_name": "username.proxy-name", + "proxy_type": "udp" + } +} +*/ + +type Request struct { + Version string `json:"version"` + Op string `json:"op"` + Content Content `json:"content"` +} + +type Content struct { + User User `json:"user"` + ProxyName string `json:"proxy_name"` + ProxyType string `json:"proxy_type"` + RemotePort int `json:"remote_port,omitempty"` +} + +type User struct { + User string `json:"user"` + Metas map[string]interface{} `json:"metas,omitempty"` + RunID string `json:"run_id"` +} + +type Response struct { + Reject bool `json:"reject,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` + Unchange bool `json:"unchange"` + Content Content `json:"content,omitempty"` +} + +func PortRegistrationsHandler(c *gin.Context) { + fmt.Printf("Query params: %+v\n", c.Request.URL.Query()) + + // Validate the request payload + var requestBody Request + if err := c.ShouldBindJSON(&requestBody); err != nil { + fmt.Println("error trying to bind request body") + c.AbortWithStatusJSON(http.StatusBadRequest, + gin.H{ + "error": "VALIDATEERR", + "message": "Invalid inputs. Please check your frpc.ini config.", + }, + ) + return + } + + op := c.Query("op") + fmt.Printf("Got %s request operation\n", op) + + // Pretty print the request body to console + requestBodyBytes, err := json.MarshalIndent(requestBody, "", " ") + if err != nil { + fmt.Println("Error marshaling request body:", err) + } + fmt.Println("Request body: ", string(requestBodyBytes)) + + if op != "NewProxy" { + fmt.Printf("returning default response...\n") + c.JSON(http.StatusOK, gin.H{"reject": false, "unchange": true}) + return + } + + fmt.Println("Processing NewProxy operation...") + + p, err := ports.GetFreePort(requestBody.Content.ProxyName) + if err != nil { + fmt.Printf("Error ocurred getting free port: %+v\n", err.Error()) + c.JSON(http.StatusOK, gin.H{"reject": true, "reject_reason": err.Error()}) + return + } + fmt.Println("Port: ", p) + + var res = Response{ + Unchange: false, + Content: Content{ + User: requestBody.Content.User, + ProxyName: requestBody.Content.ProxyName, + ProxyType: requestBody.Content.ProxyType, + RemotePort: p, + }, + } + + responseBodyBytes, _ := json.MarshalIndent(res, "", " ") + fmt.Printf("Allowing connection with response payload: %+v\n", string(responseBodyBytes)) + c.JSON(http.StatusOK, res) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..0d20da6 --- /dev/null +++ b/server/server.go @@ -0,0 +1,30 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func setupRouter() *gin.Engine { + // Disable Console Color + gin.DisableConsoleColor() + // gin.SetMode(gin.ReleaseMode) + // gin.ForceConsoleColor() + r := gin.Default() + + // Ping test + r.GET("/ping", func(c *gin.Context) { + c.String(http.StatusOK, "pong") + }) + + r.POST("/port-registrations", PortRegistrationsHandler) + + return r +} + +func Start() { + r := setupRouter() + // Listen and Server in 0.0.0.0:8080 + r.Run(":8080") +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..bbd56b6 --- /dev/null +++ b/store/store.go @@ -0,0 +1,116 @@ +package store + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/philippgille/gokv" + "github.com/philippgille/gokv/file" +) + +type ProxyRecord struct { + Port int + // IP string + CreatedAt time.Time +} + +type PortRecord struct { + Proxy string + // IP string + CreatedAt time.Time +} + +var DB gokv.Store + +func init() { + fmt.Println("Initializing store...") + DB = createStore() +} + +func PrettyStruct(data interface{}) (string, error) { + val, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", err + } + return string(val), nil +} + +// Redis Store +// func createStore() gokv.Store { +// options := redis.DefaultOptions // Address: "localhost:6379", Password: "", DB: 0 +// db, err := redis.NewClient(options) +// if err != nil { +// fmt.Println("Error occured connecting to redis: ", err) +// panic(err) +// } +// return db +// } + +// File Store +func createStore() gokv.Store { + options := file.DefaultOptions + store, err := file.NewStore(options) + if err != nil { + panic(err) + } + return store +} + +// checkConnection returns true if a connection could be made, false otherwise. +// func checkConnection() bool { +// os.Setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") +// os.Setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") +// // No S3ForcePathStyle required because we're not accessing a specific bucket here. +// sess, err := session.NewSession(aws.NewConfig().WithRegion(endpoints.EuCentral1RegionID).WithEndpoint(customEndpoint)) +// if err != nil { +// log.Printf("An error occurred during testing the connection to the server: %v\n", err) +// return false +// } +// svc := awss3.New(sess) +// +// _, err = svc.ListBuckets(&awss3.ListBucketsInput{}) +// if err != nil { +// log.Printf("An error occurred during testing the connection to the server: %v\n", err) +// return false +// } +// +// return true +// } + +// func CreateClient() s3.Client { +// // TODO +// minioClient, err := s3.New(s3.Config{ +// Endpoint: os.Getenv("MINIO_ENDPOINT"), +// AccessKeyID: os.Getenv("MINIO_ACCESS_KEY"), +// SecretAccessKey: os.Getenv("MINIO_SECRET_KEY"), +// UseSSL: false, +// Codec: encoding.JSON, +// }) +// +// if err != nil { +// panic(err) +// } +// +// bucketName := "gokv" +// client, err := s3.NewClient(minioClient, bucketName, encoding.JSON) +// +// return client +// } + +// func createClient() s3.Client { +// os.Setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") +// os.Setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") +// options := s3.Options{ +// BucketName: "gokv", +// Region: endpoints.EuCentral1RegionID, +// CustomEndpoint: customEndpoint, +// UsePathStyleAddressing: true, +// Codec: encoding.JSON, +// } +// client, err := s3.NewClient(options) +// if err != nil { +// panic(err) +// } +// return client +// }