first commit
This commit is contained in:
143
README.md
Normal file
143
README.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# rtorrent-scgi-proxy
|
||||||
|
|
||||||
|
Mały broker SCGI -> SCGI dla rTorrenta. Zachowuje protokół SCGI, sprawdza token w ścieżce, ogranicza źródłowy adres IP/CIDR i przekazuje request do lokalnego backendu rTorrent.
|
||||||
|
|
||||||
|
Repozytorium: https://git.linuxiarz.pl/gru/rtorrent-scgi-proxy
|
||||||
|
|
||||||
|
Szybkie pobranie:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.linuxiarz.pl/gru/rtorrent-scgi-proxy.git
|
||||||
|
cd rtorrent-scgi-proxy/bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Archiwum:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wget https://git.linuxiarz.pl/gru/rtorrent-scgi-proxy/archive/main.tar.gz -O rtorrent-scgi-proxy.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Jak to działa
|
||||||
|
|
||||||
|
Klient łączy się po SCGI do proxy, np.:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scgi://10.10.110.11:5050/proxy/TWOJ_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy:
|
||||||
|
|
||||||
|
1. sprawdza IP klienta przez `ALLOW_NET`,
|
||||||
|
2. sprawdza token ze ścieżki `/proxy/<TOKEN>`,
|
||||||
|
3. przepisuje `REQUEST_URI` na `/RPC2`,
|
||||||
|
4. forwarduje request do lokalnego rTorrenta po TCP albo unix sockecie.
|
||||||
|
|
||||||
|
## Konfiguracja
|
||||||
|
|
||||||
|
Plik env dla systemd:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/etc/rtorrent-scgi-proxy.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Przykład:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
LISTEN_ADDR=10.10.110.11:5050
|
||||||
|
TOKEN=change-me-long-random-token
|
||||||
|
TARGET_NETWORK=tcp
|
||||||
|
TARGET_ADDRESS=127.0.0.1:5000
|
||||||
|
TARGET_URI=/RPC2
|
||||||
|
ALLOW_NET=10.10.0.0/16
|
||||||
|
READ_TIMEOUT=15s
|
||||||
|
WRITE_TIMEOUT=30s
|
||||||
|
DIAL_TIMEOUT=5s
|
||||||
|
MAX_HEADER_BYTES=65536
|
||||||
|
MAX_CONTENT_BYTES=10485760
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend przez unix socket:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TARGET_NETWORK=unix
|
||||||
|
TARGET_ADDRESS=/run/rtorrent/rtorrent.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budowa i testy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd bin
|
||||||
|
go test ./...
|
||||||
|
./scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Wynik:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/rtorrent-scgi-proxy-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOOS=linux GOARCH=arm64 ./scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalacja przez systemd
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd bin
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
sudo nano /etc/rtorrent-scgi-proxy.env
|
||||||
|
sudo systemctl enable --now rtorrent-scgi-proxy
|
||||||
|
sudo systemctl status rtorrent-scgi-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Ręcznie:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin rtproxy
|
||||||
|
sudo install -m 0755 dist/rtorrent-scgi-proxy-linux-amd64 /usr/local/bin/rtorrent-scgi-proxy
|
||||||
|
sudo install -m 0644 systemd/rtorrent-scgi-proxy.service /etc/systemd/system/rtorrent-scgi-proxy.service
|
||||||
|
sudo install -m 0600 examples/rtorrent-scgi-proxy.env /etc/rtorrent-scgi-proxy.env
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now rtorrent-scgi-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wrzucenie do repo
|
||||||
|
|
||||||
|
Z katalogu głównego repo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add bin
|
||||||
|
git commit -m "Add rTorrent SCGI proxy binary project"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bezpieczeństwo
|
||||||
|
|
||||||
|
Nie wystawiaj tego publicznie bez dodatkowej ochrony. Zalecane minimum:
|
||||||
|
|
||||||
|
- `ALLOW_NET` ustawione na prywatną podsieć/VPN,
|
||||||
|
- długi losowy `TOKEN`,
|
||||||
|
- firewall na porcie `LISTEN_ADDR`,
|
||||||
|
- najlepiej WireGuard albo inny prywatny tunel,
|
||||||
|
- rTorrent SCGI tylko lokalnie: `127.0.0.1` albo unix socket.
|
||||||
|
|
||||||
|
SCGI nie ma wbudowanego TLS ani auth. Ten proxy dodaje prostą autoryzację tokenem i allowlistę IP, ale nie zastępuje VPN/TLS.
|
||||||
|
|
||||||
|
## Zmienne środowiskowe
|
||||||
|
|
||||||
|
| Zmienna | Domyślnie | Opis |
|
||||||
|
|---|---:|---|
|
||||||
|
| `LISTEN_ADDR` | `127.0.0.1:5050` | Adres nasłuchu proxy. |
|
||||||
|
| `TOKEN` | brak | Wymagany token w ścieżce `/proxy/<TOKEN>`. |
|
||||||
|
| `TARGET_NETWORK` | `tcp` | `tcp` albo `unix`. |
|
||||||
|
| `TARGET_ADDRESS` | `127.0.0.1:5000` | Lokalny backend rTorrent SCGI. |
|
||||||
|
| `TARGET_URI` | `/RPC2` | URI przekazywane do rTorrenta. |
|
||||||
|
| `ALLOW_NET` | `127.0.0.1` | Dozwolony IP, CIDR, lista po przecinku albo `*`. |
|
||||||
|
| `READ_TIMEOUT` | `15s` | Timeout czytania klienta. |
|
||||||
|
| `WRITE_TIMEOUT` | `30s` | Timeout zapisu/odpowiedzi. |
|
||||||
|
| `DIAL_TIMEOUT` | `5s` | Timeout połączenia z backendem. |
|
||||||
|
| `MAX_HEADER_BYTES` | `65536` | Limit nagłówków SCGI. |
|
||||||
|
| `MAX_CONTENT_BYTES` | `10485760` | Limit body XML-RPC. |
|
||||||
466
cmd/rtorrent-scgi-proxy/main.go
Normal file
466
cmd/rtorrent-scgi-proxy/main.go
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string
|
||||||
|
Token string
|
||||||
|
TargetNetwork string
|
||||||
|
TargetAddress string
|
||||||
|
TargetURI string
|
||||||
|
AllowNet string
|
||||||
|
ReadTimeout time.Duration
|
||||||
|
WriteTimeout time.Duration
|
||||||
|
DialTimeout time.Duration
|
||||||
|
MaxHeaderBytes int
|
||||||
|
MaxContentBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllowRule struct {
|
||||||
|
any bool
|
||||||
|
ip net.IP
|
||||||
|
net *net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
log.Fatalf("config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowRule, err := parseAllowRules(cfg.AllowNet)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("ALLOW_NET error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", cfg.ListenAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("listen error on %s: %v", cfg.ListenAddr, err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
log.Printf("rtorrent-scgi-proxy listening=%s target=%s:%s target_uri=%s allow=%s",
|
||||||
|
cfg.ListenAddr, cfg.TargetNetwork, cfg.TargetAddress, cfg.TargetURI, cfg.AllowNet)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("accept error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go handleConn(conn, cfg, allowRule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
return Config{
|
||||||
|
ListenAddr: getenv("LISTEN_ADDR", "127.0.0.1:5050"),
|
||||||
|
Token: os.Getenv("TOKEN"),
|
||||||
|
TargetNetwork: getenv("TARGET_NETWORK", "tcp"),
|
||||||
|
TargetAddress: getenv("TARGET_ADDRESS", "127.0.0.1:5000"),
|
||||||
|
TargetURI: getenv("TARGET_URI", "/RPC2"),
|
||||||
|
AllowNet: getenv("ALLOW_NET", "127.0.0.1"),
|
||||||
|
ReadTimeout: durationEnv("READ_TIMEOUT", 15*time.Second),
|
||||||
|
WriteTimeout: durationEnv("WRITE_TIMEOUT", 30*time.Second),
|
||||||
|
DialTimeout: durationEnv("DIAL_TIMEOUT", 5*time.Second),
|
||||||
|
MaxHeaderBytes: intEnv("MAX_HEADER_BYTES", 64*1024),
|
||||||
|
MaxContentBytes: intEnv("MAX_CONTENT_BYTES", 10*1024*1024),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) validate() error {
|
||||||
|
if c.Token == "" {
|
||||||
|
return errors.New("TOKEN is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(c.Token, "/") || strings.ContainsAny(c.Token, "\x00\r\n") {
|
||||||
|
return errors.New("TOKEN must not contain slash, NUL or newlines")
|
||||||
|
}
|
||||||
|
if c.TargetNetwork != "tcp" && c.TargetNetwork != "unix" {
|
||||||
|
return errors.New("TARGET_NETWORK must be tcp or unix")
|
||||||
|
}
|
||||||
|
if c.TargetAddress == "" {
|
||||||
|
return errors.New("TARGET_ADDRESS is required")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(c.TargetURI, "/") {
|
||||||
|
return errors.New("TARGET_URI must start with /")
|
||||||
|
}
|
||||||
|
if c.MaxHeaderBytes < 1024 || c.MaxHeaderBytes > 1024*1024 {
|
||||||
|
return errors.New("MAX_HEADER_BYTES must be between 1024 and 1048576")
|
||||||
|
}
|
||||||
|
if c.MaxContentBytes < 0 || c.MaxContentBytes > 128*1024*1024 {
|
||||||
|
return errors.New("MAX_CONTENT_BYTES must be between 0 and 134217728")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConn(client net.Conn, cfg Config, allow AllowRules) {
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
remoteIP, _, err := net.SplitHostPort(client.RemoteAddr().String())
|
||||||
|
if err != nil || !allow.Allows(net.ParseIP(remoteIP)) {
|
||||||
|
writeSimpleResponse(client, "403 Forbidden", "source ip not allowed\n")
|
||||||
|
log.Printf("blocked remote=%s", client.RemoteAddr())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = client.SetReadDeadline(time.Now().Add(cfg.ReadTimeout))
|
||||||
|
br := bufio.NewReader(client)
|
||||||
|
|
||||||
|
headersRaw, err := readNetstring(br, cfg.MaxHeaderBytes)
|
||||||
|
if err != nil {
|
||||||
|
writeSimpleResponse(client, "400 Bad Request", "invalid scgi netstring\n")
|
||||||
|
log.Printf("netstring error remote=%s err=%v", client.RemoteAddr(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
headers, err := parseSCGIHeaders(headersRaw)
|
||||||
|
if err != nil {
|
||||||
|
writeSimpleResponse(client, "400 Bad Request", "invalid scgi headers\n")
|
||||||
|
log.Printf("header error remote=%s err=%v", client.RemoteAddr(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, err := parseContentLength(headers["CONTENT_LENGTH"], cfg.MaxContentBytes)
|
||||||
|
if err != nil {
|
||||||
|
writeSimpleResponse(client, "400 Bad Request", "invalid content length\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make([]byte, cl)
|
||||||
|
if _, err := io.ReadFull(br, body); err != nil {
|
||||||
|
writeSimpleResponse(client, "400 Bad Request", "could not read body\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := extractToken(headers["REQUEST_URI"])
|
||||||
|
if err != nil || !constantTimeEqual(token, cfg.Token) {
|
||||||
|
writeSimpleResponse(client, "403 Forbidden", "invalid token\n")
|
||||||
|
log.Printf("invalid token remote=%s", client.RemoteAddr())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rewritten := cloneMap(headers)
|
||||||
|
rewritten["REQUEST_URI"] = cfg.TargetURI
|
||||||
|
rewritten["DOCUMENT_URI"] = cfg.TargetURI
|
||||||
|
rewritten["SCRIPT_NAME"] = cfg.TargetURI
|
||||||
|
rewritten["PATH_INFO"] = ""
|
||||||
|
rewritten["QUERY_STRING"] = ""
|
||||||
|
|
||||||
|
outReq, err := buildSCGIRequest(rewritten, body)
|
||||||
|
if err != nil {
|
||||||
|
writeSimpleResponse(client, "500 Internal Server Error", "could not build upstream request\n")
|
||||||
|
log.Printf("build request error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream, err := net.DialTimeout(cfg.TargetNetwork, cfg.TargetAddress, cfg.DialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
writeSimpleResponse(client, "502 Bad Gateway", "upstream connect failed\n")
|
||||||
|
log.Printf("upstream dial error target=%s:%s err=%v", cfg.TargetNetwork, cfg.TargetAddress, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
_ = upstream.SetDeadline(time.Now().Add(cfg.WriteTimeout))
|
||||||
|
if _, err := upstream.Write(outReq); err != nil {
|
||||||
|
writeSimpleResponse(client, "502 Bad Gateway", "upstream write failed\n")
|
||||||
|
log.Printf("upstream write error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = client.SetWriteDeadline(time.Now().Add(cfg.WriteTimeout))
|
||||||
|
if _, err := io.Copy(client, upstream); err != nil {
|
||||||
|
log.Printf("copy error remote=%s err=%v", client.RemoteAddr(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNetstring(r *bufio.Reader, maxHeaderBytes int) ([]byte, error) {
|
||||||
|
var lenBuf bytes.Buffer
|
||||||
|
for {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b == ':' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if b < '0' || b > '9' {
|
||||||
|
return nil, fmt.Errorf("invalid netstring length byte: %q", b)
|
||||||
|
}
|
||||||
|
if lenBuf.Len() > 10 {
|
||||||
|
return nil, errors.New("netstring length too long")
|
||||||
|
}
|
||||||
|
lenBuf.WriteByte(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lenBuf.Len() == 0 {
|
||||||
|
return nil, errors.New("empty netstring length")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(lenBuf.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n < 0 || n > maxHeaderBytes {
|
||||||
|
return nil, errors.New("netstring payload too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
trailer, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if trailer != ',' {
|
||||||
|
return nil, errors.New("missing netstring trailer comma")
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSCGIHeaders(payload []byte) (map[string]string, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, errors.New("empty header payload")
|
||||||
|
}
|
||||||
|
parts := bytes.Split(payload, []byte{0})
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil, errors.New("not enough header parts")
|
||||||
|
}
|
||||||
|
if len(parts[len(parts)-1]) != 0 {
|
||||||
|
return nil, errors.New("headers must end with NUL")
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(map[string]string)
|
||||||
|
for i := 0; i < len(parts)-1; i += 2 {
|
||||||
|
if i+1 >= len(parts)-1 {
|
||||||
|
return nil, errors.New("odd number of header items")
|
||||||
|
}
|
||||||
|
k := string(parts[i])
|
||||||
|
v := string(parts[i+1])
|
||||||
|
if k == "" {
|
||||||
|
return nil, errors.New("empty header name")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(k, "\r\n") {
|
||||||
|
return nil, fmt.Errorf("invalid header name %q", k)
|
||||||
|
}
|
||||||
|
if _, exists := headers[k]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate header %q", k)
|
||||||
|
}
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if headers["CONTENT_LENGTH"] == "" {
|
||||||
|
return nil, errors.New("missing CONTENT_LENGTH")
|
||||||
|
}
|
||||||
|
if headers["SCGI"] != "1" {
|
||||||
|
return nil, errors.New("missing or invalid SCGI")
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContentLength(s string, max int) (int, error) {
|
||||||
|
cl, err := strconv.Atoi(s)
|
||||||
|
if err != nil || cl < 0 || cl > max {
|
||||||
|
return 0, errors.New("invalid CONTENT_LENGTH")
|
||||||
|
}
|
||||||
|
return cl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSCGIRequest(headers map[string]string, body []byte) ([]byte, error) {
|
||||||
|
h := cloneMap(headers)
|
||||||
|
h["CONTENT_LENGTH"] = strconv.Itoa(len(body))
|
||||||
|
h["SCGI"] = "1"
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(h))
|
||||||
|
for k := range h {
|
||||||
|
if k == "CONTENT_LENGTH" || k == "SCGI" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sortStrings(keys)
|
||||||
|
|
||||||
|
var hb bytes.Buffer
|
||||||
|
writePair(&hb, "CONTENT_LENGTH", h["CONTENT_LENGTH"])
|
||||||
|
writePair(&hb, "SCGI", "1")
|
||||||
|
for _, k := range keys {
|
||||||
|
v := h[k]
|
||||||
|
if strings.IndexByte(k, 0) >= 0 || strings.IndexByte(v, 0) >= 0 {
|
||||||
|
return nil, fmt.Errorf("header contains NUL: %q", k)
|
||||||
|
}
|
||||||
|
writePair(&hb, k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString(strconv.Itoa(hb.Len()))
|
||||||
|
out.WriteByte(':')
|
||||||
|
out.Write(hb.Bytes())
|
||||||
|
out.WriteByte(',')
|
||||||
|
out.Write(body)
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePair(b *bytes.Buffer, k, v string) {
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteByte(0)
|
||||||
|
b.WriteString(v)
|
||||||
|
b.WriteByte(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(uri string) (string, error) {
|
||||||
|
clean := strings.SplitN(uri, "?", 2)[0]
|
||||||
|
clean = strings.TrimSpace(clean)
|
||||||
|
const prefix = "/proxy/"
|
||||||
|
if !strings.HasPrefix(clean, prefix) {
|
||||||
|
return "", errors.New("uri must start with /proxy/")
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(clean, prefix)
|
||||||
|
if token == "" || strings.Contains(token, "/") || strings.ContainsAny(token, "\x00\r\n") {
|
||||||
|
return "", errors.New("invalid token path")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllowRules []AllowRule
|
||||||
|
|
||||||
|
func parseAllowRules(s string) (AllowRules, error) {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
rules := make(AllowRules, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
rule, err := parseAllowRule(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return nil, errors.New("empty allow rules")
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAllowRule(s string) (AllowRule, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return AllowRule{}, errors.New("empty allow rule")
|
||||||
|
}
|
||||||
|
if s == "*" || s == "0.0.0.0/0" || s == "::/0" {
|
||||||
|
return AllowRule{any: true}, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "/") {
|
||||||
|
_, n, err := net.ParseCIDR(s)
|
||||||
|
if err != nil {
|
||||||
|
return AllowRule{}, err
|
||||||
|
}
|
||||||
|
return AllowRule{net: n}, nil
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(s)
|
||||||
|
if ip == nil {
|
||||||
|
return AllowRule{}, fmt.Errorf("invalid IP: %s", s)
|
||||||
|
}
|
||||||
|
return AllowRule{ip: ip}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs AllowRules) Allows(ip net.IP) bool {
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.Allows(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r AllowRule) Allows(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.any {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r.ip != nil {
|
||||||
|
return r.ip.Equal(ip)
|
||||||
|
}
|
||||||
|
if r.net != nil {
|
||||||
|
return r.net.Contains(ip)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqual(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMap(in map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSimpleResponse(w net.Conn, status, body string) {
|
||||||
|
resp := fmt.Sprintf("Status: %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n%s", status, len(body), body)
|
||||||
|
_, _ = io.WriteString(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, def string) string {
|
||||||
|
val := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationEnv(key string, def time.Duration) time.Duration {
|
||||||
|
val := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid %s=%q: %v", key, val, err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func intEnv(key string, def int) int {
|
||||||
|
val := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
i, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid %s=%q: %v", key, val, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortStrings(s []string) {
|
||||||
|
for i := 1; i < len(s); i++ {
|
||||||
|
v := s[i]
|
||||||
|
j := i - 1
|
||||||
|
for j >= 0 && s[j] > v {
|
||||||
|
s[j+1] = s[j]
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
s[j+1] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
177
cmd/rtorrent-scgi-proxy/main_test.go
Normal file
177
cmd/rtorrent-scgi-proxy/main_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSCGIHeaders(t *testing.T) {
|
||||||
|
raw := []byte("CONTENT_LENGTH\x000\x00SCGI\x001\x00REQUEST_URI\x00/proxy/token\x00")
|
||||||
|
h, err := parseSCGIHeaders(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSCGIHeaders returned error: %v", err)
|
||||||
|
}
|
||||||
|
if h["CONTENT_LENGTH"] != "0" || h["SCGI"] != "1" || h["REQUEST_URI"] != "/proxy/token" {
|
||||||
|
t.Fatalf("unexpected headers: %#v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSCGIRequestRoundTrip(t *testing.T) {
|
||||||
|
body := []byte("<methodCall/>")
|
||||||
|
req, err := buildSCGIRequest(map[string]string{
|
||||||
|
"CONTENT_LENGTH": "999",
|
||||||
|
"SCGI": "1",
|
||||||
|
"REQUEST_URI": "/RPC2",
|
||||||
|
}, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSCGIRequest returned error: %v", err)
|
||||||
|
}
|
||||||
|
br := bufio.NewReader(strings.NewReader(string(req)))
|
||||||
|
raw, err := readNetstring(br, 4096)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readNetstring returned error: %v", err)
|
||||||
|
}
|
||||||
|
h, err := parseSCGIHeaders(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSCGIHeaders returned error: %v", err)
|
||||||
|
}
|
||||||
|
if h["CONTENT_LENGTH"] != "13" || h["REQUEST_URI"] != "/RPC2" {
|
||||||
|
t.Fatalf("unexpected rewritten headers: %#v", h)
|
||||||
|
}
|
||||||
|
gotBody, err := io.ReadAll(br)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotBody) != string(body) {
|
||||||
|
t.Fatalf("unexpected body: %q", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowRules(t *testing.T) {
|
||||||
|
rules, err := parseAllowRules("10.0.0.0/8, 192.168.1.10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseAllowRules returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !rules.Allows(net.ParseIP("10.2.3.4")) {
|
||||||
|
t.Fatal("CIDR rule should allow 10.2.3.4")
|
||||||
|
}
|
||||||
|
if !rules.Allows(net.ParseIP("192.168.1.10")) {
|
||||||
|
t.Fatal("single IP rule should allow 192.168.1.10")
|
||||||
|
}
|
||||||
|
if rules.Allows(net.ParseIP("172.16.0.1")) {
|
||||||
|
t.Fatal("rules should block 172.16.0.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndToEndProxy(t *testing.T) {
|
||||||
|
upstream, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upstream listen: %v", err)
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
upstreamDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
conn, err := upstream.Accept()
|
||||||
|
if err != nil {
|
||||||
|
upstreamDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
raw, err := readNetstring(br, 4096)
|
||||||
|
if err != nil {
|
||||||
|
upstreamDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, err := parseSCGIHeaders(raw)
|
||||||
|
if err != nil {
|
||||||
|
upstreamDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h["REQUEST_URI"] != "/RPC2" {
|
||||||
|
upstreamDone <- unexpectedErr("REQUEST_URI", h["REQUEST_URI"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl, err := parseContentLength(h["CONTENT_LENGTH"], 1024)
|
||||||
|
if err != nil {
|
||||||
|
upstreamDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.CopyN(io.Discard, br, int64(cl)); err != nil {
|
||||||
|
upstreamDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(conn, "Status: 200 OK\r\nContent-Type: text/xml\r\nContent-Length: 2\r\n\r\nok")
|
||||||
|
upstreamDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
proxy, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("proxy listen: %v", err)
|
||||||
|
}
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Token: "secret",
|
||||||
|
TargetNetwork: "tcp",
|
||||||
|
TargetAddress: upstream.Addr().String(),
|
||||||
|
TargetURI: "/RPC2",
|
||||||
|
ReadTimeout: time.Second,
|
||||||
|
WriteTimeout: time.Second,
|
||||||
|
DialTimeout: time.Second,
|
||||||
|
MaxHeaderBytes: 4096,
|
||||||
|
MaxContentBytes: 1024,
|
||||||
|
}
|
||||||
|
rules, err := parseAllowRules("127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseAllowRules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := proxy.Accept()
|
||||||
|
if err == nil {
|
||||||
|
handleConn(conn, cfg, rules)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := net.Dial("tcp", proxy.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client dial: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
req, err := buildSCGIRequest(map[string]string{
|
||||||
|
"CONTENT_LENGTH": "0",
|
||||||
|
"SCGI": "1",
|
||||||
|
"REQUEST_URI": "/proxy/secret",
|
||||||
|
}, []byte("ping"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build request: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.Write(req); err != nil {
|
||||||
|
t.Fatalf("client write: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := io.ReadAll(client)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client read: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(resp), "ok") {
|
||||||
|
t.Fatalf("unexpected response: %q", resp)
|
||||||
|
}
|
||||||
|
if err := <-upstreamDone; err != nil {
|
||||||
|
t.Fatalf("upstream error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type unexpectedError string
|
||||||
|
|
||||||
|
func (e unexpectedError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func unexpectedErr(field, got string) error {
|
||||||
|
return unexpectedError(field + "=" + got)
|
||||||
|
}
|
||||||
BIN
dist/rtorrent-scgi-proxy-linux-amd64
vendored
Executable file
BIN
dist/rtorrent-scgi-proxy-linux-amd64
vendored
Executable file
Binary file not shown.
19
examples/rtorrent-scgi-proxy.env
Normal file
19
examples/rtorrent-scgi-proxy.env
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Jedna instancja proxy = jeden token = jeden backend rTorrent SCGI.
|
||||||
|
|
||||||
|
LISTEN_ADDR=10.10.110.11:5050
|
||||||
|
TOKEN=change-me-long-random-token
|
||||||
|
|
||||||
|
# tcp: np. 127.0.0.1:5000
|
||||||
|
# unix: np. /run/rtorrent/rtorrent.sock
|
||||||
|
TARGET_NETWORK=tcp
|
||||||
|
TARGET_ADDRESS=127.0.0.1:5000
|
||||||
|
TARGET_URI=/RPC2
|
||||||
|
|
||||||
|
# Pojedynczy IP, CIDR albo *.
|
||||||
|
ALLOW_NET=10.10.0.0/16
|
||||||
|
|
||||||
|
READ_TIMEOUT=15s
|
||||||
|
WRITE_TIMEOUT=30s
|
||||||
|
DIAL_TIMEOUT=5s
|
||||||
|
MAX_HEADER_BYTES=65536
|
||||||
|
MAX_CONTENT_BYTES=10485760
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.linuxiarz.pl/gru/rtorrent-scgi-proxy/bin
|
||||||
|
|
||||||
|
go 1.22
|
||||||
15
scripts/build.sh
Executable file
15
scripts/build.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
GOOS="${GOOS:-linux}"
|
||||||
|
GOARCH="${GOARCH:-amd64}"
|
||||||
|
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o "dist/rtorrent-scgi-proxy-${GOOS}-${GOARCH}" \
|
||||||
|
./cmd/rtorrent-scgi-proxy
|
||||||
|
|
||||||
|
printf '%s\n' "dist/rtorrent-scgi-proxy-${GOOS}-${GOARCH}"
|
||||||
21
scripts/install.sh
Executable file
21
scripts/install.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if ! id rtproxy >/dev/null 2>&1; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin rtproxy
|
||||||
|
fi
|
||||||
|
|
||||||
|
BIN_PATH="$(./scripts/build.sh)"
|
||||||
|
install -m 0755 "$BIN_PATH" /usr/local/bin/rtorrent-scgi-proxy
|
||||||
|
install -m 0644 systemd/rtorrent-scgi-proxy.service /etc/systemd/system/rtorrent-scgi-proxy.service
|
||||||
|
|
||||||
|
if [ ! -f /etc/rtorrent-scgi-proxy.env ]; then
|
||||||
|
install -m 0600 examples/rtorrent-scgi-proxy.env /etc/rtorrent-scgi-proxy.env
|
||||||
|
chown root:root /etc/rtorrent-scgi-proxy.env
|
||||||
|
printf '%s\n' 'Created /etc/rtorrent-scgi-proxy.env - edit TOKEN, LISTEN_ADDR, TARGET_ADDRESS and ALLOW_NET.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
printf '%s\n' 'Installed. Run: systemctl enable --now rtorrent-scgi-proxy'
|
||||||
27
systemd/rtorrent-scgi-proxy.service
Normal file
27
systemd/rtorrent-scgi-proxy.service
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=rTorrent SCGI proxy
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rtproxy
|
||||||
|
Group=rtproxy
|
||||||
|
EnvironmentFile=/etc/rtorrent-scgi-proxy.env
|
||||||
|
ExecStart=/usr/local/bin/rtorrent-scgi-proxy
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
LockPersonality=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user