From 7bc5a01ba7dff89f0344976e78145dd481b114f4 Mon Sep 17 00:00:00 2001 From: bagas Date: Thu, 18 Dec 2025 18:30:49 +0700 Subject: [PATCH 1/9] feat: add pprof for debuging --- README.md | 48 ++++++++++++++++++++ internal/port/port.go | 2 +- main.go | 24 +++++++++- server/http.go | 4 +- server/https.go | 2 +- server/server.go | 4 +- session/handler.go | 6 +-- session/interaction/interaction.go | 12 ++--- utils/utils.go | 71 +++++++++++++++++++++++++++--- 9 files changed, 150 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7a765ef..9e8bf54 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,54 @@ A lightweight SSH-based tunnel server written in Go that enables secure TCP and - Go 1.18 or higher - Valid domain name for subdomain routing +## Environment Variables + +The following environment variables can be configured in the `.env` file: + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `DOMAIN` | Domain name for subdomain routing | `localhost` | No | +| `PORT` | SSH server port | `2200` | No | +| `TLS_ENABLED` | Enable TLS/HTTPS | `false` | No | +| `TLS_REDIRECT` | Redirect HTTP to HTTPS | `false` | No | +| `CERT_LOC` | Path to TLS certificate | `certs/cert.pem` | No | +| `KEY_LOC` | Path to TLS private key | `certs/privkey.pem` | No | +| `SSH_PRIVATE_KEY` | Path to SSH private key (auto-generated if missing) | `certs/id_rsa` | No | +| `CORS_LIST` | Comma-separated list of allowed CORS origins | - | No | +| `ALLOWED_PORTS` | Port range for TCP tunnels (e.g., 40000-41000) | `40000-41000` | No | +| `PPROF_ENABLED` | Enable pprof profiling server | `false` | No | +| `PPROF_PORT` | Port for pprof server | `6060` | No | + +**Note:** All environment variables now use UPPERCASE naming. The application includes sensible defaults for all variables, so you can run it without a `.env` file for basic functionality. + +### SSH Key Auto-Generation + +If the SSH private key specified in `SSH_PRIVATE_KEY` doesn't exist, the application will automatically generate a new 4096-bit RSA key pair at the specified location. This makes it easier to get started without manually creating SSH keys. + +### Profiling with pprof + +To enable profiling for performance analysis: + +1. Set `PPROF_ENABLED=true` in your `.env` file +2. Optionally set `PPROF_PORT` to your desired port (default: 6060) +3. Access profiling data at `http://localhost:6060/debug/pprof/` + +Common pprof endpoints: +- `/debug/pprof/` - Index page with available profiles +- `/debug/pprof/heap` - Memory allocation profile +- `/debug/pprof/goroutine` - Stack traces of all current goroutines +- `/debug/pprof/profile` - CPU profile (30-second sample by default) +- `/debug/pprof/trace` - Execution trace + +Example usage with `go tool pprof`: +```bash +# Analyze CPU profile +go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 + +# Analyze memory heap +go tool pprof http://localhost:6060/debug/pprof/heap +``` + ## Contributing Contributions are welcome! diff --git a/internal/port/port.go b/internal/port/port.go index 1f05eab..6512f40 100644 --- a/internal/port/port.go +++ b/internal/port/port.go @@ -21,7 +21,7 @@ var Manager = PortManager{ } func init() { - rawRange := utils.Getenv("ALLOWED_PORTS") + rawRange := utils.Getenv("ALLOWED_PORTS", "40000-41000") splitRange := strings.Split(rawRange, "-") if len(splitRange) != 2 { Manager.AddPortRange(30000, 31000) diff --git a/main.go b/main.go index 3dd3811..65fb3ca 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,10 @@ package main import ( + "fmt" "log" + "net/http" + _ "net/http/pprof" "os" "tunnel_pls/server" "tunnel_pls/utils" @@ -12,13 +15,30 @@ import ( func main() { log.SetOutput(os.Stdout) log.SetFlags(log.LstdFlags | log.Lshortfile) - + + pprofEnabled := utils.Getenv("PPROF_ENABLED", "false") + if pprofEnabled == "true" { + pprofPort := utils.Getenv("PPROF_PORT", "6060") + go func() { + pprofAddr := fmt.Sprintf("localhost:%s", pprofPort) + log.Printf("Starting pprof server on http://%s/debug/pprof/", pprofAddr) + if err := http.ListenAndServe(pprofAddr, nil); err != nil { + log.Printf("pprof server error: %v", err) + } + }() + } + sshConfig := &ssh.ServerConfig{ NoClientAuth: true, ServerVersion: "SSH-2.0-TunnlPls-1.0", } - privateBytes, err := os.ReadFile(utils.Getenv("ssh_private_key")) + sshKeyPath := utils.Getenv("SSH_PRIVATE_KEY", "certs/id_rsa") + if err := utils.GenerateSSHKeyIfNotExist(sshKeyPath); err != nil { + log.Fatalf("Failed to generate SSH key: %s", err) + } + + privateBytes, err := os.ReadFile(sshKeyPath) if err != nil { log.Fatalf("Failed to load private key: %s", err) } diff --git a/server/http.go b/server/http.go index 9eafb7d..34fad0e 100644 --- a/server/http.go +++ b/server/http.go @@ -194,7 +194,7 @@ func NewHTTPServer() error { if err != nil { return errors.New("Error listening: " + err.Error()) } - if utils.Getenv("tls_enabled") == "true" && utils.Getenv("tls_redirect") == "true" { + if utils.Getenv("TLS_ENABLED", "false") == "true" && utils.Getenv("TLS_REDIRECT", "false") == "true" { redirectTLS = true } go func() { @@ -246,7 +246,7 @@ func Handler(conn net.Conn) { if redirectTLS { _, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" + - fmt.Sprintf("Location: https://%s.%s/\r\n", slug, utils.Getenv("domain")) + + fmt.Sprintf("Location: https://%s.%s/\r\n", slug, utils.Getenv("DOMAIN", "localhost")) + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n")) diff --git a/server/https.go b/server/https.go index 4e23d17..f65a0ba 100644 --- a/server/https.go +++ b/server/https.go @@ -13,7 +13,7 @@ import ( ) func NewHTTPSServer() error { - cert, err := tls.LoadX509KeyPair(utils.Getenv("cert_loc"), utils.Getenv("key_loc")) + cert, err := tls.LoadX509KeyPair(utils.Getenv("CERT_LOC", "certs/cert.pem"), utils.Getenv("KEY_LOC", "certs/privkey.pem")) if err != nil { return err } diff --git a/server/server.go b/server/server.go index 8051a02..e81ac62 100644 --- a/server/server.go +++ b/server/server.go @@ -17,12 +17,12 @@ type Server struct { } func NewServer(config *ssh.ServerConfig) *Server { - listener, err := net.Listen("tcp", fmt.Sprintf(":%s", utils.Getenv("port"))) + listener, err := net.Listen("tcp", fmt.Sprintf(":%s", utils.Getenv("PORT", "2200"))) if err != nil { log.Fatalf("failed to listen on port 2200: %v", err) return nil } - if utils.Getenv("tls_enabled") == "true" { + if utils.Getenv("TLS_ENABLED", "false") == "true" { go func() { err = NewHTTPSServer() if err != nil { diff --git a/session/handler.go b/session/handler.go index 79ee46c..962aa65 100644 --- a/session/handler.go +++ b/session/handler.go @@ -179,9 +179,9 @@ func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) { } log.Printf("HTTP forwarding approved on port: %d", portToBind) - domain := utils.Getenv("domain") + domain := utils.Getenv("DOMAIN", "localhost") protocol := "http" - if utils.Getenv("tls_enabled") == "true" { + if utils.Getenv("TLS_ENABLED", "false") == "true" { protocol = "https" } @@ -261,7 +261,7 @@ func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind s.Forwarder.SetForwardedPort(portToBind) s.Interaction.SendMessage("\033[H\033[2J") s.Interaction.ShowWelcomeMessage() - s.Interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to tcp://%s:%d \r\n", utils.Getenv("domain"), s.Forwarder.GetForwardedPort())) + s.Interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to tcp://%s:%d \r\n", utils.Getenv("DOMAIN", "localhost"), s.Forwarder.GetForwardedPort())) s.Lifecycle.SetStatus(types.RUNNING) go s.Forwarder.AcceptTCPConnections() s.Interaction.HandleUserInput() diff --git a/session/interaction/interaction.go b/session/interaction/interaction.go index 17020a8..d8cf4c7 100644 --- a/session/interaction/interaction.go +++ b/session/interaction/interaction.go @@ -208,7 +208,7 @@ func (i *Interaction) appendToSlug(char byte) { } func (i *Interaction) refreshSlugDisplay() { - domain := utils.Getenv("domain") + domain := utils.Getenv("DOMAIN", "localhost") i.SendMessage(clearToLineEnd) i.SendMessage("➤ " + i.EditSlug + "." + domain) } @@ -238,7 +238,7 @@ func (i *Interaction) updateSlug() { return } - domain := utils.Getenv("domain") + domain := utils.Getenv("DOMAIN", "localhost") i.SendMessage("\r\n\r\n✅ SUBDOMAIN UPDATED ✅\r\n\r\n") i.SendMessage("Your new address is: " + newSlug + "." + domain + "\r\n\r\n") i.SendMessage("Press any key to continue...\r\n") @@ -340,16 +340,16 @@ func (i *Interaction) handleSlugCommand() { i.SendMessage(clearScreen) i.DisplaySlugEditor() - domain := utils.Getenv("domain") + domain := utils.Getenv("DOMAIN", "localhost") i.SendMessage("➤ " + i.EditSlug + "." + domain) } func (i *Interaction) ShowForwardingMessage() { - domain := utils.Getenv("domain") + domain := utils.Getenv("DOMAIN", "localhost") if i.Forwarder.GetTunnelType() == types.HTTP { protocol := "http" - if utils.Getenv("tls_enabled") == "true" { + if utils.Getenv("TLS_ENABLED", "false") == "true" { protocol = "https" } i.SendMessage(fmt.Sprintf("Forwarding your traffic to %s://%s.%s \r\n", protocol, i.SlugManager.Get(), domain)) @@ -384,7 +384,7 @@ func (i *Interaction) ShowWelcomeMessage() { } func (i *Interaction) DisplaySlugEditor() { - domain := utils.Getenv("domain") + domain := utils.Getenv("DOMAIN", "localhost") fullDomain := i.SlugManager.Get() + "." + domain contentLine := " ║ Current: " + fullDomain diff --git a/utils/utils.go b/utils/utils.go index d5d05da..2518627 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,14 +1,20 @@ package utils import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "log" - "math/rand" + mathrand "math/rand" "os" + "path/filepath" "strings" "sync" "time" "github.com/joho/godotenv" + "golang.org/x/crypto/ssh" ) type Env struct { @@ -24,7 +30,7 @@ func init() { func GenerateRandomString(length int) string { const charset = "abcdefghijklmnopqrstuvwxyz" - seededRand := rand.New(rand.NewSource(time.Now().UnixNano() + int64(rand.Intn(9999)))) + seededRand := mathrand.New(mathrand.NewSource(time.Now().UnixNano() + int64(mathrand.Intn(9999)))) var result strings.Builder for i := 0; i < length; i++ { randomIndex := seededRand.Intn(len(charset)) @@ -33,7 +39,7 @@ func GenerateRandomString(length int) string { return result.String() } -func Getenv(key string) string { +func Getenv(key, defaultValue string) string { env.mu.Lock() defer env.mu.Unlock() if val, ok := env.value[key]; ok { @@ -48,11 +54,64 @@ func Getenv(key string) string { } val := os.Getenv(key) - env.value[key] = val - if val == "" { - panic("Asking for env: " + key + " but got nothing, please set your environment first") + val = defaultValue } + env.value[key] = val return val } + +func GenerateSSHKeyIfNotExist(keyPath string) error { + if _, err := os.Stat(keyPath); err == nil { + log.Printf("SSH key already exists at %s", keyPath) + return nil + } + + log.Printf("SSH key not found at %s, generating new key pair...", keyPath) + + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return err + } + + privateKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + } + + dir := filepath.Dir(keyPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + privateKeyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer privateKeyFile.Close() + + if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil { + return err + } + + publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return err + } + + pubKeyPath := keyPath + ".pub" + pubKeyFile, err := os.OpenFile(pubKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer pubKeyFile.Close() + + _, err = pubKeyFile.Write(ssh.MarshalAuthorizedKey(publicKey)) + if err != nil { + return err + } + + log.Printf("SSH key pair generated successfully at %s and %s", keyPath, pubKeyPath) + return nil +} -- 2.49.1 From 6dff7352169adad7c86da58f969d5b526c76d764 Mon Sep 17 00:00:00 2001 From: bagas Date: Thu, 18 Dec 2025 21:09:12 +0700 Subject: [PATCH 2/9] fix: prevent OOM by bounding io.Copy buffer usage --- README.md | 1 + session/forwarder/forwarder.go | 19 +++++++++++++++++-- utils/utils.go | 10 ++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e8bf54..d66785f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The following environment variables can be configured in the `.env` file: | `SSH_PRIVATE_KEY` | Path to SSH private key (auto-generated if missing) | `certs/id_rsa` | No | | `CORS_LIST` | Comma-separated list of allowed CORS origins | - | No | | `ALLOWED_PORTS` | Port range for TCP tunnels (e.g., 40000-41000) | `40000-41000` | No | +| `BUFFER_SIZE` | Buffer size for io.Copy operations in bytes (4096-1048576) | `32768` | No | | `PPROF_ENABLED` | Enable pprof profiling server | `false` | No | | `PPROF_PORT` | Port for pprof server | `6060` | No | diff --git a/session/forwarder/forwarder.go b/session/forwarder/forwarder.go index 9d94abe..3bf41bb 100644 --- a/session/forwarder/forwarder.go +++ b/session/forwarder/forwarder.go @@ -8,12 +8,27 @@ import ( "log" "net" "strconv" + "sync" "tunnel_pls/session/slug" "tunnel_pls/types" + "tunnel_pls/utils" "golang.org/x/crypto/ssh" ) +var bufferPool = sync.Pool{ + New: func() interface{} { + bufSize := utils.GetBufferSize() + return make([]byte, bufSize) + }, +} + +func copyWithBuffer(dst io.Writer, src io.Reader) (written int64, err error) { + buf := bufferPool.Get().([]byte) + defer bufferPool.Put(buf) + return io.CopyBuffer(dst, src, buf) +} + type Forwarder struct { Listener net.Listener TunnelType types.TunnelType @@ -103,7 +118,7 @@ func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteA done := make(chan struct{}, 2) go func() { - _, err := io.Copy(src, dst) + _, err := copyWithBuffer(src, dst) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { log.Printf("Error copying from conn.Reader to channel: %v", err) } @@ -111,7 +126,7 @@ func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteA }() go func() { - _, err := io.Copy(dst, src) + _, err := copyWithBuffer(dst, src) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { log.Printf("Error copying from channel to conn.Writer: %v", err) } diff --git a/utils/utils.go b/utils/utils.go index 2518627..d2087d1 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -9,6 +9,7 @@ import ( mathrand "math/rand" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -62,6 +63,15 @@ func Getenv(key, defaultValue string) string { return val } +func GetBufferSize() int { + sizeStr := Getenv("BUFFER_SIZE", "32768") + size, err := strconv.Atoi(sizeStr) + if err != nil || size < 4096 || size > 1048576 { + return 32768 + } + return size +} + func GenerateSSHKeyIfNotExist(keyPath string) error { if _, err := os.Stat(keyPath); err == nil { log.Printf("SSH key already exists at %s", keyPath) -- 2.49.1 From 76d1202b8ec4bdf144dc05f67d17ec5b687a6f48 Mon Sep 17 00:00:00 2001 From: bagas Date: Fri, 26 Dec 2025 23:17:13 +0700 Subject: [PATCH 3/9] fix: correct logic when checking tcpip-forward request --- README.md | 15 +++++++++ server/http.go | 56 ++++++++++++++++++++-------------- server/server.go | 19 +++++------- session/forwarder/forwarder.go | 51 +++++++++++++++++++++++-------- session/handler.go | 3 -- session/lifecycle/lifecycle.go | 31 ------------------- session/session.go | 44 +++++++++++++++++++++----- 7 files changed, 130 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index d66785f..28f8f4a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,21 @@ The following environment variables can be configured in the `.env` file: If the SSH private key specified in `SSH_PRIVATE_KEY` doesn't exist, the application will automatically generate a new 4096-bit RSA key pair at the specified location. This makes it easier to get started without manually creating SSH keys. +### Memory Optimization + +The application uses a buffer pool with controlled buffer sizes to prevent excessive memory usage under high concurrent loads. The `BUFFER_SIZE` environment variable controls the size of buffers used for io.Copy operations: + +- **Default:** 32768 bytes (32 KB) - Good balance for most scenarios +- **Minimum:** 4096 bytes (4 KB) - Lower memory usage, more CPU overhead +- **Maximum:** 1048576 bytes (1 MB) - Higher throughput, more memory usage + +**Recommended settings based on load:** +- **Low traffic (<100 concurrent):** `BUFFER_SIZE=32768` (default) +- **High traffic (>100 concurrent):** `BUFFER_SIZE=16384` or `BUFFER_SIZE=8192` +- **Very high traffic (>1000 concurrent):** `BUFFER_SIZE=8192` or `BUFFER_SIZE=4096` + +The buffer pool reuses buffers across connections, preventing memory fragmentation and reducing garbage collection pressure. + ### Profiling with pprof To enable profiling for performance analysis: diff --git a/server/http.go b/server/http.go index 34fad0e..6c716d1 100644 --- a/server/http.go +++ b/server/http.go @@ -10,8 +10,11 @@ import ( "net" "regexp" "strings" + "time" "tunnel_pls/session" "tunnel_pls/utils" + + "golang.org/x/crypto/ssh" ) type Interaction interface { @@ -295,30 +298,38 @@ func Handler(conn net.Conn) { func forwardRequest(cw *CustomWriter, initialRequest *RequestHeaderFactory, sshSession *session.SSHSession) { payload := sshSession.Forwarder.CreateForwardedTCPIPPayload(cw.RemoteAddr) - channel, reqs, err := sshSession.Lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload) - if err != nil { - log.Printf("Failed to open forwarded-tcpip channel: %v", err) - if closer, ok := cw.writer.(io.Closer); ok { - if closeErr := closer.Close(); closeErr != nil { - log.Printf("Failed to close connection: %v", closeErr) - } + + type channelResult struct { + channel ssh.Channel + reqs <-chan *ssh.Request + err error + } + resultChan := make(chan channelResult, 1) + + go func() { + channel, reqs, err := sshSession.Lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload) + resultChan <- channelResult{channel, reqs, err} + }() + + var channel ssh.Channel + var reqs <-chan *ssh.Request + + select { + case result := <-resultChan: + if result.err != nil { + log.Printf("Failed to open forwarded-tcpip channel: %v", result.err) + sshSession.Forwarder.WriteBadGatewayResponse(cw.writer) + return } + channel = result.channel + reqs = result.reqs + case <-time.After(5 * time.Second): + log.Printf("Timeout opening forwarded-tcpip channel") + sshSession.Forwarder.WriteBadGatewayResponse(cw.writer) return } - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("Panic in request handler goroutine: %v", r) - } - }() - for req := range reqs { - if err := req.Reply(false, nil); err != nil { - log.Printf("Failed to reply to request: %v", err) - return - } - } - }() + go ssh.DiscardRequests(reqs) fingerprintMiddleware := NewTunnelFingerprint() forwardedForMiddleware := NewForwardedFor(cw.RemoteAddr) @@ -329,14 +340,13 @@ func forwardRequest(cw *CustomWriter, initialRequest *RequestHeaderFactory, sshS cw.reqHeader = initialRequest for _, m := range cw.reqStartMW { - err = m.HandleRequest(cw.reqHeader) - if err != nil { + if err := m.HandleRequest(cw.reqHeader); err != nil { log.Printf("Error handling request: %v", err) return } } - _, err = channel.Write(initialRequest.Finalize()) + _, err := channel.Write(initialRequest.Finalize()) if err != nil { log.Printf("Failed to forward request: %v", err) return diff --git a/server/server.go b/server/server.go index e81ac62..8fb85b0 100644 --- a/server/server.go +++ b/server/server.go @@ -23,20 +23,15 @@ func NewServer(config *ssh.ServerConfig) *Server { return nil } if utils.Getenv("TLS_ENABLED", "false") == "true" { - go func() { - err = NewHTTPSServer() - if err != nil { - log.Fatalf("failed to start https server: %v", err) - } - return - }() - } - go func() { - err = NewHTTPServer() + err = NewHTTPSServer() if err != nil { - log.Fatalf("failed to start http server: %v", err) + log.Fatalf("failed to start https server: %v", err) } - }() + } + err = NewHTTPServer() + if err != nil { + log.Fatalf("failed to start http server: %v", err) + } return &Server{ Conn: &listener, Config: config, diff --git a/session/forwarder/forwarder.go b/session/forwarder/forwarder.go index 3bf41bb..c993183 100644 --- a/session/forwarder/forwarder.go +++ b/session/forwarder/forwarder.go @@ -9,6 +9,7 @@ import ( "net" "strconv" "sync" + "time" "tunnel_pls/session/slug" "tunnel_pls/types" "tunnel_pls/utils" @@ -70,26 +71,52 @@ func (f *Forwarder) AcceptTCPConnections() { log.Printf("Error accepting connection: %v", err) continue } - payload := f.CreateForwardedTCPIPPayload(conn.RemoteAddr()) - channel, reqs, err := f.Lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload) - if err != nil { - log.Printf("Failed to open forwarded-tcpip channel: %v", err) + + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + log.Printf("Failed to set connection deadline: %v", err) if closeErr := conn.Close(); closeErr != nil { log.Printf("Failed to close connection: %v", closeErr) } continue } + payload := f.CreateForwardedTCPIPPayload(conn.RemoteAddr()) + + type channelResult struct { + channel ssh.Channel + reqs <-chan *ssh.Request + err error + } + resultChan := make(chan channelResult, 1) + go func() { - for req := range reqs { - err := req.Reply(false, nil) - if err != nil { - log.Printf("Failed to reply to request: %v", err) - return - } - } + channel, reqs, err := f.Lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload) + resultChan <- channelResult{channel, reqs, err} }() - go f.HandleConnection(conn, channel, conn.RemoteAddr()) + + select { + case result := <-resultChan: + if result.err != nil { + log.Printf("Failed to open forwarded-tcpip channel: %v", result.err) + if closeErr := conn.Close(); closeErr != nil { + log.Printf("Failed to close connection: %v", closeErr) + } + continue + } + + if err := conn.SetDeadline(time.Time{}); err != nil { + log.Printf("Failed to clear connection deadline: %v", err) + } + + go ssh.DiscardRequests(result.reqs) + go f.HandleConnection(conn, result.channel, conn.RemoteAddr()) + + case <-time.After(5 * time.Second): + log.Printf("Timeout opening forwarded-tcpip channel") + if closeErr := conn.Close(); closeErr != nil { + log.Printf("Failed to close connection: %v", closeErr) + } + } } } diff --git a/session/handler.go b/session/handler.go index 962aa65..eb61cfd 100644 --- a/session/handler.go +++ b/session/handler.go @@ -19,9 +19,6 @@ var blockedReservedPorts = []uint16{1080, 1433, 1521, 1900, 2049, 3306, 3389, 54 func (s *SSHSession) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) { for req := range GlobalRequest { switch req.Type { - case "tcpip-forward": - s.HandleTCPIPForward(req) - return case "shell", "pty-req", "window-change": err := req.Reply(true, nil) if err != nil { diff --git a/session/lifecycle/lifecycle.go b/session/lifecycle/lifecycle.go index 29b02ed..ecfc206 100644 --- a/session/lifecycle/lifecycle.go +++ b/session/lifecycle/lifecycle.go @@ -2,11 +2,8 @@ package lifecycle import ( "errors" - "fmt" "io" - "log" "net" - "time" portUtil "tunnel_pls/internal/port" "tunnel_pls/session/slug" "tunnel_pls/types" @@ -41,7 +38,6 @@ func (l *Lifecycle) SetUnregisterClient(unregisterClient func(slug string)) { type SessionLifecycle interface { Close() error - WaitForRunningStatus() SetStatus(status types.Status) GetConnection() ssh.Conn GetChannel() ssh.Channel @@ -62,33 +58,6 @@ func (l *Lifecycle) GetConnection() ssh.Conn { func (l *Lifecycle) SetStatus(status types.Status) { l.Status = status } -func (l *Lifecycle) WaitForRunningStatus() { - timeout := time.After(3 * time.Second) - ticker := time.NewTicker(150 * time.Millisecond) - defer ticker.Stop() - frames := []string{"-", "\\", "|", "/"} - i := 0 - for { - select { - case <-ticker.C: - l.Interaction.SendMessage(fmt.Sprintf("\rLoading %s", frames[i])) - i = (i + 1) % len(frames) - if l.Status == types.RUNNING { - l.Interaction.SendMessage("\r\033[K") - return - } - case <-timeout: - l.Interaction.SendMessage("\r\033[K") - l.Interaction.SendMessage("TCP/IP request not received in time.\r\nCheck your internet connection and confirm the server responds within 3000ms.\r\nEnsure you ran the correct command. For more details, visit https://tunnl.live.\r\n\r\n") - err := l.Close() - if err != nil { - log.Printf("failed to close session: %v", err) - } - log.Println("Timeout waiting for session to start running") - return - } - } -} func (l *Lifecycle) Close() error { err := l.Forwarder.Close() diff --git a/session/session.go b/session/session.go index f5f9ed5..1d23994 100644 --- a/session/session.go +++ b/session/session.go @@ -2,13 +2,15 @@ package session import ( "bytes" + "fmt" "log" "sync" + "time" "tunnel_pls/session/forwarder" "tunnel_pls/session/interaction" "tunnel_pls/session/lifecycle" "tunnel_pls/session/slug" - "tunnel_pls/types" + "tunnel_pls/utils" "golang.org/x/crypto/ssh" ) @@ -30,8 +32,6 @@ type SSHSession struct { Interaction interaction.Controller Forwarder forwarder.ForwardingController SlugManager slug.Manager - - channelOnce sync.Once } func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel) { @@ -71,20 +71,27 @@ func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan SlugManager: slugManager, } + var once sync.Once for channel := range sshChan { ch, reqs, err := channel.Accept() if err != nil { log.Printf("failed to accept channel: %v", err) continue } - session.channelOnce.Do(func() { + once.Do(func() { session.Lifecycle.SetChannel(ch) session.Interaction.SetChannel(ch) - session.Lifecycle.SetStatus(types.SETUP) - go session.HandleGlobalRequest(forwardingReq) - session.Lifecycle.WaitForRunningStatus() - }) + tcpipReq := session.waitForTCPIPForward(forwardingReq) + if tcpipReq == nil { + session.Interaction.SendMessage(fmt.Sprintf("Port forwarding request not received.\r\nEnsure you ran the correct command with -R flag.\r\nExample: ssh %s -p %s -R 80:localhost:3000\r\nFor more details, visit https://tunnl.live.\r\n\r\n", utils.Getenv("DOMAIN", "localhost"), utils.Getenv("PORT", "2200"))) + if err := session.Lifecycle.Close(); err != nil { + log.Printf("failed to close session: %v", err) + } + return + } + session.HandleTCPIPForward(tcpipReq) + }) go session.HandleGlobalRequest(reqs) } if err := session.Lifecycle.Close(); err != nil { @@ -92,6 +99,27 @@ func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan } } +func (s *SSHSession) waitForTCPIPForward(forwardingReq <-chan *ssh.Request) *ssh.Request { + select { + case req, ok := <-forwardingReq: + if !ok { + log.Println("Forwarding request channel closed") + return nil + } + if req.Type == "tcpip-forward" { + return req + } + if err := req.Reply(false, nil); err != nil { + log.Printf("Failed to reply to request: %v", err) + } + log.Printf("Expected tcpip-forward request, got: %s", req.Type) + return nil + case <-time.After(500 * time.Millisecond): + log.Println("No forwarding request received") + return nil + } +} + func updateClientSlug(oldSlug, newSlug string) bool { clientsMutex.Lock() defer clientsMutex.Unlock() -- 2.49.1 From c69cd6882071d3789fa8173986db6f7b00881d3d Mon Sep 17 00:00:00 2001 From: bagas Date: Fri, 26 Dec 2025 23:44:50 +0700 Subject: [PATCH 4/9] feat: add certmagic for automatic TLS certificate management --- .gitignore | 1 + README.md | 34 ++++ certs/cert.pem | 26 --- certs/localhost.direct.SS.crt | 21 --- certs/localhost.direct.SS.key | 28 --- certs/privkey.pem | 28 --- go.mod | 20 ++- go.sum | 32 ++++ server/https.go | 9 +- server/tls.go | 312 ++++++++++++++++++++++++++++++++++ 10 files changed, 403 insertions(+), 108 deletions(-) delete mode 100644 certs/cert.pem delete mode 100644 certs/localhost.direct.SS.crt delete mode 100644 certs/localhost.direct.SS.key delete mode 100644 certs/privkey.pem create mode 100644 server/tls.go diff --git a/.gitignore b/.gitignore index f71d6af..bfc3046 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ id_rsa* .idea .env tmp +certs diff --git a/README.md b/README.md index 28f8f4a..7a52b04 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ The following environment variables can be configured in the `.env` file: | `TLS_REDIRECT` | Redirect HTTP to HTTPS | `false` | No | | `CERT_LOC` | Path to TLS certificate | `certs/cert.pem` | No | | `KEY_LOC` | Path to TLS private key | `certs/privkey.pem` | No | +| `CERT_STORAGE_PATH` | Path for CertMagic certificate storage | `certs/certmagic` | No | +| `ACME_EMAIL` | Email for Let's Encrypt registration | `admin@` | No | +| `CF_API_TOKEN` | Cloudflare API token for DNS-01 challenge | - | Yes (if auto-cert) | +| `ACME_STAGING` | Use Let's Encrypt staging server | `false` | No | | `SSH_PRIVATE_KEY` | Path to SSH private key (auto-generated if missing) | `certs/id_rsa` | No | | `CORS_LIST` | Comma-separated list of allowed CORS origins | - | No | | `ALLOWED_PORTS` | Port range for TCP tunnels (e.g., 40000-41000) | `40000-41000` | No | @@ -35,6 +39,36 @@ The following environment variables can be configured in the `.env` file: **Note:** All environment variables now use UPPERCASE naming. The application includes sensible defaults for all variables, so you can run it without a `.env` file for basic functionality. +### Automatic TLS Certificate Management + +The server supports automatic TLS certificate generation and renewal using [CertMagic](https://github.com/caddyserver/certmagic) with Cloudflare DNS-01 challenge. This is required for wildcard certificate support (`*.yourdomain.com`). + +**How it works:** +1. If user-provided certificates (`CERT_LOC`, `KEY_LOC`) exist and cover both `DOMAIN` and `*.DOMAIN`, they will be used +2. If certificates are missing, expired, expiring within 30 days, or don't cover the required domains, CertMagic will automatically obtain new certificates from Let's Encrypt +3. Certificates are automatically renewed before expiration +4. User-provided certificates support hot-reload (changes detected every 30 seconds) + +**Cloudflare API Token Setup:** + +To use automatic certificate generation, you need a Cloudflare API token with the following permissions: + +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens) +2. Click "Create Token" +3. Use "Create Custom Token" with these permissions: + - **Zone → Zone → Read** (for all zones or specific zone) + - **Zone → DNS → Edit** (for all zones or specific zone) +4. Copy the token and set it as `CF_API_TOKEN` environment variable + +**Example configuration for automatic certificates:** +```env +DOMAIN=example.com +TLS_ENABLED=true +CF_API_TOKEN=your_cloudflare_api_token_here +ACME_EMAIL=admin@example.com +# ACME_STAGING=true # Uncomment for testing to avoid rate limits +``` + ### SSH Key Auto-Generation If the SSH private key specified in `SSH_PRIVATE_KEY` doesn't exist, the application will automatically generate a new 4096-bit RSA key pair at the specified location. This makes it easier to get started without manually creating SSH keys. diff --git a/certs/cert.pem b/certs/cert.pem deleted file mode 100644 index 0e2136d..0000000 --- a/certs/cert.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEWzCCA0OgAwIBAgIUDYTdEDHwVznxV/qnn0/WVlHNMZAwDQYJKoZIhvcNAQEL -BQAwgZYxCzAJBgNVBAYTAklEMQ8wDQYDVQQIDAZCYW50ZW4xGjAYBgNVBAcMEVRh -bmdlcmFuZyBTZWxhdGFuMRIwEAYDVQQKDAlGb3NzeSBMVFMxDjAMBgNVBAsMBUZv -c3N5MRQwEgYDVQQDDAtmb3NzeS5teS5pZDEgMB4GCSqGSIb3DQEJARYRYmFnYXNA -Zm9zc3kubXkuaWQwHhcNMjUwMjA3MTYyMTU1WhcNMjYwMjA3MTYyMTU1WjCBljEL -MAkGA1UEBhMCSUQxDzANBgNVBAgMBkJhbnRlbjEaMBgGA1UEBwwRVGFuZ2VyYW5n -IFNlbGF0YW4xEjAQBgNVBAoMCUZvc3N5IExUUzEOMAwGA1UECwwFRm9zc3kxFDAS -BgNVBAMMC2Zvc3N5Lm15LmlkMSAwHgYJKoZIhvcNAQkBFhFiYWdhc0Bmb3NzeS5t -eS5pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMU3pCA0eP+VR2CA -+p3p0BgKw33xCKQmQx52jnqNbvwW4NMMDc3mS6a+wb6QB6v0WLGMI1g22TtJPatx -7dcy4PCSOCV9xJ2Yfq5HlQgDHYoyE+juy4/pGlMjo45thJ0yI8zOSzaz2esosP22 -XkfFwj7oVMXXPIY6UovcAlGU+DFtwVrNa76/esUwJs+7M3tBubjkpcal0wXR+SIX -3fmw5v0YzKV8qLGMGOvX6+OyLQCx4r9gB0d+WOrT6EfrPfAzo07NKjzWG9aWl1rk -Q+h0i38hke2MTFxId7za57L+NlXRjLo3ESbBF0hjYquTQOG3jx/UvWs+I1NEfsdu -vj8beiMCAwEAAaOBnjCBmzAfBgNVHSMEGDAWgBT6nj69+I+GSdgScjfOqnVFzX7G -aTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAs -BgNVHREEJTAjgglsb2NhbGhvc3SCCTEyNy4wLjAuMYILKi5sb2NhbGhvc3QwHQYD -VR0OBBYEFD9ci20pAUTRLVWxvVXjfE2GKwSAMA0GCSqGSIb3DQEBCwUAA4IBAQAT -rkjU+GzQy9B3/nd/79N7ozK80ygzmnRnj3ou/bbqUHOIYQQgKM1cuN6zh57ovRh/ -u45s6pZUOUVN59POFUCvqUiOgsDkY/auXGbKtzzqzoZABuvm85ySd6zurOOx8tA5 -e7ArX1ppy3LgzSb+cXANvzYC9bCwp70w2YylMFhwHBAp5TXRVqsqG6jujD8GMLoS -zDSDx8M6o8gqWmPQve7Saim9mgLJUvvCYBzvowuNjzZrJfAeGoIfLV127xCQiylm -fzUQ1Ac6udldWm9scA32nteSQMWg2d1nW3RG1nRondp13WUkgGQ490/c97D3MKLt -D1HB5dLIYkRVHVhCKHT1 ------END CERTIFICATE----- diff --git a/certs/localhost.direct.SS.crt b/certs/localhost.direct.SS.crt deleted file mode 100644 index d36fa1f..0000000 --- a/certs/localhost.direct.SS.crt +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDjDCCAnSgAwIBAgIUPpuvw4ZdpnDBbwOZBt2ALfZykuYwDQYJKoZIhvcNAQEL -BQAwPDELMAkGA1UEBhMCVUsxDzANBgNVBAgMBkxvbmRvbjEPMA0GA1UEBwwGTG9u -ZG9uMQswCQYDVQQLDAJIUTAeFw0yNDExMTkxNjQxNTNaFw0zNDExMTcxNjQxNTNa -MDwxCzAJBgNVBAYTAlVLMQ8wDQYDVQQIDAZMb25kb24xDzANBgNVBAcMBkxvbmRv -bjELMAkGA1UECwwCSFEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD -L/aeaBzgkYxDNiQq7+nt6tKDfGnPDfBXunJlr1xbZfIVpJeDqwrNLWaZ0gtHci5E -sHptIpu5/uTBFaFyZVH604etY/YHIsff7BT2y32OobJYiKy2lvAI3/IDR4TGeDgA -HKryOwMcB5DheBVdeggxj36m8OjxhVADaiKp7BNXE2eqUqk8f2QpwqLQYe9UaU9r -WSJllNHOFk+RH17YBDiyyQ8CD1Vf5HcVSmPItWOMQytHcSgy0DHVXQCde86mky8t -8Ik74GeNrM6f+vWR4OfQ8dU2WSyTUE4c7czagkToheMX5fbbzWJkJcd2SD9wvyIk -tOot0YiZGQAoOedtGSEnAgMBAAGjgYUwgYIwCQYDVR0TBAIwADALBgNVHQ8EBAMC -BeAwSQYDVR0RBEIwQIIQbG9jYWxob3N0LmRpcmVjdIISKi5sb2NhbGhvc3QuZGly -ZWN0ghhTUy5jZXJ0LmxvY2FsaG9zdC5kaXJlY3QwHQYDVR0OBBYEFKBVeirQGZ4D -4AKVPd7LGfCF1wEZMA0GCSqGSIb3DQEBCwUAA4IBAQCRpvsc5DrQBo8yATmUS0OK -xfUXfZR28u3xYY+qMHi+ngIVT2TKJ1yoBJezV6WwQLkcGdWacULvPYt3jCFUtaP7 -+hzfs5y1FssFsXDx+r3pQxYyE6BX3BhlrJPJhLRyG1siTTgN439Qu40/TsTzNgAT -A9GbAro+W6+qA4H92mBlyfQEOBossID/Kk95uvDnQguUOp0ZBLgFNRfE6Ra9+yC+ -ufAOksYDrisPE6kZId0Ra4Ln/GmrIXKjjmLCitq2q2REC/70JSCnaJcBYeThSJLZ -AZ24AF+JteakSJ8FEgRGxvSu0wdZfnMobNoelsjai1p5l5mCTiD8GH9sQCkslnp3 ------END CERTIFICATE----- diff --git a/certs/localhost.direct.SS.key b/certs/localhost.direct.SS.key deleted file mode 100644 index 0e0aa47..0000000 --- a/certs/localhost.direct.SS.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDL/aeaBzgkYxD -NiQq7+nt6tKDfGnPDfBXunJlr1xbZfIVpJeDqwrNLWaZ0gtHci5EsHptIpu5/uTB -FaFyZVH604etY/YHIsff7BT2y32OobJYiKy2lvAI3/IDR4TGeDgAHKryOwMcB5Dh -eBVdeggxj36m8OjxhVADaiKp7BNXE2eqUqk8f2QpwqLQYe9UaU9rWSJllNHOFk+R -H17YBDiyyQ8CD1Vf5HcVSmPItWOMQytHcSgy0DHVXQCde86mky8t8Ik74GeNrM6f -+vWR4OfQ8dU2WSyTUE4c7czagkToheMX5fbbzWJkJcd2SD9wvyIktOot0YiZGQAo -OedtGSEnAgMBAAECggEASXibb2MnQ4zl7ELL+HGYb5sNpLrHJU5M4ujmuMn6jNjh -+C2dbs2KYlMtpMcAweMD8Y0weDYnwiplNx0KSYJECpNnJehTqrn33J0EAyXz3CWX -eWXxBUXpkp2hfoSEQSTth3VDD60Q7ZMXgRdvi2EtBmLKPNLADHGu/aoM5ENdwE9/ -E80X68E+MT2czOY/sEI/w2Tf/S2ZVOHRvFOsmvTLFlbiElWG8pmguajpJwdae7uX -c5VD87b0oYicSUvaHe6xOCCyyeBVq06sWk+vh6Tzrw6K/B+q0SYvzXdrdsJxbzUD -PvhVi9rf9AC1Ncb6lFOP2ZjGfqYQvfgGXKqaVxXWQQKBgQD8frATynMSeR15oERa -+Maa7r3GlwWV3tkUblX7/FxBo9UhAsivnWZprccZR43YPowbWNeU3AQppf/YDxmx -70/RVTCMjXPGyspSS2iFtcp+K2KnIZA4BuAG/s1EBrAUW2KrtaTaXgYu9usgb+Fq -dJUEBDWrL59XXtQUwSM1laH0BwKBgQDF5Z31VY96xOS7flmolq8Ag+frSMH0G4np -3nUnTlUkgpFXE5FkmUccbYZv9QialAVriBBUNANPDBQH4PRrNnX8Z6B2HJbpAy30 -II9jPMTNKnXT3RKFcJCamNkOQhT0sBAwm4gTsx+7gbpdZxinxH0Cr/08sfU4lbee -EtMV/J1h4QKBgC4MLLBvS20jCW0U/WJZ3F6FC7cb87jRW2WOeb/q1ihiaIwMpezh -F7xOJPFHS2cUgRi7qxVKyreNvor4tgbtTfEvSBtZ8LNgaGV5uyYncTZxUxyH0nVl -S5X7AhRV4+bSg7ws9FOesiH+hgL0ZHe1qzeATQlbNgQJF0RxtKohD9ghAoGAcM0N -WIZInoYUivreSEZ7wiNt0qNKSsZXukLfLGRuC72Q8r1opprn+cBEXRSirtmorT6F -cDmlmS0dTdBgAaytXA4FXM23B2KUkw7sLHi7BOcq+nSM1hrvke+F6aapI0AoOkyt -J+12LP8pJ4xYdWh+iUWfZzVYvcQ5QZUhVOsFGoECgYA9llKmc/cFaXrCr97g7ls8 -ZWW1kQKLawAv6+90dwECJl+zpyQN/TURyEfz6oFJDbNEL0xAAcm9thDah177Tq95 -pcHbVn/pAI4h4CLzM2ExSe8Ybvy6iPZaBiCVXq3ms1PK0EDyYLH1p3FZqm+UStZh -/6fYspyivrQEivRK3jGuWA== ------END PRIVATE KEY----- diff --git a/certs/privkey.pem b/certs/privkey.pem deleted file mode 100644 index bedf787..0000000 --- a/certs/privkey.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDFN6QgNHj/lUdg -gPqd6dAYCsN98QikJkMedo56jW78FuDTDA3N5kumvsG+kAer9FixjCNYNtk7ST2r -ce3XMuDwkjglfcSdmH6uR5UIAx2KMhPo7suP6RpTI6OObYSdMiPMzks2s9nrKLD9 -tl5HxcI+6FTF1zyGOlKL3AJRlPgxbcFazWu+v3rFMCbPuzN7Qbm45KXGpdMF0fki -F935sOb9GMylfKixjBjr1+vjsi0AseK/YAdHfljq0+hH6z3wM6NOzSo81hvWlpda -5EPodIt/IZHtjExcSHe82uey/jZV0Yy6NxEmwRdIY2Krk0Dht48f1L1rPiNTRH7H -br4/G3ojAgMBAAECggEAATiJ23ZhS1++squ87jsgAfR+TYP8Kpv408u/43Ig5KgC -ZnwPUWqvP2e0TLwyhMKwXxHMt2nITxQlSnyCXU/5nk07BYxkqhiwEni4Xo86YK+H -rQXEnKFaSHdF6dNNIo+VZiark4adS9XgJp0fsn0LnON7GhBt72MvPW6auxH1HJIC -rcjnzJu/KzTVrId9QNsEDl9cNRlHXuPSfohdq2o39PBKGDeDEDTvP9wHtasEF6oe -uj1OH5fAxiXptT0Ln0Y8QeFe8R8Odul5mUXCgMOkKmHZPDxTq9ldCuPjrz15JfnS -T3MecaWdvChpIP2WybjLstJy9qXTl0fhRkpJWpaVbQKBgQDu9puq64vZn0jak7ns -bFbKPemFw7tiiwwnqyzqCTRddw1vETJN/MgpRWvc7d1Cvzs2+Z2kn0Cgpdw/30Ej -VRHiE/d1rj+u33F6vGBUBccRue1t0UdFMFF/YnlNDRRnljQgx4N3mezzNE2RikR8 -jVp+jvWTSTgf3y4Hip1ffCSNtQKBgQDTRx1huSzUOed/36YLdnxP+AlV3L+c/EKU -GItKZk2bqRlqgs6wcsNlVjveb9o9j6M5pg/lh5HaqJyhj8DYVnEdmXfcQJiUfIPh -5802asIXQVBmL9rTMR34wsr/lzzK3k0KORqFcUdf2fDI/UQNpVUJvWQQY8fl2sVo -zmp88JAPdwKBgQCLC6fsvn5ztMF5nffTX/7oUzosgYXpgyshcfMCgzSbJgkFFaaF -xo7ZpPFsbmQO0KMuC/T0s02xrJEKAWgvnPJ48FFPgoK/yHiJiE8s1OfOordK7Tlh -QwpI6w3WDcRPuhC++hi/YSuFIGv6QdA0ATQk7B5tA2/K69wmuztzMhM6+QKBgH5E -LwQbRfZj0L20bKjHHA4y32loLz/j5upZLM2/DDyuN9lW6a28OJiUi90pHdXSxSsL -2s5DUmDKiiloH0lrh9i3wlFobYe4Tp0xCoyuCucZCrK3gODcptvnlqhfu15GsuYc -MIR1qcFYH7YO3qAFIiha/rVo3Ku7LmWvjyayInaLAoGAZ/dELEAcyhImf1HvtmBT -qNg4uI6t/2fvHdoAeQkkGjEDWFTGEaMp0cJEbETPD68TwNh5dL/xUJibTwMbK+m5 -rdIA2oTZMkFtWubIvMCrD/J6E+8Pzl+eK+0C0axO/I29S4veORNGWvCtqdFICDgZ -CluJ0NZFeMH8g8tgfI9HnHc= ------END PRIVATE KEY----- diff --git a/go.mod b/go.mod index 09be3c3..785f70d 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,22 @@ require ( golang.org/x/crypto v0.45.0 ) -require golang.org/x/sys v0.38.0 // indirect +require ( + github.com/caddyserver/certmagic v0.25.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.3 // indirect + github.com/miekg/dns v1.1.68 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect +) diff --git a/go.sum b/go.sum index 27269bf..e593e12 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,37 @@ +github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= +github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc= +github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= @@ -11,3 +39,7 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= diff --git a/server/https.go b/server/https.go index f65a0ba..2964d4f 100644 --- a/server/https.go +++ b/server/https.go @@ -13,13 +13,14 @@ import ( ) func NewHTTPSServer() error { - cert, err := tls.LoadX509KeyPair(utils.Getenv("CERT_LOC", "certs/cert.pem"), utils.Getenv("KEY_LOC", "certs/privkey.pem")) + domain := utils.Getenv("DOMAIN", "localhost") + + tlsConfig, err := NewTLSConfig(domain) if err != nil { - return err + return fmt.Errorf("failed to initialize TLS config: %w", err) } - config := &tls.Config{Certificates: []tls.Certificate{cert}} - ln, err := tls.Listen("tcp", ":443", config) + ln, err := tls.Listen("tcp", ":443", tlsConfig) if err != nil { return err } diff --git a/server/tls.go b/server/tls.go new file mode 100644 index 0000000..e5b3105 --- /dev/null +++ b/server/tls.go @@ -0,0 +1,312 @@ +package server + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "os" + "sync" + "time" + "tunnel_pls/utils" + + "github.com/caddyserver/certmagic" + "github.com/libdns/cloudflare" +) + +type TLSManager struct { + domain string + certPath string + keyPath string + storagePath string + + userCert *tls.Certificate + userCertMu sync.RWMutex + + magic *certmagic.Config + + useCertMagic bool +} + +var tlsManager *TLSManager +var tlsManagerOnce sync.Once + +func NewTLSConfig(domain string) (*tls.Config, error) { + var initErr error + + tlsManagerOnce.Do(func() { + certPath := utils.Getenv("CERT_LOC", "certs/cert.pem") + keyPath := utils.Getenv("KEY_LOC", "certs/privkey.pem") + storagePath := utils.Getenv("CERT_STORAGE_PATH", "certs/certmagic") + + tm := &TLSManager{ + domain: domain, + certPath: certPath, + keyPath: keyPath, + storagePath: storagePath, + } + + if tm.userCertsExistAndValid() { + log.Printf("Using user-provided certificates from %s and %s", certPath, keyPath) + if err := tm.loadUserCerts(); err != nil { + initErr = fmt.Errorf("failed to load user certificates: %w", err) + return + } + tm.useCertMagic = false + tm.startCertWatcher() + } else { + if !isACMEConfigComplete() { + log.Printf("User certificates missing or invalid, and ACME configuration is incomplete") + log.Printf("To enable automatic certificate generation, set CF_API_TOKEN environment variable") + initErr = fmt.Errorf("no valid certificates found and ACME configuration is incomplete (CF_API_TOKEN is required)") + return + } + + log.Printf("User certificates missing or don't cover %s and *.%s, using CertMagic", domain, domain) + if err := tm.initCertMagic(); err != nil { + initErr = fmt.Errorf("failed to initialize CertMagic: %w", err) + return + } + tm.useCertMagic = true + } + + tlsManager = tm + }) + + if initErr != nil { + return nil, initErr + } + + return tlsManager.getTLSConfig(), nil +} + +func isACMEConfigComplete() bool { + cfAPIToken := utils.Getenv("CF_API_TOKEN", "") + return cfAPIToken != "" +} + +func (tm *TLSManager) userCertsExistAndValid() bool { + if _, err := os.Stat(tm.certPath); os.IsNotExist(err) { + log.Printf("Certificate file not found: %s", tm.certPath) + return false + } + if _, err := os.Stat(tm.keyPath); os.IsNotExist(err) { + log.Printf("Key file not found: %s", tm.keyPath) + return false + } + + return ValidateCertDomains(tm.certPath, tm.domain) +} + +func ValidateCertDomains(certPath, domain string) bool { + certPEM, err := os.ReadFile(certPath) + if err != nil { + log.Printf("Failed to read certificate: %v", err) + return false + } + + block, _ := pem.Decode(certPEM) + if block == nil { + log.Printf("Failed to decode PEM block from certificate") + return false + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + log.Printf("Failed to parse certificate: %v", err) + return false + } + + if time.Now().After(cert.NotAfter) { + log.Printf("Certificate has expired (NotAfter: %v)", cert.NotAfter) + return false + } + + if time.Now().Add(30 * 24 * time.Hour).After(cert.NotAfter) { + log.Printf("Certificate expiring soon (NotAfter: %v), will use CertMagic for renewal", cert.NotAfter) + return false + } + + var certDomains []string + if cert.Subject.CommonName != "" { + certDomains = append(certDomains, cert.Subject.CommonName) + } + certDomains = append(certDomains, cert.DNSNames...) + + hasBase := false + hasWildcard := false + wildcardDomain := "*." + domain + + for _, d := range certDomains { + if d == domain { + hasBase = true + } + if d == wildcardDomain { + hasWildcard = true + } + } + + if !hasBase { + log.Printf("Certificate does not cover base domain: %s", domain) + } + if !hasWildcard { + log.Printf("Certificate does not cover wildcard domain: %s", wildcardDomain) + } + + return hasBase && hasWildcard +} + +func (tm *TLSManager) loadUserCerts() error { + cert, err := tls.LoadX509KeyPair(tm.certPath, tm.keyPath) + if err != nil { + return err + } + + tm.userCertMu.Lock() + tm.userCert = &cert + tm.userCertMu.Unlock() + + log.Printf("Loaded user certificates successfully") + return nil +} + +func (tm *TLSManager) startCertWatcher() { + go func() { + var lastCertMod, lastKeyMod time.Time + + if info, err := os.Stat(tm.certPath); err == nil { + lastCertMod = info.ModTime() + } + if info, err := os.Stat(tm.keyPath); err == nil { + lastKeyMod = info.ModTime() + } + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + certInfo, certErr := os.Stat(tm.certPath) + keyInfo, keyErr := os.Stat(tm.keyPath) + + if certErr != nil || keyErr != nil { + continue + } + + if certInfo.ModTime().After(lastCertMod) || keyInfo.ModTime().After(lastKeyMod) { + log.Printf("Certificate files changed, reloading...") + + if !ValidateCertDomains(tm.certPath, tm.domain) { + log.Printf("New certificates don't cover required domains") + + if !isACMEConfigComplete() { + log.Printf("Cannot switch to CertMagic: ACME configuration is incomplete (CF_API_TOKEN is required)") + continue + } + + log.Printf("Switching to CertMagic for automatic certificate management") + if err := tm.initCertMagic(); err != nil { + log.Printf("Failed to initialize CertMagic: %v", err) + continue + } + tm.useCertMagic = true + return + } + + if err := tm.loadUserCerts(); err != nil { + log.Printf("Failed to reload certificates: %v", err) + continue + } + + lastCertMod = certInfo.ModTime() + lastKeyMod = keyInfo.ModTime() + log.Printf("Certificates reloaded successfully") + } + } + }() +} + +func (tm *TLSManager) initCertMagic() error { + if err := os.MkdirAll(tm.storagePath, 0700); err != nil { + return fmt.Errorf("failed to create cert storage directory: %w", err) + } + + acmeEmail := utils.Getenv("ACME_EMAIL", "admin@"+tm.domain) + cfAPIToken := utils.Getenv("CF_API_TOKEN", "") + acmeStaging := utils.Getenv("ACME_STAGING", "false") == "true" + + if cfAPIToken == "" { + return fmt.Errorf("CF_API_TOKEN environment variable is required for automatic certificate generation") + } + + cfProvider := &cloudflare.Provider{ + APIToken: cfAPIToken, + } + + storage := &certmagic.FileStorage{Path: tm.storagePath} + + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) { + return tm.magic, nil + }, + }) + + magic := certmagic.New(cache, certmagic.Config{ + Storage: storage, + }) + + acmeIssuer := certmagic.NewACMEIssuer(magic, certmagic.ACMEIssuer{ + Email: acmeEmail, + Agreed: true, + DNS01Solver: &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + DNSProvider: cfProvider, + }, + }, + }) + + if acmeStaging { + acmeIssuer.CA = certmagic.LetsEncryptStagingCA + log.Printf("Using Let's Encrypt staging server") + } else { + acmeIssuer.CA = certmagic.LetsEncryptProductionCA + log.Printf("Using Let's Encrypt production server") + } + + magic.Issuers = []certmagic.Issuer{acmeIssuer} + tm.magic = magic + + domains := []string{tm.domain, "*." + tm.domain} + log.Printf("Requesting certificates for: %v", domains) + + ctx := context.Background() + if err := magic.ManageSync(ctx, domains); err != nil { + return fmt.Errorf("failed to obtain certificates: %w", err) + } + + log.Printf("Certificates obtained successfully for %v", domains) + return nil +} + +func (tm *TLSManager) getTLSConfig() *tls.Config { + return &tls.Config{ + GetCertificate: tm.getCertificate, + MinVersion: tls.VersionTLS12, + } +} + +func (tm *TLSManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if tm.useCertMagic { + return tm.magic.GetCertificate(hello) + } + + tm.userCertMu.RLock() + defer tm.userCertMu.RUnlock() + + if tm.userCert == nil { + return nil, fmt.Errorf("no certificate available") + } + + return tm.userCert, nil +} -- 2.49.1 From e3c4f59a7727b68afb9d7ff5279b607d6a0e4b1c Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 28 Dec 2025 13:15:05 +0700 Subject: [PATCH 5/9] Add GitHub to Gitea sync workflow --- .github/workflows/sync-to-gitea.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/sync-to-gitea.yml diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml new file mode 100644 index 0000000..e3d8d3f --- /dev/null +++ b/.github/workflows/sync-to-gitea.yml @@ -0,0 +1,27 @@ +name: Sync to Gitea + +on: + push: + branches: + - '**' + +jobs: + sync: + runs-on: ubuntu-latest + if: | + github.repository == 'fossyy/tunnel-please' && + !contains(github.event.head_commit.message, '[skip-gitea]') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to Gitea + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_URL: git.fossy.my.id + run: | + git remote add gitea https://${{ secrets.GITEA_USERNAME }}:${GITEA_TOKEN}@${GITEA_URL}/bagas/tunnel-please.git + git push gitea ${{ github.ref }} --force -- 2.49.1 From fa6b097d661937a3337ea52eec88e5677840f2bb Mon Sep 17 00:00:00 2001 From: Bagas Aulia Rezki Date: Sun, 28 Dec 2025 13:19:45 +0700 Subject: [PATCH 6/9] Add test file --- test | 1 + 1 file changed, 1 insertion(+) create mode 100644 test diff --git a/test b/test new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/test @@ -0,0 +1 @@ +test -- 2.49.1 From cb8529f13eab75a011f043c6de90c12480c53ae3 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 28 Dec 2025 06:20:21 +0000 Subject: [PATCH 7/9] Update test --- test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test b/test index 9daeafb..2969be3 100644 --- a/test +++ b/test @@ -1 +1 @@ -test +test 2 -- 2.49.1 From 7348bdafb746ebb3159d29f8865f7a559cabe7f3 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 28 Dec 2025 06:24:40 +0000 Subject: [PATCH 8/9] Delete test --- test | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test diff --git a/test b/test deleted file mode 100644 index 2969be3..0000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -test 2 -- 2.49.1 From 9a2a373eb3c29ca51addf6a14bb20b93269308d4 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 28 Dec 2025 14:51:00 +0700 Subject: [PATCH 9/9] feat: add renovate --- .gitea/workflows/renovate.yml | 20 ++++++++++++++++++++ renovate-config.js | 8 ++++++++ renovate.json | 11 +++++++++++ 3 files changed, 39 insertions(+) create mode 100644 .gitea/workflows/renovate.yml create mode 100644 renovate-config.js create mode 100644 renovate.json diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml new file mode 100644 index 0000000..a4e6eaa --- /dev/null +++ b/.gitea/workflows/renovate.yml @@ -0,0 +1,20 @@ +name: renovate + +on: + schedule: + - cron: "0 0 * * *" + push: + branches: + - main + +jobs: + renovate: + runs-on: ubuntu-latest + container: git.fossy.my.id/renovate-clanker/renovate:latest + steps: + - uses: actions/checkout@v4 + - run: renovate + env: + RENOVATE_CONFIG_FILE: ${{ gitea.workspace }}/renovate-config.js + LOG_LEVEL: "debug" + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} \ No newline at end of file diff --git a/renovate-config.js b/renovate-config.js new file mode 100644 index 0000000..883f95e --- /dev/null +++ b/renovate-config.js @@ -0,0 +1,8 @@ +module.exports = { + "endpoint": "https://git.fossy.my.id/api/v1", + "gitAuthor": "Renovate Bot ", + "platform": "gitea", + "onboardingConfigFileName": "renovate.json", + "autodiscover": true, + "optimizeForDisabled": true, +}; \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..a492bc9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "config:base" + ], + "packageRules": [ + { + "updateTypes": ["minor", "patch", "pin", "digest"], + "automerge": true + } + ] +} \ No newline at end of file -- 2.49.1