Sfoglia il codice sorgente

got an initial proof of concept working

aeth 2 giorni fa
parent
commit
0ccaab679e
6 ha cambiato i file con 388 aggiunte e 0 eliminazioni
  1. 1 0
      .gitignore
  2. 8 0
      go.mod
  3. 4 0
      go.sum
  4. 52 0
      main.go
  5. 112 0
      pkg/gluetun/client.go
  6. 211 0
      pkg/qbitt/client.go

+ 1 - 0
.gitignore

@@ -24,3 +24,4 @@ _testmain.go
 *.test
 *.prof
 
+*.env

+ 8 - 0
go.mod

@@ -0,0 +1,8 @@
+module git.aetherial.dev/aeth/gluetun-qbitt-sidecar
+
+go 1.25.5
+
+require (
+	github.com/joho/godotenv v1.5.1 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+)

+ 4 - 0
go.sum

@@ -0,0 +1,4 @@
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=

+ 52 - 0
main.go

@@ -0,0 +1,52 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"time"
+
+	"git.aetherial.dev/aeth/gluetun-qbitt-sidecar/pkg/gluetun"
+	"git.aetherial.dev/aeth/gluetun-qbitt-sidecar/pkg/qbitt"
+	"github.com/joho/godotenv"
+)
+
+func main() {
+	godotenv.Load()
+	gluetunAddress := os.Getenv("GLUETUN_ADDRESS")
+	gluetunApikey := os.Getenv("GLUETUN_API_KEY")
+	qbitAddress := os.Getenv("QBITTORRENT_ADDRESS")
+	qbitUsername := os.Getenv("QBITTORRENT_USERNAME")
+	qbitPassword := os.Getenv("QBITTORRENT_PASSWORD")
+	qbitClient, err := qbitt.NewQbittorrentClient(qbitUsername, qbitPassword, qbitAddress)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	for {
+		port, err := gluetun.GetForwardedPort(gluetunAddress, gluetunApikey)
+		if err != nil {
+			log.Fatal(err)
+		}
+		cfg, err := qbitClient.GetRunningConfiguration()
+		if err != nil {
+			log.Fatal(err)
+		}
+		if cfg.ListenPort != port.Port { // if the port in qbittorrent is not the same as the forwarded port from gluetun, update it in following block
+			err = qbitClient.UpdateIncomingPort(port.Port)
+			if err != nil {
+				log.Fatal(err)
+			}
+			updated, err := qbitClient.VerifyPortChange(port.Port)
+			if err != nil {
+				log.Fatal(err)
+			}
+			fmt.Printf("Updated qbittorrent incoming port: %t\n", updated)
+
+		} else {
+			fmt.Printf("Qbittorrent incoming port: %v\nGluetun forwarded port: %v\nNo update.\n", cfg.ListenPort, port.Port)
+		}
+		time.Sleep(time.Second * 30)
+	}
+
+}

+ 112 - 0
pkg/gluetun/client.go

