mirror of
https://github.com/Gouryella/drip.git
synced 2026-02-24 05:10:43 +00:00
feat(tunnel): switch to yamux stream proxying and connection pooling
- Introduce pooled tunnel sessions (TunnelID/DataConnect) on client/server - Proxy HTTP/HTTPS via raw HTTP over yamux streams; pipe TCP streams directly - Move UI/stats into internal/shared; refactor CLI tunnel helpers; drop msgpack/hpack legacy
This commit is contained in:
@@ -12,7 +12,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/cli/ui"
|
||||
"drip/internal/shared/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ var attachCmd = &cobra.Command{
|
||||
Examples:
|
||||
drip attach List running tunnels and select one
|
||||
drip attach http 3000 Attach to HTTP tunnel on port 3000
|
||||
drip attach https 8443 Attach to HTTPS tunnel on port 8443
|
||||
drip attach tcp 5432 Attach to TCP tunnel on port 5432
|
||||
|
||||
Press Ctrl+C to detach (tunnel will continue running).`,
|
||||
@@ -36,7 +37,7 @@ func init() {
|
||||
rootCmd.AddCommand(attachCmd)
|
||||
}
|
||||
|
||||
func runAttach(cmd *cobra.Command, args []string) error {
|
||||
func runAttach(_ *cobra.Command, args []string) error {
|
||||
CleanupStaleDaemons()
|
||||
|
||||
daemons, err := ListAllDaemons()
|
||||
@@ -59,8 +60,8 @@ func runAttach(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if len(args) == 2 {
|
||||
tunnelType := args[0]
|
||||
if tunnelType != "http" && tunnelType != "tcp" {
|
||||
return fmt.Errorf("invalid tunnel type: %s (must be 'http' or 'tcp')", tunnelType)
|
||||
if tunnelType != "http" && tunnelType != "https" && tunnelType != "tcp" {
|
||||
return fmt.Errorf("invalid tunnel type: %s (must be 'http', 'https', or 'tcp')", tunnelType)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(args[1])
|
||||
@@ -119,10 +120,13 @@ func selectDaemonInteractive(daemons []*DaemonInfo) (*DaemonInfo, error) {
|
||||
uptime := time.Since(d.StartTime)
|
||||
|
||||
var typeStr string
|
||||
if d.Type == "http" {
|
||||
typeStr = ui.Success("HTTP")
|
||||
} else {
|
||||
typeStr = ui.Highlight("TCP")
|
||||
switch d.Type {
|
||||
case "http":
|
||||
typeStr = ui.Highlight("HTTP")
|
||||
case "https":
|
||||
typeStr = ui.Highlight("HTTPS")
|
||||
default:
|
||||
typeStr = ui.Cyan("TCP")
|
||||
}
|
||||
|
||||
table.AddRow([]string{
|
||||
@@ -196,7 +200,7 @@ func attachToDaemon(daemon *DaemonInfo) error {
|
||||
select {
|
||||
case <-sigCh:
|
||||
if tailCmd.Process != nil {
|
||||
tailCmd.Process.Kill()
|
||||
_ = tailCmd.Process.Kill()
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(ui.Warning("Detached from tunnel (tunnel is still running)"))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"drip/internal/client/cli/ui"
|
||||
"drip/internal/shared/ui"
|
||||
"drip/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -77,7 +77,7 @@ func init() {
|
||||
rootCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
func runConfigInit(cmd *cobra.Command, args []string) error {
|
||||
func runConfigInit(_ *cobra.Command, _ []string) error {
|
||||
fmt.Print(ui.RenderConfigInit())
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
@@ -109,7 +109,7 @@ func runConfigInit(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigShow(cmd *cobra.Command, args []string) error {
|
||||
func runConfigShow(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -137,7 +137,7 @@ func runConfigShow(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigSet(cmd *cobra.Command, args []string) error {
|
||||
func runConfigSet(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
cfg = &config.ClientConfig{
|
||||
@@ -173,7 +173,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigReset(cmd *cobra.Command, args []string) error {
|
||||
func runConfigReset(_ *cobra.Command, _ []string) error {
|
||||
configPath := config.DefaultClientConfigPath()
|
||||
|
||||
if !config.ConfigExists("") {
|
||||
@@ -202,7 +202,7 @@ func runConfigReset(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigValidate(cmd *cobra.Command, args []string) error {
|
||||
func runConfigValidate(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
fmt.Println(ui.Error("Failed to load configuration"))
|
||||
|
||||
@@ -5,10 +5,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/cli/ui"
|
||||
"drip/internal/shared/ui"
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
@@ -194,8 +193,8 @@ func StartDaemon(tunnelType string, port int, args []string) error {
|
||||
return fmt.Errorf("failed to start daemon: %w", err)
|
||||
}
|
||||
|
||||
// Don't wait for the process - let it run in background
|
||||
// The child process will save its own daemon info after connecting
|
||||
_ = logFile.Close()
|
||||
_ = devNull.Close()
|
||||
|
||||
fmt.Println(ui.RenderDaemonStarted(tunnelType, port, cmd.Process.Pid, logPath))
|
||||
|
||||
@@ -220,28 +219,16 @@ func CleanupStaleDaemons() error {
|
||||
|
||||
// FormatDuration formats a duration in a human-readable way
|
||||
func FormatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
} else if d < time.Hour {
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
} else if d < 24*time.Hour {
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
|
||||
// ParsePortFromArgs extracts the port number from command arguments
|
||||
func ParsePortFromArgs(args []string) (int, error) {
|
||||
for _, arg := range args {
|
||||
if len(arg) > 0 && arg[0] == '-' {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(arg)
|
||||
if err == nil && port > 0 && port <= 65535 {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("port number not found in arguments")
|
||||
}
|
||||
|
||||
@@ -2,22 +2,14 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/tcp"
|
||||
"drip/internal/shared/protocol"
|
||||
"drip/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
maxReconnectAttempts = 5
|
||||
reconnectInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
subdomain string
|
||||
daemonMode bool
|
||||
@@ -52,55 +44,19 @@ func init() {
|
||||
rootCmd.AddCommand(httpCmd)
|
||||
}
|
||||
|
||||
func runHTTP(cmd *cobra.Command, args []string) error {
|
||||
func runHTTP(_ *cobra.Command, args []string) error {
|
||||
port, err := strconv.Atoi(args[0])
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port number: %s", args[0])
|
||||
}
|
||||
|
||||
if daemonMode && !daemonMarker {
|
||||
daemonArgs := append([]string{"http"}, args...)
|
||||
daemonArgs = append(daemonArgs, "--daemon-child")
|
||||
if subdomain != "" {
|
||||
daemonArgs = append(daemonArgs, "--subdomain", subdomain)
|
||||
}
|
||||
if localAddress != "127.0.0.1" {
|
||||
daemonArgs = append(daemonArgs, "--address", localAddress)
|
||||
}
|
||||
if serverURL != "" {
|
||||
daemonArgs = append(daemonArgs, "--server", serverURL)
|
||||
}
|
||||
if authToken != "" {
|
||||
daemonArgs = append(daemonArgs, "--token", authToken)
|
||||
}
|
||||
if insecure {
|
||||
daemonArgs = append(daemonArgs, "--insecure")
|
||||
}
|
||||
if verbose {
|
||||
daemonArgs = append(daemonArgs, "--verbose")
|
||||
}
|
||||
return StartDaemon("http", port, daemonArgs)
|
||||
return StartDaemon("http", port, buildDaemonArgs("http", args, subdomain, localAddress))
|
||||
}
|
||||
|
||||
var serverAddr, token string
|
||||
|
||||
if serverURL == "" {
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`configuration not found.
|
||||
|
||||
Please run 'drip config init' first, or use flags:
|
||||
drip http %d --server SERVER:PORT --token TOKEN`, port)
|
||||
}
|
||||
serverAddr = cfg.Server
|
||||
token = cfg.Token
|
||||
} else {
|
||||
serverAddr = serverURL
|
||||
token = authToken
|
||||
}
|
||||
|
||||
if serverAddr == "" {
|
||||
return fmt.Errorf("server address is required")
|
||||
serverAddr, token, err := resolveServerAddrAndToken("http", port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
connConfig := &tcp.ConnectorConfig{
|
||||
@@ -115,15 +71,7 @@ Please run 'drip config init' first, or use flags:
|
||||
|
||||
var daemon *DaemonInfo
|
||||
if daemonMarker {
|
||||
daemon = &DaemonInfo{
|
||||
PID: os.Getpid(),
|
||||
Type: "http",
|
||||
Port: port,
|
||||
Subdomain: subdomain,
|
||||
Server: serverAddr,
|
||||
StartTime: time.Now(),
|
||||
Executable: os.Args[0],
|
||||
}
|
||||
daemon = newDaemonInfo("http", port, subdomain, serverAddr)
|
||||
}
|
||||
|
||||
return runTunnelWithUI(connConfig, daemon)
|
||||
|
||||
@@ -2,24 +2,14 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/tcp"
|
||||
"drip/internal/shared/protocol"
|
||||
"drip/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
httpsSubdomain string
|
||||
httpsDaemonMode bool
|
||||
httpsDaemonMarker bool
|
||||
httpsLocalAddress string
|
||||
)
|
||||
|
||||
var httpsCmd = &cobra.Command{
|
||||
Use: "https <port>",
|
||||
Short: "Start HTTPS tunnel",
|
||||
@@ -39,86 +29,42 @@ Note: Uses TCP over TLS 1.3 for secure communication`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
httpsCmd.Flags().StringVarP(&httpsSubdomain, "subdomain", "n", "", "Custom subdomain (optional)")
|
||||
httpsCmd.Flags().BoolVarP(&httpsDaemonMode, "daemon", "d", false, "Run in background (daemon mode)")
|
||||
httpsCmd.Flags().StringVarP(&httpsLocalAddress, "address", "a", "127.0.0.1", "Local address to forward to (default: 127.0.0.1)")
|
||||
httpsCmd.Flags().BoolVar(&httpsDaemonMarker, "daemon-child", false, "Internal flag for daemon child process")
|
||||
httpsCmd.Flags().StringVarP(&subdomain, "subdomain", "n", "", "Custom subdomain (optional)")
|
||||
httpsCmd.Flags().BoolVarP(&daemonMode, "daemon", "d", false, "Run in background (daemon mode)")
|
||||
httpsCmd.Flags().StringVarP(&localAddress, "address", "a", "127.0.0.1", "Local address to forward to (default: 127.0.0.1)")
|
||||
httpsCmd.Flags().BoolVar(&daemonMarker, "daemon-child", false, "Internal flag for daemon child process")
|
||||
httpsCmd.Flags().MarkHidden("daemon-child")
|
||||
rootCmd.AddCommand(httpsCmd)
|
||||
}
|
||||
|
||||
func runHTTPS(cmd *cobra.Command, args []string) error {
|
||||
func runHTTPS(_ *cobra.Command, args []string) error {
|
||||
port, err := strconv.Atoi(args[0])
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port number: %s", args[0])
|
||||
}
|
||||
|
||||
if httpsDaemonMode && !httpsDaemonMarker {
|
||||
daemonArgs := append([]string{"https"}, args...)
|
||||
daemonArgs = append(daemonArgs, "--daemon-child")
|
||||
if httpsSubdomain != "" {
|
||||
daemonArgs = append(daemonArgs, "--subdomain", httpsSubdomain)
|
||||
}
|
||||
if httpsLocalAddress != "127.0.0.1" {
|
||||
daemonArgs = append(daemonArgs, "--address", httpsLocalAddress)
|
||||
}
|
||||
if serverURL != "" {
|
||||
daemonArgs = append(daemonArgs, "--server", serverURL)
|
||||
}
|
||||
if authToken != "" {
|
||||
daemonArgs = append(daemonArgs, "--token", authToken)
|
||||
}
|
||||
if insecure {
|
||||
daemonArgs = append(daemonArgs, "--insecure")
|
||||
}
|
||||
if verbose {
|
||||
daemonArgs = append(daemonArgs, "--verbose")
|
||||
}
|
||||
return StartDaemon("https", port, daemonArgs)
|
||||
if daemonMode && !daemonMarker {
|
||||
return StartDaemon("https", port, buildDaemonArgs("https", args, subdomain, localAddress))
|
||||
}
|
||||
|
||||
var serverAddr, token string
|
||||
|
||||
if serverURL == "" {
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`configuration not found.
|
||||
|
||||
Please run 'drip config init' first, or use flags:
|
||||
drip https %d --server SERVER:PORT --token TOKEN`, port)
|
||||
}
|
||||
serverAddr = cfg.Server
|
||||
token = cfg.Token
|
||||
} else {
|
||||
serverAddr = serverURL
|
||||
token = authToken
|
||||
}
|
||||
|
||||
if serverAddr == "" {
|
||||
return fmt.Errorf("server address is required")
|
||||
serverAddr, token, err := resolveServerAddrAndToken("https", port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
connConfig := &tcp.ConnectorConfig{
|
||||
ServerAddr: serverAddr,
|
||||
Token: token,
|
||||
TunnelType: protocol.TunnelTypeHTTPS,
|
||||
LocalHost: httpsLocalAddress,
|
||||
LocalHost: localAddress,
|
||||
LocalPort: port,
|
||||
Subdomain: httpsSubdomain,
|
||||
Subdomain: subdomain,
|
||||
Insecure: insecure,
|
||||
}
|
||||
|
||||
var daemon *DaemonInfo
|
||||
if httpsDaemonMarker {
|
||||
daemon = &DaemonInfo{
|
||||
PID: os.Getpid(),
|
||||
Type: "https",
|
||||
Port: port,
|
||||
Subdomain: httpsSubdomain,
|
||||
Server: serverAddr,
|
||||
StartTime: time.Now(),
|
||||
Executable: os.Args[0],
|
||||
}
|
||||
if daemonMarker {
|
||||
daemon = newDaemonInfo("https", port, subdomain, serverAddr)
|
||||
}
|
||||
|
||||
return runTunnelWithUI(connConfig, daemon)
|
||||
|
||||
@@ -8,13 +8,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/cli/ui"
|
||||
"drip/internal/shared/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
interactiveMode bool
|
||||
)
|
||||
var interactiveMode bool
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -26,7 +24,7 @@ Example:
|
||||
drip list -i Interactive mode (select to attach/stop)
|
||||
|
||||
This command shows:
|
||||
- Tunnel type (HTTP/TCP)
|
||||
- Tunnel type (HTTP/HTTPS/TCP)
|
||||
- Local port being tunneled
|
||||
- Public URL
|
||||
- Process ID (PID)
|
||||
@@ -44,7 +42,7 @@ func init() {
|
||||
rootCmd.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, args []string) error {
|
||||
func runList(_ *cobra.Command, _ []string) error {
|
||||
CleanupStaleDaemons()
|
||||
|
||||
daemons, err := ListAllDaemons()
|
||||
@@ -99,7 +97,7 @@ func runList(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Print(table.Render())
|
||||
|
||||
if interactiveMode || shouldPromptForAction() {
|
||||
if interactiveMode {
|
||||
return runInteractiveList(daemons)
|
||||
}
|
||||
|
||||
@@ -114,13 +112,6 @@ func runList(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldPromptForAction() bool {
|
||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runInteractiveList(daemons []*DaemonInfo) error {
|
||||
var runningDaemons []*DaemonInfo
|
||||
for _, d := range daemons {
|
||||
@@ -161,9 +152,9 @@ func runInteractiveList(daemons []*DaemonInfo) error {
|
||||
"",
|
||||
ui.Muted("What would you like to do?"),
|
||||
"",
|
||||
ui.Cyan(" 1.") + " Attach (view logs)",
|
||||
ui.Cyan(" 2.") + " Stop tunnel",
|
||||
ui.Muted(" q.") + " Cancel",
|
||||
ui.Cyan(" 1.")+" Attach (view logs)",
|
||||
ui.Cyan(" 2.")+" Stop tunnel",
|
||||
ui.Muted(" q.")+" Cancel",
|
||||
))
|
||||
|
||||
fmt.Print(ui.Muted("Choose an action: "))
|
||||
|
||||
@@ -3,7 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"drip/internal/client/cli/ui"
|
||||
"drip/internal/shared/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -56,14 +56,12 @@ func init() {
|
||||
versionCmd.Flags().BoolVar(&versionPlain, "short", false, "Print version information without styling")
|
||||
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
// http and tcp commands are added in their respective init() functions
|
||||
// config command is added in config.go init() function
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
if versionPlain {
|
||||
fmt.Printf("Version: %s\nGit Commit: %s\nBuild Time: %s\n", Version, GitCommit, BuildTime)
|
||||
return
|
||||
|
||||
@@ -59,7 +59,7 @@ func init() {
|
||||
serverCmd.Flags().IntVar(&serverPprofPort, "pprof", getEnvInt("DRIP_PPROF_PORT", 0), "Enable pprof on specified port (env: DRIP_PPROF_PORT)")
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
func runServer(_ *cobra.Command, _ []string) error {
|
||||
if serverTLSCert == "" {
|
||||
return fmt.Errorf("TLS certificate path is required (use --tls-cert flag or DRIP_TLS_CERT environment variable)")
|
||||
}
|
||||
@@ -126,11 +126,9 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
|
||||
listenAddr := fmt.Sprintf("0.0.0.0:%d", serverPort)
|
||||
|
||||
responseHandler := proxy.NewResponseHandler(logger)
|
||||
httpHandler := proxy.NewHandler(tunnelManager, logger, serverDomain, serverAuthToken)
|
||||
|
||||
httpHandler := proxy.NewHandler(tunnelManager, logger, responseHandler, serverDomain, serverAuthToken)
|
||||
|
||||
listener := tcp.NewListener(listenAddr, tlsConfig, serverAuthToken, tunnelManager, logger, portAllocator, serverDomain, displayPort, httpHandler, responseHandler)
|
||||
listener := tcp.NewListener(listenAddr, tlsConfig, serverAuthToken, tunnelManager, logger, portAllocator, serverDomain, displayPort, httpHandler)
|
||||
|
||||
if err := listener.Start(); err != nil {
|
||||
logger.Fatal("Failed to start TCP listener", zap.Error(err))
|
||||
|
||||
@@ -28,7 +28,7 @@ func init() {
|
||||
rootCmd.AddCommand(stopCmd)
|
||||
}
|
||||
|
||||
func runStop(cmd *cobra.Command, args []string) error {
|
||||
func runStop(_ *cobra.Command, args []string) error {
|
||||
if args[0] == "all" {
|
||||
return stopAllDaemons()
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/tcp"
|
||||
"drip/internal/shared/protocol"
|
||||
"drip/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -47,55 +44,19 @@ func init() {
|
||||
rootCmd.AddCommand(tcpCmd)
|
||||
}
|
||||
|
||||
func runTCP(cmd *cobra.Command, args []string) error {
|
||||
func runTCP(_ *cobra.Command, args []string) error {
|
||||
port, err := strconv.Atoi(args[0])
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port number: %s", args[0])
|
||||
}
|
||||
|
||||
if daemonMode && !daemonMarker {
|
||||
daemonArgs := append([]string{"tcp"}, args...)
|
||||
daemonArgs = append(daemonArgs, "--daemon-child")
|
||||
if subdomain != "" {
|
||||
daemonArgs = append(daemonArgs, "--subdomain", subdomain)
|
||||
}
|
||||
if localAddress != "127.0.0.1" {
|
||||
daemonArgs = append(daemonArgs, "--address", localAddress)
|
||||
}
|
||||
if serverURL != "" {
|
||||
daemonArgs = append(daemonArgs, "--server", serverURL)
|
||||
}
|
||||
if authToken != "" {
|
||||
daemonArgs = append(daemonArgs, "--token", authToken)
|
||||
}
|
||||
if insecure {
|
||||
daemonArgs = append(daemonArgs, "--insecure")
|
||||
}
|
||||
if verbose {
|
||||
daemonArgs = append(daemonArgs, "--verbose")
|
||||
}
|
||||
return StartDaemon("tcp", port, daemonArgs)
|
||||
return StartDaemon("tcp", port, buildDaemonArgs("tcp", args, subdomain, localAddress))
|
||||
}
|
||||
|
||||
var serverAddr, token string
|
||||
|
||||
if serverURL == "" {
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`configuration not found.
|
||||
|
||||
Please run 'drip config init' first, or use flags:
|
||||
drip tcp %d --server SERVER:PORT --token TOKEN`, port)
|
||||
}
|
||||
serverAddr = cfg.Server
|
||||
token = cfg.Token
|
||||
} else {
|
||||
serverAddr = serverURL
|
||||
token = authToken
|
||||
}
|
||||
|
||||
if serverAddr == "" {
|
||||
return fmt.Errorf("server address is required")
|
||||
serverAddr, token, err := resolveServerAddrAndToken("tcp", port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
connConfig := &tcp.ConnectorConfig{
|
||||
@@ -110,15 +71,7 @@ Please run 'drip config init' first, or use flags:
|
||||
|
||||
var daemon *DaemonInfo
|
||||
if daemonMarker {
|
||||
daemon = &DaemonInfo{
|
||||
PID: os.Getpid(),
|
||||
Type: "tcp",
|
||||
Port: port,
|
||||
Subdomain: subdomain,
|
||||
Server: serverAddr,
|
||||
StartTime: time.Now(),
|
||||
Executable: os.Args[0],
|
||||
}
|
||||
daemon = newDaemonInfo("tcp", port, subdomain, serverAddr)
|
||||
}
|
||||
|
||||
return runTunnelWithUI(connConfig, daemon)
|
||||
|
||||
67
internal/client/cli/tunnel_helpers.go
Normal file
67
internal/client/cli/tunnel_helpers.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"drip/pkg/config"
|
||||
)
|
||||
|
||||
func buildDaemonArgs(tunnelType string, args []string, subdomain string, localAddress string) []string {
|
||||
daemonArgs := append([]string{tunnelType}, args...)
|
||||
daemonArgs = append(daemonArgs, "--daemon-child")
|
||||
|
||||
if subdomain != "" {
|
||||
daemonArgs = append(daemonArgs, "--subdomain", subdomain)
|
||||
}
|
||||
if localAddress != "127.0.0.1" {
|
||||
daemonArgs = append(daemonArgs, "--address", localAddress)
|
||||
}
|
||||
if serverURL != "" {
|
||||
daemonArgs = append(daemonArgs, "--server", serverURL)
|
||||
}
|
||||
if authToken != "" {
|
||||
daemonArgs = append(daemonArgs, "--token", authToken)
|
||||
}
|
||||
if insecure {
|
||||
daemonArgs = append(daemonArgs, "--insecure")
|
||||
}
|
||||
if verbose {
|
||||
daemonArgs = append(daemonArgs, "--verbose")
|
||||
}
|
||||
|
||||
return daemonArgs
|
||||
}
|
||||
|
||||
func resolveServerAddrAndToken(tunnelType string, port int) (string, string, error) {
|
||||
if serverURL != "" {
|
||||
return serverURL, authToken, nil
|
||||
}
|
||||
|
||||
cfg, err := config.LoadClientConfig("")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf(`configuration not found.
|
||||
|
||||
Please run 'drip config init' first, or use flags:
|
||||
drip %s %d --server SERVER:PORT --token TOKEN`, tunnelType, port)
|
||||
}
|
||||
|
||||
if cfg.Server == "" {
|
||||
return "", "", fmt.Errorf("server address is required")
|
||||
}
|
||||
|
||||
return cfg.Server, cfg.Token, nil
|
||||
}
|
||||
|
||||
func newDaemonInfo(tunnelType string, port int, subdomain string, serverAddr string) *DaemonInfo {
|
||||
return &DaemonInfo{
|
||||
PID: os.Getpid(),
|
||||
Type: tunnelType,
|
||||
Port: port,
|
||||
Subdomain: subdomain,
|
||||
Server: serverAddr,
|
||||
StartTime: time.Now(),
|
||||
Executable: os.Args[0],
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"drip/internal/client/cli/ui"
|
||||
"drip/internal/client/tcp"
|
||||
"drip/internal/shared/ui"
|
||||
"drip/internal/shared/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// runTunnelWithUI runs a tunnel with the new UI
|
||||
const (
|
||||
maxReconnectAttempts = 5
|
||||
reconnectInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
func runTunnelWithUI(connConfig *tcp.ConnectorConfig, daemonInfo *DaemonInfo) error {
|
||||
if err := utils.InitLogger(verbose); err != nil {
|
||||
return fmt.Errorf("failed to initialize logger: %w", err)
|
||||
@@ -28,7 +32,7 @@ func runTunnelWithUI(connConfig *tcp.ConnectorConfig, daemonInfo *DaemonInfo) er
|
||||
|
||||
reconnectAttempts := 0
|
||||
for {
|
||||
connector := tcp.NewConnector(connConfig, logger)
|
||||
connector := tcp.NewTunnelClient(connConfig, logger)
|
||||
|
||||
fmt.Println(ui.RenderConnecting(connConfig.ServerAddr, reconnectAttempts, maxReconnectAttempts))
|
||||
|
||||
@@ -87,8 +91,8 @@ func runTunnelWithUI(connConfig *tcp.ConnectorConfig, daemonInfo *DaemonInfo) er
|
||||
disconnected := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
renderTicker := time.NewTicker(1 * time.Second)
|
||||
defer renderTicker.Stop()
|
||||
|
||||
var lastLatency time.Duration
|
||||
lastRenderedLines := 0
|
||||
@@ -97,27 +101,38 @@ func runTunnelWithUI(connConfig *tcp.ConnectorConfig, daemonInfo *DaemonInfo) er
|
||||
select {
|
||||
case latency := <-latencyCh:
|
||||
lastLatency = latency
|
||||
case <-ticker.C:
|
||||
case <-renderTicker.C:
|
||||
stats := connector.GetStats()
|
||||
if stats != nil {
|
||||
stats.UpdateSpeed()
|
||||
snapshot := stats.GetSnapshot()
|
||||
|
||||
status.Latency = lastLatency
|
||||
status.BytesIn = snapshot.TotalBytesIn
|
||||
status.BytesOut = snapshot.TotalBytesOut
|
||||
status.SpeedIn = float64(snapshot.SpeedIn)
|
||||
status.SpeedOut = float64(snapshot.SpeedOut)
|
||||
status.TotalRequest = snapshot.TotalRequests
|
||||
|
||||
statsView := ui.RenderTunnelStats(status)
|
||||
if lastRenderedLines > 0 {
|
||||
fmt.Print(clearLines(lastRenderedLines))
|
||||
}
|
||||
|
||||
fmt.Print(statsView)
|
||||
lastRenderedLines = countRenderedLines(statsView)
|
||||
if stats == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.UpdateSpeed()
|
||||
snapshot := stats.GetSnapshot()
|
||||
|
||||
status.Latency = lastLatency
|
||||
status.BytesIn = snapshot.TotalBytesIn
|
||||
status.BytesOut = snapshot.TotalBytesOut
|
||||
status.SpeedIn = float64(snapshot.SpeedIn)
|
||||
status.SpeedOut = float64(snapshot.SpeedOut)
|
||||
|
||||
if status.Type == "tcp" {
|
||||
if snapshot.SpeedIn == 0 && snapshot.SpeedOut == 0 {
|
||||
status.TotalRequest = 0
|
||||
} else {
|
||||
status.TotalRequest = snapshot.ActiveConnections
|
||||
}
|
||||
} else {
|
||||
status.TotalRequest = snapshot.TotalRequests
|
||||
}
|
||||
|
||||
statsView := ui.RenderTunnelStats(status)
|
||||
if lastRenderedLines > 0 {
|
||||
fmt.Print(clearLines(lastRenderedLines))
|
||||
}
|
||||
|
||||
fmt.Print(statsView)
|
||||
lastRenderedLines = countRenderedLines(statsView)
|
||||
case <-stopDisplay:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RenderConfigInit renders config initialization UI
|
||||
func RenderConfigInit() string {
|
||||
title := "Drip Configuration Setup"
|
||||
box := boxStyle.Width(50)
|
||||
return "\n" + box.Render(titleStyle.Render(title)) + "\n"
|
||||
}
|
||||
|
||||
// RenderConfigShow renders the config display
|
||||
func RenderConfigShow(server, token string, tokenHidden bool, tlsEnabled bool, configPath string) string {
|
||||
lines := []string{
|
||||
KeyValue("Server", server),
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
if tokenHidden {
|
||||
if len(token) > 10 {
|
||||
displayToken := token[:3] + "***" + token[len(token)-3:]
|
||||
lines = append(lines, KeyValue("Token", Muted(displayToken+" (hidden)")))
|
||||
} else {
|
||||
lines = append(lines, KeyValue("Token", Muted(token[:3]+"*** (hidden)")))
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, KeyValue("Token", token))
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, KeyValue("Token", Muted("(not set)")))
|
||||
}
|
||||
|
||||
tlsStatus := "enabled"
|
||||
if !tlsEnabled {
|
||||
tlsStatus = "disabled"
|
||||
}
|
||||
lines = append(lines, KeyValue("TLS", tlsStatus))
|
||||
lines = append(lines, KeyValue("Config", Muted(configPath)))
|
||||
|
||||
return Info("Current Configuration", lines...)
|
||||
}
|
||||
|
||||
// RenderConfigSaved renders config saved message
|
||||
func RenderConfigSaved(configPath string) string {
|
||||
return SuccessBox(
|
||||
"Configuration Saved",
|
||||
Muted("Config saved to: ")+configPath,
|
||||
"",
|
||||
Muted("You can now use 'drip' without --server and --token flags"),
|
||||
)
|
||||
}
|
||||
|
||||
// RenderConfigUpdated renders config updated message
|
||||
func RenderConfigUpdated(updates []string) string {
|
||||
lines := make([]string, len(updates)+1)
|
||||
for i, update := range updates {
|
||||
lines[i] = Success(update)
|
||||
}
|
||||
lines[len(updates)] = ""
|
||||
lines = append(lines, Muted("Configuration has been updated"))
|
||||
return SuccessBox("Configuration Updated", lines...)
|
||||
}
|
||||
|
||||
// RenderConfigDeleted renders config deleted message
|
||||
func RenderConfigDeleted() string {
|
||||
return SuccessBox("Configuration Deleted", Muted("Configuration file has been removed"))
|
||||
}
|
||||
|
||||
// RenderConfigValidation renders config validation results
|
||||
func RenderConfigValidation(serverValid bool, serverMsg string, tokenSet bool, tokenMsg string, tlsEnabled bool) string {
|
||||
lines := []string{}
|
||||
|
||||
if serverValid {
|
||||
lines = append(lines, Success(serverMsg))
|
||||
} else {
|
||||
lines = append(lines, Error(serverMsg))
|
||||
}
|
||||
|
||||
if tokenSet {
|
||||
lines = append(lines, Success(tokenMsg))
|
||||
} else {
|
||||
lines = append(lines, Warning(tokenMsg))
|
||||
}
|
||||
|
||||
if tlsEnabled {
|
||||
lines = append(lines, Success("TLS is enabled"))
|
||||
} else {
|
||||
lines = append(lines, Warning("TLS is disabled (not recommended for production)"))
|
||||
}
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, Muted("Configuration validation complete"))
|
||||
|
||||
if serverValid && tokenSet && tlsEnabled {
|
||||
return SuccessBox("Configuration Valid", lines...)
|
||||
}
|
||||
return WarningBox("Configuration Validation", lines...)
|
||||
}
|
||||
|
||||
// RenderDaemonStarted renders daemon started message
|
||||
func RenderDaemonStarted(tunnelType string, port int, pid int, logPath string) string {
|
||||
lines := []string{
|
||||
KeyValue("Type", Highlight(tunnelType)),
|
||||
KeyValue("Port", fmt.Sprintf("%d", port)),
|
||||
KeyValue("PID", fmt.Sprintf("%d", pid)),
|
||||
"",
|
||||
Muted("Commands:"),
|
||||
Cyan(" drip list") + Muted(" Check tunnel status"),
|
||||
Cyan(fmt.Sprintf(" drip attach %s %d", tunnelType, port)) + Muted(" View logs"),
|
||||
Cyan(fmt.Sprintf(" drip stop %s %d", tunnelType, port)) + Muted(" Stop tunnel"),
|
||||
"",
|
||||
Muted("Logs: ") + mutedStyle.Render(logPath),
|
||||
}
|
||||
return SuccessBox("Tunnel Started in Background", lines...)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
// Colors inspired by Vercel CLI
|
||||
successColor = lipgloss.Color("#0070F3")
|
||||
warningColor = lipgloss.Color("#F5A623")
|
||||
errorColor = lipgloss.Color("#E00")
|
||||
mutedColor = lipgloss.Color("#888")
|
||||
highlightColor = lipgloss.Color("#0070F3")
|
||||
cyanColor = lipgloss.Color("#50E3C2")
|
||||
|
||||
// Box styles - Vercel-like clean box
|
||||
boxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
Padding(1, 2).
|
||||
MarginTop(1).
|
||||
MarginBottom(1)
|
||||
|
||||
successBoxStyle = boxStyle.BorderForeground(successColor)
|
||||
|
||||
warningBoxStyle = boxStyle.BorderForeground(warningColor)
|
||||
|
||||
errorBoxStyle = boxStyle.BorderForeground(errorColor)
|
||||
|
||||
// Text styles
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true)
|
||||
|
||||
subtitleStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor)
|
||||
|
||||
successStyle = lipgloss.NewStyle().
|
||||
Foreground(successColor).
|
||||
Bold(true)
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(errorColor).
|
||||
Bold(true)
|
||||
|
||||
warningStyle = lipgloss.NewStyle().
|
||||
Foreground(warningColor).
|
||||
Bold(true)
|
||||
|
||||
mutedStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor)
|
||||
|
||||
highlightStyle = lipgloss.NewStyle().
|
||||
Foreground(highlightColor).
|
||||
Bold(true)
|
||||
|
||||
cyanStyle = lipgloss.NewStyle().
|
||||
Foreground(cyanColor)
|
||||
|
||||
urlStyle = lipgloss.NewStyle().
|
||||
Foreground(highlightColor).
|
||||
Underline(true).
|
||||
Bold(true)
|
||||
|
||||
labelStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor).
|
||||
Width(12)
|
||||
|
||||
valueStyle = lipgloss.NewStyle().
|
||||
Bold(true)
|
||||
|
||||
// Table styles (padding handled manually for consistent Windows output)
|
||||
tableHeaderStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
// Success returns a styled success message
|
||||
func Success(text string) string {
|
||||
return successStyle.Render("✓ " + text)
|
||||
}
|
||||
|
||||
// Error returns a styled error message
|
||||
func Error(text string) string {
|
||||
return errorStyle.Render("✗ " + text)
|
||||
}
|
||||
|
||||
// Warning returns a styled warning message
|
||||
func Warning(text string) string {
|
||||
return warningStyle.Render("⚠ " + text)
|
||||
}
|
||||
|
||||
// Muted returns a styled muted text
|
||||
func Muted(text string) string {
|
||||
return mutedStyle.Render(text)
|
||||
}
|
||||
|
||||
// Highlight returns a styled highlighted text
|
||||
func Highlight(text string) string {
|
||||
return highlightStyle.Render(text)
|
||||
}
|
||||
|
||||
// Cyan returns a styled cyan text
|
||||
func Cyan(text string) string {
|
||||
return cyanStyle.Render(text)
|
||||
}
|
||||
|
||||
// URL returns a styled URL
|
||||
func URL(text string) string {
|
||||
return urlStyle.Render(text)
|
||||
}
|
||||
|
||||
// Title returns a styled title
|
||||
func Title(text string) string {
|
||||
return titleStyle.Render(text)
|
||||
}
|
||||
|
||||
// Subtitle returns a styled subtitle
|
||||
func Subtitle(text string) string {
|
||||
return subtitleStyle.Render(text)
|
||||
}
|
||||
|
||||
// KeyValue returns a styled key-value pair
|
||||
func KeyValue(key, value string) string {
|
||||
return labelStyle.Render(key+":") + " " + valueStyle.Render(value)
|
||||
}
|
||||
|
||||
// Info renders an info box (Vercel-style)
|
||||
func Info(title string, lines ...string) string {
|
||||
content := titleStyle.Render(title)
|
||||
if len(lines) > 0 {
|
||||
content += "\n\n"
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
content += line
|
||||
}
|
||||
}
|
||||
return boxStyle.Render(content)
|
||||
}
|
||||
|
||||
// SuccessBox renders a success box
|
||||
func SuccessBox(title string, lines ...string) string {
|
||||
content := successStyle.Render("✓ " + title)
|
||||
if len(lines) > 0 {
|
||||
content += "\n\n"
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
content += line
|
||||
}
|
||||
}
|
||||
return successBoxStyle.Render(content)
|
||||
}
|
||||
|
||||
// WarningBox renders a warning box
|
||||
func WarningBox(title string, lines ...string) string {
|
||||
content := warningStyle.Render("⚠ " + title)
|
||||
if len(lines) > 0 {
|
||||
content += "\n\n"
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
content += line
|
||||
}
|
||||
}
|
||||
return warningBoxStyle.Render(content)
|
||||
}
|
||||
|
||||
// ErrorBox renders an error box
|
||||
func ErrorBox(title string, lines ...string) string {
|
||||
content := errorStyle.Render("✗ " + title)
|
||||
if len(lines) > 0 {
|
||||
content += "\n\n"
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
content += line
|
||||
}
|
||||
}
|
||||
return errorBoxStyle.Render(content)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Table represents a simple table for CLI output
|
||||
type Table struct {
|
||||
headers []string
|
||||
rows [][]string
|
||||
title string
|
||||
}
|
||||
|
||||
// NewTable creates a new table
|
||||
func NewTable(headers []string) *Table {
|
||||
return &Table{
|
||||
headers: headers,
|
||||
rows: [][]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// WithTitle sets the table title
|
||||
func (t *Table) WithTitle(title string) *Table {
|
||||
t.title = title
|
||||
return t
|
||||
}
|
||||
|
||||
// AddRow adds a row to the table
|
||||
func (t *Table) AddRow(row []string) *Table {
|
||||
t.rows = append(t.rows, row)
|
||||
return t
|
||||
}
|
||||
|
||||
// Render renders the table (Vercel-style)
|
||||
func (t *Table) Render() string {
|
||||
if len(t.rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
colWidths := make([]int, len(t.headers))
|
||||
for i, header := range t.headers {
|
||||
colWidths[i] = lipgloss.Width(header)
|
||||
}
|
||||
for _, row := range t.rows {
|
||||
for i, cell := range row {
|
||||
if i < len(colWidths) {
|
||||
width := lipgloss.Width(cell)
|
||||
if width > colWidths[i] {
|
||||
colWidths[i] = width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Title
|
||||
if t.title != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(titleStyle.Render(t.title))
|
||||
output.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Header
|
||||
headerParts := make([]string, len(t.headers))
|
||||
for i, header := range t.headers {
|
||||
styled := tableHeaderStyle.Render(header)
|
||||
headerParts[i] = padRight(styled, colWidths[i])
|
||||
}
|
||||
output.WriteString(strings.Join(headerParts, " "))
|
||||
output.WriteString("\n")
|
||||
|
||||
// Separator line
|
||||
separatorChar := "─"
|
||||
if runtime.GOOS == "windows" {
|
||||
separatorChar = "-"
|
||||
}
|
||||
separatorParts := make([]string, len(t.headers))
|
||||
for i := range t.headers {
|
||||
separatorParts[i] = mutedStyle.Render(strings.Repeat(separatorChar, colWidths[i]))
|
||||
}
|
||||
output.WriteString(strings.Join(separatorParts, " "))
|
||||
output.WriteString("\n")
|
||||
|
||||
// Rows
|
||||
for _, row := range t.rows {
|
||||
rowParts := make([]string, len(t.headers))
|
||||
for i, cell := range row {
|
||||
if i < len(colWidths) {
|
||||
rowParts[i] = padRight(cell, colWidths[i])
|
||||
}
|
||||
}
|
||||
output.WriteString(strings.Join(rowParts, " "))
|
||||
output.WriteString("\n")
|
||||
}
|
||||
|
||||
output.WriteString("\n")
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// padRight pads
|
||||
func padRight(text string, targetWidth int) string {
|
||||
visibleWidth := lipgloss.Width(text)
|
||||
if visibleWidth >= targetWidth {
|
||||
return text
|
||||
}
|
||||
padding := strings.Repeat(" ", targetWidth-visibleWidth)
|
||||
return text + padding
|
||||
}
|
||||
|
||||
// Print prints the table
|
||||
func (t *Table) Print() {
|
||||
fmt.Print(t.Render())
|
||||
}
|
||||
|
||||
// RenderList renders a simple list with bullet points
|
||||
func RenderList(items []string) string {
|
||||
bullet := "•"
|
||||
if runtime.GOOS == "windows" {
|
||||
bullet = "*"
|
||||
}
|
||||
var output strings.Builder
|
||||
for _, item := range items {
|
||||
output.WriteString(mutedStyle.Render(" " + bullet + " "))
|
||||
output.WriteString(item)
|
||||
output.WriteString("\n")
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderNumberedList renders a numbered list
|
||||
func RenderNumberedList(items []string) string {
|
||||
var output strings.Builder
|
||||
for i, item := range items {
|
||||
output.WriteString(mutedStyle.Render(fmt.Sprintf(" %d. ", i+1)))
|
||||
output.WriteString(item)
|
||||
output.WriteString("\n")
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelCardWidth = 76
|
||||
statsColumnWidth = 32
|
||||
)
|
||||
|
||||
var (
|
||||
latencyFastColor = lipgloss.Color("#22c55e") // green
|
||||
latencyYellowColor = lipgloss.Color("#eab308") // yellow
|
||||
latencyOrangeColor = lipgloss.Color("#f97316") // orange
|
||||
latencyRedColor = lipgloss.Color("#ef4444") // red
|
||||
)
|
||||
|
||||
// TunnelStatus represents the status of a tunnel
|
||||
type TunnelStatus struct {
|
||||
Type string // "http", "https", "tcp"
|
||||
URL string // Public URL
|
||||
LocalAddr string // Local address
|
||||
Latency time.Duration // Current latency
|
||||
BytesIn int64 // Bytes received
|
||||
BytesOut int64 // Bytes sent
|
||||
SpeedIn float64 // Download speed
|
||||
SpeedOut float64 // Upload speed
|
||||
TotalRequest int64 // Total requests
|
||||
}
|
||||
|
||||
// RenderTunnelConnected renders the tunnel connection card
|
||||
func RenderTunnelConnected(status *TunnelStatus) string {
|
||||
icon, typeStr, accent := tunnelVisuals(status.Type)
|
||||
|
||||
card := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accent).
|
||||
Padding(1, 2).
|
||||
Width(tunnelCardWidth)
|
||||
|
||||
typeBadge := lipgloss.NewStyle().
|
||||
Background(accent).
|
||||
Foreground(lipgloss.Color("#f8fafc")).
|
||||
Bold(true).
|
||||
Padding(0, 1).
|
||||
Render(strings.ToUpper(typeStr) + " TUNNEL")
|
||||
|
||||
headline := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
lipgloss.NewStyle().Foreground(accent).Render(icon),
|
||||
lipgloss.NewStyle().Bold(true).MarginLeft(1).Render("Tunnel Connected"),
|
||||
lipgloss.NewStyle().MarginLeft(2).Render(typeBadge),
|
||||
)
|
||||
|
||||
urlLine := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
urlStyle.Foreground(accent).Render(status.URL),
|
||||
lipgloss.NewStyle().MarginLeft(1).Foreground(mutedColor).Render("(forwarded address)"),
|
||||
)
|
||||
|
||||
forwardLine := lipgloss.NewStyle().
|
||||
MarginLeft(2).
|
||||
Render(Muted("⇢ ") + valueStyle.Render(status.LocalAddr))
|
||||
|
||||
hint := lipgloss.NewStyle().
|
||||
Foreground(latencyOrangeColor).
|
||||
Render("Ctrl+C to stop • reconnects automatically")
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
headline,
|
||||
"",
|
||||
urlLine,
|
||||
forwardLine,
|
||||
"",
|
||||
hint,
|
||||
)
|
||||
|
||||
return "\n" + card.Render(content) + "\n"
|
||||
}
|
||||
|
||||
// RenderTunnelStats renders real-time tunnel statistics in a card
|
||||
func RenderTunnelStats(status *TunnelStatus) string {
|
||||
latencyStr := formatLatency(status.Latency)
|
||||
trafficStr := fmt.Sprintf("↓ %s ↑ %s", formatBytes(status.BytesIn), formatBytes(status.BytesOut))
|
||||
speedStr := fmt.Sprintf("↓ %s ↑ %s", formatSpeed(status.SpeedIn), formatSpeed(status.SpeedOut))
|
||||
requestsStr := fmt.Sprintf("%d", status.TotalRequest)
|
||||
|
||||
_, _, accent := tunnelVisuals(status.Type)
|
||||
|
||||
header := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
lipgloss.NewStyle().Foreground(accent).Render("◉"),
|
||||
lipgloss.NewStyle().Bold(true).MarginLeft(1).Render("Live Metrics"),
|
||||
)
|
||||
|
||||
row1 := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
statColumn("Latency", latencyStr, statsColumnWidth),
|
||||
statColumn("Requests", highlightStyle.Render(requestsStr), statsColumnWidth),
|
||||
)
|
||||
|
||||
row2 := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
statColumn("Traffic", Cyan(trafficStr), statsColumnWidth),
|
||||
statColumn("Speed", warningStyle.Render(speedStr), statsColumnWidth),
|
||||
)
|
||||
|
||||
card := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accent).
|
||||
Padding(1, 2).
|
||||
Width(tunnelCardWidth)
|
||||
|
||||
body := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
header,
|
||||
"",
|
||||
row1,
|
||||
row2,
|
||||
)
|
||||
|
||||
return "\n" + card.Render(body) + "\n"
|
||||
}
|
||||
|
||||
// RenderConnecting renders the connecting message
|
||||
func RenderConnecting(serverAddr string, attempt int, maxAttempts int) string {
|
||||
if attempt == 0 {
|
||||
return Highlight("◌") + " Connecting to " + Muted(serverAddr) + "..."
|
||||
}
|
||||
return Warning(fmt.Sprintf("◌ Reconnecting to %s (attempt %d/%d)...", serverAddr, attempt, maxAttempts))
|
||||
}
|
||||
|
||||
// RenderConnectionFailed renders connection failure message
|
||||
func RenderConnectionFailed(err error) string {
|
||||
return Error(fmt.Sprintf("Connection failed: %v", err))
|
||||
}
|
||||
|
||||
// RenderShuttingDown renders shutdown message
|
||||
func RenderShuttingDown() string {
|
||||
return Warning("⏹ Shutting down...")
|
||||
}
|
||||
|
||||
// RenderConnectionLost renders connection lost message
|
||||
func RenderConnectionLost() string {
|
||||
return Error("⚠ Connection lost!")
|
||||
}
|
||||
|
||||
// RenderRetrying renders retry message
|
||||
func RenderRetrying(interval time.Duration) string {
|
||||
return Muted(fmt.Sprintf(" Retrying in %v...", interval))
|
||||
}
|
||||
|
||||
// formatLatency formats latency with color
|
||||
func formatLatency(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return mutedStyle.Render("measuring...")
|
||||
}
|
||||
|
||||
ms := d.Milliseconds()
|
||||
var style lipgloss.Style
|
||||
|
||||
switch {
|
||||
case ms < 50:
|
||||
style = lipgloss.NewStyle().Foreground(latencyFastColor)
|
||||
case ms < 150:
|
||||
style = lipgloss.NewStyle().Foreground(latencyYellowColor)
|
||||
case ms < 300:
|
||||
style = lipgloss.NewStyle().Foreground(latencyOrangeColor)
|
||||
default:
|
||||
style = lipgloss.NewStyle().Foreground(latencyRedColor)
|
||||
}
|
||||
|
||||
if ms == 0 {
|
||||
us := d.Microseconds()
|
||||
return style.Render(fmt.Sprintf("%dµs", us))
|
||||
}
|
||||
|
||||
return style.Render(fmt.Sprintf("%dms", ms))
|
||||
}
|
||||
|
||||
// formatBytes formats bytes to human readable format
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatSpeed formats speed to human readable format
|
||||
func formatSpeed(bytesPerSec float64) string {
|
||||
const unit = 1024.0
|
||||
if bytesPerSec < unit {
|
||||
return fmt.Sprintf("%.0f B/s", bytesPerSec)
|
||||
}
|
||||
div, exp := unit, 0
|
||||
for n := bytesPerSec / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB/s", bytesPerSec/div, "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func statColumn(label, value string, width int) string {
|
||||
labelView := lipgloss.NewStyle().
|
||||
Foreground(mutedColor).
|
||||
Render(strings.ToUpper(label))
|
||||
|
||||
block := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
labelView,
|
||||
lipgloss.NewStyle().MarginLeft(1).Render(value),
|
||||
)
|
||||
|
||||
if width <= 0 {
|
||||
return block
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Render(block)
|
||||
}
|
||||
|
||||
func tunnelVisuals(tunnelType string) (string, string, lipgloss.Color) {
|
||||
switch tunnelType {
|
||||
case "http":
|
||||
return "🚀", "HTTP", lipgloss.Color("#0070F3")
|
||||
case "https":
|
||||
return "🔒", "HTTPS", lipgloss.Color("#2D8CFF")
|
||||
case "tcp":
|
||||
return "🔌", "TCP", lipgloss.Color("#50E3C2")
|
||||
default:
|
||||
return "🌐", strings.ToUpper(tunnelType), lipgloss.Color("#0070F3")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user