commit 0b430973c9c9f13a368d0c04376e75bce8ac2019 Author: nxshock Date: Thu Dec 28 16:00:04 2023 +0500 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b735ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a626811 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 nxshock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..adc9109 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,23 @@ +pkgname=gonx +pkgver=0.0.1 +pkgrel=1 +pkgdesc='Simple reverse proxy server' +arch=('x86_64' 'aarch64') +url="https://github.com/nxshock/$pkgname" +license=('MIT') +makedepends=('go' 'git') +source=("$url/$pkgname-$pkgver.tar.gz") +sha256sums=('SKIP') +backup=("etc/$pkgname.conf") + +build() { + cd "$pkgname-$pkgver" + go build -o "$pkgname" -ldflags "-linkmode=external -s -w" -buildmode=pie -trimpath -mod=readonly -modcacherw +} + +package() { + cd "$pkgname-$pkgver" + install -Dm755 "$pkgname" "$pkgdir"/usr/bin/$pkgname + install -Dm644 "$pkgname.conf" "$pkgdir/etc/$pkgname.conf" + install -Dm755 $pkgname.service "$pkgdir"/usr/lib/systemd/system/$pkgname.service +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..350b26a --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# gonx + +Simple reverse proxy server. + +## Features: + +* Simple TCP redirection +* Simple static file server + +## Usage + +1. Install package +2. Edit config in `/etc/gonx.conf` +3. Start application with systemd: + + `systemctl start gonx.service` + +## Config example + +```toml +LogLevel = "DEBUG" # Log level (DEBUG, INFO, WARN, ERROR) +TlsKeysDir = "/etc/letsencrypt/live" # Path to TLS-certificates generated by Certbot +TlsListenAddr = ":443" # TLS listen address +HttpListenAddr = ":80" # HTTP listen address +AcmeChallengePath = "/var/lib/letsencrypt" # Path for ACME challenge files + +# Map of hostname -> redirect URL +[TLS] +"git.host.com" = "tcp://127.0.0.1:8001" # TCP redirect +"www.host.com" = "file:///srv/http" # simple static file server from `/srv/http` +``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..c6a2585 --- /dev/null +++ b/config.go @@ -0,0 +1,74 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log/slog" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +type Config struct { + // Log level + LogLevel slog.Level + + // Path to TLS-certificates generated by Certbot + TlsKeysDir string + + // TLS listen address + TlsListenAddr string + + // HTTP listen address + HttpListenAddr string + + // Map of hostname -> redirect URL + TLS map[string]string + + // Acme path + AcmeChallengePath string + + // Parsed list of servers + proxyRules HostMapping + + // loaded TLS keys + tlsConfig *tls.Config +} + +func LoadConfig(configFilePath string) (*Config, error) { + config := new(Config) + + _, err := toml.DecodeFile(configFilePath, &config) + if err != nil { + return nil, err + } + + config.proxyRules = make(HostMapping) + for inputUrlStr, outputUrlStr := range config.TLS { + err = config.proxyRules.Add(inputUrlStr, outputUrlStr) + if err != nil { + return nil, err + } + } + + return config, nil +} + +func (c *Config) initTls() error { + c.tlsConfig = new(tls.Config) + + for hostName := range c.proxyRules { + slog.Debug("reading tls key", slog.String("host", hostName)) + certFilePath := filepath.Join(c.TlsKeysDir, hostName, defaultCertFileName) + keyFilePath := filepath.Join(c.TlsKeysDir, hostName, defaultKeyFileName) + + cert, err := tls.LoadX509KeyPair(certFilePath, keyFilePath) + if err != nil { + return fmt.Errorf("read tls files error: %v", err) + } + + c.tlsConfig.Certificates = append(c.tlsConfig.Certificates, cert) + } + + return nil +} diff --git a/consts.go b/consts.go new file mode 100644 index 0000000..ad23634 --- /dev/null +++ b/consts.go @@ -0,0 +1,11 @@ +package main + +const ( + defaultConfigPath = "/etc/gonx.conf" + defaultTlsKeysPath = "/etc/letsencrypt/live" + + defaultCertFileName = "fullchain.pem" + defaultKeyFileName = "privkey.pem" + + defaultAcmeChallengePath = "/.well-known/acme-challenge/" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8deb096 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module gonx + +go 1.21 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/lmittmann/tint v1.0.3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..12f5232 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= +github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= diff --git a/gonx.conf b/gonx.conf new file mode 100644 index 0000000..119ddc8 --- /dev/null +++ b/gonx.conf @@ -0,0 +1,9 @@ +LogLevel = "INFO" # Log level (DEBUG, INFO, WARN, ERROR) +TlsKeysDir = "/etc/letsencrypt/live" # Path to TLS-certificates generated by Certbot +TlsListenAddr = ":443" # TLS listen address +HttpListenAddr = ":80" # HTTP listen address +AcmeChallengePath = "/var/lib/letsencrypt" # Path for ACME challenge files + +[TLS] +# "www.example.com" = "file:/srv/http" +# "git.example.com" = "tcp://127.0.0.1:8001" diff --git a/gonx.service b/gonx.service new file mode 100644 index 0000000..a7d874f --- /dev/null +++ b/gonx.service @@ -0,0 +1,42 @@ +[Unit] +Description=gonx service +After=network.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/gonx +User=http +Group=http +Restart=on-failure +RestartSec=5s + +SecureBits=keep-caps +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE +DevicePolicy=closed +IPAccounting=true +LockPersonality=true +MemoryDenyWriteExecute=true +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectClock=true +ProtectControlGroups=true +ProtectControlGroups=true +ProtectHome=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +ReadWritePaths= +RemoveIPC=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +SystemCallArchitectures=native +UMask=0027 + +[Install] +WantedBy=multi-user.target diff --git a/listener.go b/listener.go new file mode 100644 index 0000000..0cb1721 --- /dev/null +++ b/listener.go @@ -0,0 +1,34 @@ +package main + +import ( + "net" +) + +// Listener implements net.Listener and additional Add(net.Conn) method. +type Listener struct { + c chan net.Conn +} + +func NewListener() *Listener { + c := make(chan net.Conn) + + return &Listener{c: c} +} + +func (m *Listener) Add(conn net.Conn) { + m.c <- conn +} + +func (m *Listener) Accept() (net.Conn, error) { + return <-m.c, nil +} + +func (m *Listener) Close() error { + close(m.c) + + return nil +} + +func (m *Listener) Addr() net.Addr { + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cf11504 --- /dev/null +++ b/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto/tls" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/lmittmann/tint" +) + +func main() { + configFilePath := defaultConfigPath + if len(os.Args) > 1 { + configFilePath = os.Args[1] + } + + config, err := LoadConfig(configFilePath) + if err != nil { + slog.Error("Failed to load config", slog.String("err", err.Error())) + os.Exit(1) + } + + logger := slog.New(tint.NewHandler(os.Stderr, &tint.Options{ + Level: config.LogLevel, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + return a + }})) + slog.SetDefault(logger) + + err = config.initTls() + if err != nil { + slog.Error("init tls error", slog.String("err", err.Error())) + os.Exit(1) + } + + go func() { + slog.Debug("Starting TLS listener", slog.String("addr", config.TlsListenAddr)) + + listener, err := tls.Listen("tcp", config.TlsListenAddr, config.tlsConfig) + if err != nil { + slog.Error("Failed to open tls listener", slog.String("err", err.Error())) + os.Exit(1) + } + + for { + conn, err := listener.Accept() + if err != nil { + slog.Debug("incoming connection failed", slog.String("err", err.Error())) + continue + } + slog.Debug("incoming connection", slog.String("RemoteAddr", conn.RemoteAddr().String())) + + go func() { _ = handleTlsConn(conn.(*tls.Conn), config.proxyRules) }() + } + }() + + go func() { + slog.Debug("Starting HTTP listener", slog.String("addr", config.HttpListenAddr)) + + smux := http.NewServeMux() + smux.Handle(defaultAcmeChallengePath, http.StripPrefix(defaultAcmeChallengePath, http.FileServer(http.Dir(config.AcmeChallengePath)))) + smux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently) // TODO: заполнить URL переадресации + }) + httpServer := http.Server{Handler: smux} + httpServer.Addr = config.HttpListenAddr + err := httpServer.ListenAndServe() + if err != nil { + slog.Error("Failed to start HTTP server", slog.String("err", err.Error())) + os.Exit(1) + } + }() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + slog.Debug("Interrupt signal received.") +} diff --git a/mapping.go b/mapping.go new file mode 100644 index 0000000..0fced0c --- /dev/null +++ b/mapping.go @@ -0,0 +1,96 @@ +package main + +import ( + "crypto/tls" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "sync" +) + +type ProxyDirection struct { + Output *url.URL + + listener *Listener +} + +type HostMapping map[string]ProxyDirection // hostName -> rule + +func (h HostMapping) Add(host, outputUrlStr string) error { + outputUrl, err := url.Parse(outputUrlStr) + if err != nil { + return err + } + + pd := ProxyDirection{outputUrl, NewListener()} + + switch outputUrl.Scheme { + case "file": + server := http.Server{Handler: http.FileServer(http.Dir(outputUrl.Path))} + go func() { _ = server.Serve(pd.listener) }() + case "tcp": + go func(pd ProxyDirection) { + for { + conn, err := pd.listener.Accept() + if err != nil { + slog.Debug(err.Error()) + continue + } + go func() { _ = handleProxy(conn.(*tls.Conn), pd.Output) }() + } + }(pd) + default: + return fmt.Errorf("unknown output protocol: %v", outputUrl.Scheme) + } + + h[host] = pd + + return nil +} + +func handleTlsConn(conn *tls.Conn, hosts HostMapping) error { + err := conn.Handshake() + if err != nil { + return fmt.Errorf("handshake error: %v", err) + } + + hostName := conn.ConnectionState().ServerName + proxyDirection, exists := hosts[hostName] + if !exists { + return fmt.Errorf("requested host not found: %s", hostName) + } + + proxyDirection.listener.Add(conn) + + return nil +} + +func handleProxy(conn *tls.Conn, outputUrl *url.URL) error { + c, err := net.Dial(outputUrl.Scheme, outputUrl.Host) + if err != nil { + return fmt.Errorf("dial: %v", err) + } + defer c.Close() + + wg := new(sync.WaitGroup) + wg.Add(2) + + go func() { + defer wg.Done() + + _, _ = io.Copy(conn, c) + }() + + go func() { + defer wg.Done() + + _, _ = io.Copy(c, conn) + }() + + wg.Wait() + + return nil +}