@@ -0,0 +1,112 @@
+package gluetun
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"strings"
+)
+
+/*
+will need to parse a configuration like this:
+
+[[roles]]
+name = "qbittorrent"
+# Define a list of routes with the syntax "Http-Method /path"
+routes = ["GET /v1/portforward"]
+# Define an authentication method with its parameters
+auth = "basic"
+username = "myusername"
+password = "mypassword"
+
+Or if using the API key authentication method:
+auth = "apikey"
+apikey = "myapikey"
+
+the 'apikey' is then sent in the X-API-Key header
+
+
+we will need to extract the username and password for authentication to gluetun. This will be done by mounting the same
+config file that gluetun has to a static location, like /gluetun-qbitt-sidecar/config.toml
+
+will need to just retrieve the exposed port via the route:
+	GET /v1/portforward  ---->  {"port":5914}
+
+*/
+
+const (
+	PORT_FORWARD_PATH = "v1/portforward"
+	API_KEY_HEADER    = "X-API-Key"
+)
+
+type Port struct {
+	Port int `json:"port"`
+}
+
+type Authentication struct {
+	ApiKey   string `toml:"apikey"`
+	Username string `toml:"username"`
+	Password string `toml:"password"`
+}
+
+/*
+Read in the config.toml file that gluetun uses to set the api key / username and password
+
+	:param path: the path to the config file. Use the path for where this is running, since this
+	can be either ran as a container next to your deployment, or on the host itself
+*/
+func GetAuthentication(path string) (Authentication, error) {
+	var auth Authentication
+	_, err := os.ReadFile(path)
+	if err != nil {
+		return auth, err
+	}
+	return auth, nil
+
+}
+
+// strips everything but the port number and hostname from a string
+func sanitizeUrl(s string) string {
+	protoStripped := strings.ReplaceAll(
+		strings.ReplaceAll(
+			strings.ReplaceAll(
+				s, "https", "",
+			), "http", "",
+		), "://", "",
+	)
+	splitHost := strings.Split(protoStripped, "/")
+	return splitHost[0]
+
+}
+
+/*
+Gets the forwarded port from the server
+*/
+func GetForwardedPort(address string, apiKey string) (Port, error) {
+	formattedUrl := fmt.Sprintf("http://%s/%s", sanitizeUrl(address), PORT_FORWARD_PATH)
+	req, err := http.NewRequest(http.MethodGet, formattedUrl, nil)
+	if err != nil {
+		return Port{}, err
+	}
+	req.Header.Add(API_KEY_HEADER, apiKey)
+	client := http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return Port{}, err
+	}
+	var b []byte
+	defer resp.Body.Close()
+	b, err = io.ReadAll(resp.Body)
+	if err != nil {
+		return Port{}, err
+	}
+	var port Port
+	err = json.Unmarshal(b, &port)
+	if err != nil {
+		return port, err
+	}
+	return port, nil
+
+}

+ 211 - 0
pkg/qbitt/client.go

