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