diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3932bb4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: push + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + cache: false # golangci-lint-action does caching + + - name: Lint + uses: golangci/golangci-lint-action@v3 + + test: + name: Test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + + - name: Test + run: go test -race -v ./... + + build: + name: Build + needs: [lint, test] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + os: ["linux", "darwin"] + arch: ["amd64", "arm64"] + env: + app: transmission-gluetun-port.${{ matrix.os }}-${{ matrix.arch }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + + - name: Build + run: go build -o ${{ env.app }} -v ./... + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + + - name: Upload + uses: actions/upload-artifact@v3 + with: + name: ${{ env.app }} + path: ${{ env.app }} + if-no-files-found: error diff --git a/.rtx.toml b/.rtx.toml new file mode 100644 index 0000000..fabed83 --- /dev/null +++ b/.rtx.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.21" diff --git a/README.md b/README.md new file mode 100644 index 0000000..321ed72 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# transmission-gluetun-port update + +Small go binary to periodically update the port of transmission based on gluten port forwarding status. + +Based on [transmission-nat-pmp](https://github.com/jordanpotter/transmission-nat-pmp). + +## Usage + +```bash +transmission-gluetun-port -h +``` + +## Available environment variables + +| Name | Description | Default | +|------|-------------|---------| +| `TRANSMISSION_USER` | Transmission user | - | +| `TRANSMISSION_PASSWORD` | Transmission password | - | +| `GLUETUN_HOST` | Gluetun api host | `127.0.0.1` | +| `GLUETUN_PORT` | Gluetun api port | `8000` | +| `CHECK_INTERVAL` | Update interval ([format](https://pkg.go.dev/time#ParseDuration)) | `1m` | +| `ERROR_INTERVAL` | Update interval in case of error ([format](https://pkg.go.dev/time#ParseDuration)) | `5s` | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..092e888 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/Michsior14/transmission-gluetun-port-update + +go 1.20 + +require ( + github.com/go-resty/resty/v2 v2.10.0 + github.com/hekmon/transmissionrpc/v2 v2.0.1 +) + +require ( + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hekmon/cunits/v2 v2.1.0 // indirect + golang.org/x/net v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a761360 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= +github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hekmon/cunits/v2 v2.1.0 h1:k6wIjc4PlacNOHwKEMBgWV2/c8jyD4eRMs5mR1BBhI0= +github.com/hekmon/cunits/v2 v2.1.0/go.mod h1:9r1TycXYXaTmEWlAIfFV8JT+Xo59U96yUJAYHxzii2M= +github.com/hekmon/transmissionrpc/v2 v2.0.1 h1:WkILCEdbNy3n/N/w7mi449waMPdH2AA1THyw7TfnN/w= +github.com/hekmon/transmissionrpc/v2 v2.0.1/go.mod h1:+s96Pkg7dIP3h2PT3fzhXPvNb3OdLryh5J8PIvQg3aA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..528d079 --- /dev/null +++ b/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/go-resty/resty/v2" + "github.com/hekmon/transmissionrpc/v2" +) + +type GluetunResponse struct { + Port uint16 +} + +var ( + transmissionHostname = flag.String("transmission-hostname", "127.0.0.1", "transmission hostname") + transmissionPort = flag.Int("transmission-port", 9091, "transmission port") + transmissionUsername = os.Getenv("TRANSMISSION_USERNAME") + transmissionPassword = os.Getenv("TRANSMISSION_PASSWORD") + + gluetunHostname = getEnv("GLUETUN_HOSTNAME", "127.0.0.1") + gluetunPort = getEnv("GLUETUN_PORT", "8000") + + checkIntervalStr = getEnv("CHECK_INTERVAL", "1m") + errorIntervalStr = getEnv("ERROR_INTERVAL", "5s") +) + +func init() { + flag.Parse() +} + +func main() { + checkInterval, _ := time.ParseDuration(checkIntervalStr) + errorInterval, _ := time.ParseDuration(errorIntervalStr) + previousExternalPort := uint16(0) + gluetunPortApi := fmt.Sprintf("http://%s:%s/v1/openvpn/portforwarded", gluetunHostname, gluetunPort) + errorCount := 0 + maxErrorCount := 5 + + httpClient := resty.New() + + transmissionClient, err := transmissionrpc.New(*transmissionHostname, transmissionUsername, transmissionPassword, &transmissionrpc.AdvancedConfig{ + Port: uint16(*transmissionPort), + }) + if err != nil { + log.Fatalf("failed to create transmission client: %v", err) + } + + for { + portMapping := &GluetunResponse{} + resp, err := httpClient.R(). + SetResult(portMapping). + ForceContentType("application/json"). + Get(gluetunPortApi) + + if err != nil || resp.IsError() { + log.Fatalf("failed to fetch port mapping from gluetun: %v, %d", err, resp.StatusCode()) + } + + if portMapping.Port == 0 { + err = errors.New("empty port") + log.Printf("new port is not yet assigned, %v", err) + } else if portMapping.Port == previousExternalPort { + log.Printf("external port is unchanged: %d", portMapping.Port) + } else { + log.Printf("external port changed to: %d", portMapping.Port) + + transmissionPeerPort := int64(portMapping.Port) + err = transmissionClient.SessionArgumentsSet(context.Background(), transmissionrpc.SessionArguments{ + PeerPort: &transmissionPeerPort, + }) + if err != nil { + log.Fatalf("failed to set transmission peer port: %v", err) + } + previousExternalPort = portMapping.Port + log.Printf("updated transmission peer port to: %d", transmissionPeerPort) + } + + if err != nil && errorCount < maxErrorCount { + time.Sleep(errorInterval) + errorCount++ + } else { + time.Sleep(checkInterval) + errorCount = 0 + } + } +} + +func getEnv(key, fallback string) string { + value := os.Getenv(key) + if len(value) == 0 { + return fallback + } + return value +}