@@ -0,0 +1,211 @@
+package qbitt
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+/*
+Need to make a request to the qbittorrent API
+	POST /api/v2/app/setPreferences
+with structure:
+
+	{
+		"listen_port": 58925
+	}
+
+*/
+
+const (
+	LOGIN_PATH       = "/api/v2/auth/login"
+	LOGOUT_PATH      = "/api/v2/auth/logout"
+	PREFERENCES_PATH = "/api/v2/app/setPreferences"
+	CONFIG_PATH      = "/api/v2/app/preferences"
+)
+
+type ListenPortRequest struct {
+	ListenPort int `json:"listen_port"`
+}
+
+type Configuration struct {
+	ListenPort int  `json:"listen_port"`
+	RandomPort bool `json:"random_port"`
+	Upnp       bool `json:"upnp"`
+	Pex        bool `json:"pex"`
+	Dht        bool `json:"dht"`
+}
+
+func makeListenPortRequest(port int) (string, error) {
+	b, err := json.Marshal(ListenPortRequest{ListenPort: port})
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+
+}
+
+type server struct {
+	proto string // http or https
+	host  string // just the domain name, like 'qbit.local.lan' or whatever
+	port  int    // port that the web ui listens on
+}
+
+func (s server) format() string {
+	return fmt.Sprintf("%s://%s:%v", s.proto, s.host, s.port)
+}
+
+func (s server) formatWith(path string) string {
+	return fmt.Sprintf("%s%s", s.format(), path)
+}
+
+type QbittorrentClient struct {
+	server server
+	client *http.Client // plumbing
+}
+
+// takes string s and returns the host and port
+func splitServerPort(s string) (string, int) {
+	splitPort := strings.Split(s, ":")
+	if len(splitPort) < 1 {
+		// add log here
+		log.Fatal("unhandled for now")
+	}
+	host := splitPort[0]
+	port, err := strconv.Atoi(splitPort[1])
+	if err != nil {
+		log.Fatal("couldnt parse port. unhandled for now.")
+	}
+	return host, port
+
+}
+
+// strips everything but the port number and hostname from a string
+func parseServer(s string) (server, error) {
+	var proto, host string
+	var port int
+	if strings.Contains(s, "https") {
+		proto = "https"
+	} else {
+		proto = "http"
+	}
+	protoStripped := strings.ReplaceAll(
+		strings.ReplaceAll(
+			strings.ReplaceAll(
+				s, "https", "",
+			), "http", "",
+		), "://", "",
+	)
+	splitHost := strings.Split(protoStripped, "/")
+	host, port = splitServerPort(splitHost[0])
+	return server{proto: proto, host: host, port: port}, nil
+
+}
+
+/*
+	 create a new qbit client
+		:param username: username for your qbittorrent Web UI
+		:param password: password for your qbittorrent Web UI
+		:param host: the host/url of the server. should be in format: <protocol>://<host>:<port>
+*/
+func NewQbittorrentClient(username, password, host string) (QbittorrentClient, error) {
+	ckJar, err := cookiejar.New(nil)
+	client := http.Client{Jar: ckJar}
+	srv, err := parseServer(host)
+	if err != nil {
+		return QbittorrentClient{}, err
+	}
+	formattedUrl := srv.formatWith(LOGIN_PATH)
+	data := url.Values{}
+	data.Set("username", username)
+	data.Set("password", password)
+	body := strings.NewReader(data.Encode())
+	req, err := http.NewRequest(http.MethodPost, formattedUrl, body)
+	if err != nil {
+		return QbittorrentClient{}, err
+	}
+	req.Header.Add("Referer", srv.format())
+	req.Header.Add("Origin", srv.format())
+	req.Header.Add("Host", srv.host)
+	// req.Header.Add("Content-Type", "text/plain")
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := client.Do(req)
+	if err != nil {
+		return QbittorrentClient{}, err
+	}
+	if resp.StatusCode != 200 {
+		return QbittorrentClient{}, errors.New(fmt.Sprintf("Authentication failed.\n  Host: %s\n  Status Code: %s\n", srv.format(), resp.Status))
+	}
+
+	client.Jar.SetCookies(resp.Request.URL, resp.Cookies())
+	return QbittorrentClient{server: srv, client: &client}, nil
+
+}
+
+// updates the listening port for incoming bittorrent requests to 'port' on your server
+// :param port: the port to update for incoming connections
+func (q QbittorrentClient) UpdateIncomingPort(port int) error {
+	body, err := makeListenPortRequest(port)
+	if err != nil {
+		return err
+	}
+	data := url.Values{}
+	data.Set("json", body)
+	req, err := http.NewRequest(http.MethodPost, q.server.formatWith(PREFERENCES_PATH), strings.NewReader(data.Encode()))
+	req.Header.Add("Origin", q.server.format())
+	req.Header.Add("Referer", q.server.format())
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := q.client.Do(req)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != 200 {
+		return errors.New(fmt.Sprintf("Update request failed.\n  Host: %s\n  Status Code: %s\n", q.server.format(), resp.Status))
+	}
+	return nil
+}
+
+// gets the running configuration
+func (q QbittorrentClient) GetRunningConfiguration() (Configuration, error) {
+	var cfg Configuration
+	req, err := http.NewRequest(http.MethodGet, q.server.formatWith(CONFIG_PATH), nil)
+	if err != nil {
+		return cfg, err
+	}
+	req.Header.Add("Origin", q.server.format())
+	req.Header.Add("Referer", q.server.format())
+	resp, err := q.client.Do(req)
+	if err != nil {
+		return cfg, err
+	}
+	defer resp.Body.Close()
+	b, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return cfg, err
+	}
+	err = json.Unmarshal(b, &cfg)
+	if err != nil {
+		return cfg, err
+	}
+	return cfg, nil
+}
+
+// verify that the listening port was modified
+func (q QbittorrentClient) VerifyPortChange(port int) (bool, error) {
+	cfg, err := q.GetRunningConfiguration()
+	if err != nil {
+		return false, err
+	}
+	if cfg.ListenPort != port {
+		return false, errors.New(fmt.Sprintf("Listening port on server: '%v' did not match what was supplied: '%v'\n", cfg.ListenPort, port))
+	}
+	return true, nil
+
+}