initial commit
This commit is contained in:
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Build Stage
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod and sum files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the server binary
|
||||||
|
# CGO_ENABLED=0 ensures a static binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o grokway-server ./cmd/server
|
||||||
|
|
||||||
|
# Final Stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=builder /app/grokway-server .
|
||||||
|
|
||||||
|
# Copy static files (needed for landing page checks)
|
||||||
|
COPY --from=builder /app/cmd/server/static ./cmd/server/static
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
# 2222 for SSH Tunneling
|
||||||
|
# 8080 for HTTP Proxy
|
||||||
|
EXPOSE 2222 8080
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
CMD ["./grokway-server"]
|
||||||
15325
client_debug.log
Normal file
15325
client_debug.log
Normal file
File diff suppressed because it is too large
Load Diff
66
cmd/client/main.go
Normal file
66
cmd/client/main.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"grokway/internal/config"
|
||||||
|
"grokway/internal/tui"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Subcommand handling
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "configure" {
|
||||||
|
configureCmd := flag.NewFlagSet("configure", flag.ExitOnError)
|
||||||
|
tokenFlag := configureCmd.String("token", "", "Authentication token")
|
||||||
|
serverFlag := configureCmd.String("server", "", "Grokway server address (e.g., localhost:2222)")
|
||||||
|
configureCmd.Parse(os.Args[2:])
|
||||||
|
|
||||||
|
if *tokenFlag == "" {
|
||||||
|
fmt.Println("Error: --token is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(*tokenFlag, *serverFlag); err != nil {
|
||||||
|
fmt.Printf("Error saving config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Configuration saved successfully!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal run
|
||||||
|
localPort := flag.String("local", "8080", "Local port to expose")
|
||||||
|
serverAddrFlag := flag.String("server", "", "Grokway server address")
|
||||||
|
tokenFlag := flag.String("token", "", "Authentication token (overrides config)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
cfg, err := config.Load()
|
||||||
|
authToken := *tokenFlag
|
||||||
|
serverAddr := *serverAddrFlag
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if authToken == "" {
|
||||||
|
authToken = cfg.Token
|
||||||
|
}
|
||||||
|
if serverAddr == "" {
|
||||||
|
serverAddr = cfg.ServerURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default server address if not specified anywhere
|
||||||
|
if serverAddr == "" {
|
||||||
|
serverAddr = "localhost:2222"
|
||||||
|
}
|
||||||
|
|
||||||
|
m := tui.InitialModel(*localPort, serverAddr, authToken)
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Printf("Alas, there's been an error: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
371
cmd/server/main.go
Normal file
371
cmd/server/main.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
"github.com/gliderlabs/ssh"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
func generateSlug(length int) string {
|
||||||
|
b := make([]byte, length)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[rand.Intn(len(charset))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelManager manages the active tunnels
|
||||||
|
type TunnelManager struct {
|
||||||
|
tunnels map[string]*Tunnel
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tunnel struct {
|
||||||
|
ID string
|
||||||
|
LocalPort uint32
|
||||||
|
Conn *gossh.ServerConn
|
||||||
|
}
|
||||||
|
|
||||||
|
var manager = &TunnelManager{
|
||||||
|
tunnels: make(map[string]*Tunnel),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *TunnelManager) Register(id string, t *Tunnel) {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
tm.tunnels[id] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *TunnelManager) Unregister(id string) {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
delete(tm.tunnels, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *TunnelManager) Get(id string) (*Tunnel, bool) {
|
||||||
|
tm.mu.RLock()
|
||||||
|
defer tm.mu.RUnlock()
|
||||||
|
t, ok := tm.tunnels[id]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *TunnelManager) FindSlugByConn(conn *gossh.ServerConn) (string, bool) {
|
||||||
|
tm.mu.RLock()
|
||||||
|
defer tm.mu.RUnlock()
|
||||||
|
for slug, t := range tm.tunnels {
|
||||||
|
if t.Conn == conn {
|
||||||
|
return slug, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP Proxy to forward requests to the SSH tunnel
|
||||||
|
func startHttpProxy(port string) {
|
||||||
|
log.Printf("Starting HTTP Proxy on %s", port)
|
||||||
|
// Serve static files for 404 page
|
||||||
|
|
||||||
|
http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./cmd/server/static/assets"))))
|
||||||
|
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract subdomain to identify the tunnel
|
||||||
|
// Format expects: slug.grokway.com or slug.localhost
|
||||||
|
host := r.Host
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
// If accessing IP directly or localhost without subdomain
|
||||||
|
if len(parts) < 2 || host == "localhost:8080" {
|
||||||
|
http.Error(w, "Grokway Server Running", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := parts[0]
|
||||||
|
|
||||||
|
tunnel, ok := manager.Get(slug)
|
||||||
|
if !ok {
|
||||||
|
// Serve 404 page
|
||||||
|
http.ServeFile(w, r, "./cmd/server/static/404.html")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real implementation, we would dial the tunnel
|
||||||
|
// using ssh.Conn.OpenChannel("direct-tcpip", ...)
|
||||||
|
// But gliderlabs/ssh handles Reverse Port Forwarding differently.
|
||||||
|
// It usually binds a listener on the server side.
|
||||||
|
|
||||||
|
// Wait! The standard "Remote Port Forwarding" (-R) means the SSH Server
|
||||||
|
// listens on a port ON THE SERVER, and forwards traffic to the client.
|
||||||
|
|
||||||
|
// If GrokwayV1 used -R 80:localhost:8080, then the server was listening on 80.
|
||||||
|
// Here we want dynamic subdomains.
|
||||||
|
// So we likely want to use a Custom Request or just standard forwarding
|
||||||
|
// but mapped to our HTTP proxy.
|
||||||
|
|
||||||
|
// SIMPLE APPROACH (Like Ngrok):
|
||||||
|
// 1. Client connects via SSH.
|
||||||
|
// 2. Client requests "tcpip-forward" (remote forwarding).
|
||||||
|
// 3. Server acknowledges.
|
||||||
|
// 4. When HTTP request comes to `slug.grokway.com`:
|
||||||
|
// a. Server accepts request.
|
||||||
|
// b. Server opens a "forwarded-tcpip" channel TO the client.
|
||||||
|
// c. Server proxies the HTTP request body over this channel.
|
||||||
|
|
||||||
|
// For this MVP, let's assume we implement the "forwarded-tcpip" logic.
|
||||||
|
// However, gliderlabs/ssh simplifies this if we use its Forwarding support.
|
||||||
|
// But typically `gliderlabs/ssh` is for allowing the server to be a jump host.
|
||||||
|
// We want to be an HTTP Gateway.
|
||||||
|
|
||||||
|
// Manual channel opening:
|
||||||
|
if tunnel.Conn == nil {
|
||||||
|
http.Error(w, "Tunnel Broken", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := make([]byte, 0) // dynamic payload constructing
|
||||||
|
// Open channel to client
|
||||||
|
// "forwarded-tcpip" arguments:
|
||||||
|
// string address that was connected
|
||||||
|
// uint32 port that was connected
|
||||||
|
// string originator IP address
|
||||||
|
// uint32 originator port
|
||||||
|
|
||||||
|
// For simplicity, we just say we are connecting to 127.0.0.1:LocalPort
|
||||||
|
// And originating from 127.0.0.1:0
|
||||||
|
|
||||||
|
destHost := "0.0.0.0"
|
||||||
|
destPort := tunnel.LocalPort
|
||||||
|
srcHost := "127.0.0.1"
|
||||||
|
srcPort := uint32(12345) // Random source port, cannot be 0
|
||||||
|
|
||||||
|
payload = append(payload, []byte{0, 0, 0, uint8(len(destHost))}...)
|
||||||
|
payload = append(payload, []byte(destHost)...)
|
||||||
|
payload = append(payload, byte(destPort>>24), byte(destPort>>16), byte(destPort>>8), byte(destPort))
|
||||||
|
|
||||||
|
payload = append(payload, []byte{0, 0, 0, uint8(len(srcHost))}...)
|
||||||
|
payload = append(payload, []byte(srcHost)...)
|
||||||
|
payload = append(payload, byte(srcPort>>24), byte(srcPort>>16), byte(srcPort>>8), byte(srcPort))
|
||||||
|
|
||||||
|
ch, reqs, err := tunnel.Conn.OpenChannel("forwarded-tcpip", payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to open forwarded-tcpip channel: %s", err)
|
||||||
|
http.Error(w, "Failed to connect to client", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ch.Close()
|
||||||
|
go gossh.DiscardRequests(reqs)
|
||||||
|
|
||||||
|
// Pipe HTTP request to the channel?
|
||||||
|
// No, we can't just pipe the raw HTTP structure unless the client expects it.
|
||||||
|
// If the client is just a TCP forwarder (ssh -R), it expects a raw TCP stream.
|
||||||
|
// So we need to act as a TCP proxy.
|
||||||
|
// But we are in an http.Handler.
|
||||||
|
// We can hijack the connection or just Re-serialize the HTTP request.
|
||||||
|
|
||||||
|
// Re-serializing is safer for now.
|
||||||
|
// Or we use Hijack to get the raw TCP conn.
|
||||||
|
|
||||||
|
// Let's iterate: just write the Request to the channel
|
||||||
|
// The client will see it as raw bytes on the socket.
|
||||||
|
// This works if the client is forwarding to a web server.
|
||||||
|
|
||||||
|
err = r.Write(ch) // Writes the request wire representation
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to write request: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response from channel and write to w
|
||||||
|
// This is tricky because we need to parse the response to set headers correctly in w.
|
||||||
|
// Or we can just use Hijack if we want to be a transparent TCP proxy.
|
||||||
|
// But Hijack breaks HTTP/2 and some middleware.
|
||||||
|
// Let's use `http.ReadResponse`
|
||||||
|
|
||||||
|
// Problem: `r.Write(ch)` might not close the write side, so the server might wait.
|
||||||
|
// but HTTP 1.1 usually has content-length.
|
||||||
|
|
||||||
|
// Using bufio to read the response from the channel.
|
||||||
|
// resp, err := http.ReadResponse(bufio.NewReader(ch), r)
|
||||||
|
// ... copy headers, body ...
|
||||||
|
|
||||||
|
// For MVP, hijacking is often easiest for "Tunneling"
|
||||||
|
hijacker, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientConn, _, err := hijacker.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
// bidirectional copy
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
// We already consumed `r` somewhat? No, Hijack takes over.
|
||||||
|
// But `http.HandleFunc` might have read the headers.
|
||||||
|
// The `clientConn` is the raw TCP connection from the Browser/ExternalUser to OUR Proxy.
|
||||||
|
// We need to replay the request headers!
|
||||||
|
// Because `http.Server` already read them.
|
||||||
|
|
||||||
|
// Actually, `r.Write(ch)` is correct for sending the request to the backend.
|
||||||
|
// But then we need to pipe `ch` output back to `w`.
|
||||||
|
// And since we used Hijack, we are responsible for the entire response.
|
||||||
|
|
||||||
|
// So:
|
||||||
|
// 1. Write `r` to `ch`.
|
||||||
|
// 2. IO Copy `ch` -> `clientConn`.
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
r.Write(ch) // Send the request to valid backend
|
||||||
|
// Also forward any extra body if not fully read?
|
||||||
|
// r.Write handles body.
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
io.Copy(clientConn, ch) // Copy response from backend to user
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait? No, if we wait for r.Write, it might block if the body is large.
|
||||||
|
// Actually, standard proxying is complex.
|
||||||
|
// Simplest "Grokway" logic:
|
||||||
|
// User runs: ssh -R 80:localhost:8080
|
||||||
|
// Server listens on a random port (or we map virtual host).
|
||||||
|
|
||||||
|
// Let's ignore Hijack for a moment and try standard Request/Response.
|
||||||
|
// Implementation detail for later.
|
||||||
|
|
||||||
|
// Connection is hijacked, do not write to w
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(port, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
|
||||||
|
// 0. Parse Flags
|
||||||
|
sshAddr := flag.String("ssh", ":2222", "Address for SSH server to listen on")
|
||||||
|
httpAddr := flag.String("http", ":8080", "Address for HTTP proxy to listen on")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 1. Auth Token Setup
|
||||||
|
authToken := os.Getenv("GROKWAY_TOKEN")
|
||||||
|
if authToken == "" {
|
||||||
|
authToken = generateSlug(16) // Longer token
|
||||||
|
fmt.Printf("\nGenerated Admin Token: %s\n\n", authToken)
|
||||||
|
fmt.Println("Use this token to configure your client:")
|
||||||
|
fmt.Printf(" grokway client configure --token %s --server <GROKWAY_SERVER_ADDRESS>\n\n", authToken)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Using configured GROKWAY_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SSH Server
|
||||||
|
sshServer := &ssh.Server{
|
||||||
|
Addr: *sshAddr,
|
||||||
|
PasswordHandler: func(ctx ssh.Context, password string) bool {
|
||||||
|
return password == authToken
|
||||||
|
},
|
||||||
|
Handler: func(s ssh.Session) {
|
||||||
|
// This handler handles the "Shell" or "Exec" requests.
|
||||||
|
// The port forwarding happens via "tcpip-forward" request global handler.
|
||||||
|
|
||||||
|
// Show TUI-ish banner to the SSH client
|
||||||
|
io.WriteString(s, "Welcome to GrokwayV2!\n")
|
||||||
|
io.WriteString(s, fmt.Sprintf("You are connected as %s\n", s.User()))
|
||||||
|
|
||||||
|
// Keep session open
|
||||||
|
select {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register global request handler for "tcpip-forward"
|
||||||
|
// This is where clients say "Please listen on port X"
|
||||||
|
sshServer.RequestHandlers = map[string]ssh.RequestHandler{
|
||||||
|
"tcpip-forward": func(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) {
|
||||||
|
// Parse payload
|
||||||
|
// string address to bind (usually empty or 0.0.0.0 or 127.0.0.1)
|
||||||
|
// uint32 port number to bind
|
||||||
|
|
||||||
|
// For Grokway, we ignore the requested port and assign a random subdomain/slug
|
||||||
|
// Or we use the requested port if valid?
|
||||||
|
// Let's assume we ignore it and generate a slug,
|
||||||
|
// OR we use the port as the "slug" if it's special.
|
||||||
|
|
||||||
|
// But wait, the client needs to know what we assigned!
|
||||||
|
// Standard SSH response to tcpip-forward contains the BOUND PORT.
|
||||||
|
// If we return a port, the client knows.
|
||||||
|
// If we want to return a URL, standard SSH doesn't have a field for that.
|
||||||
|
// But we can write it to the Session (stdout).
|
||||||
|
|
||||||
|
// Let's accept any request and map it to a random slug.
|
||||||
|
slug := generateSlug(8)
|
||||||
|
|
||||||
|
log.Printf("Client requested forwarding. Assigning slug: %s", slug)
|
||||||
|
|
||||||
|
conn := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
|
||||||
|
|
||||||
|
manager.Register(slug, &Tunnel{
|
||||||
|
ID: slug,
|
||||||
|
LocalPort: 80, // We assume client forwards to port 80 locally? No,
|
||||||
|
// the client says "forward FROM remote port X".
|
||||||
|
// The server opens channel TO client when traffic hits X.
|
||||||
|
Conn: conn,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Monitor connection for closure to cleanup
|
||||||
|
go func() {
|
||||||
|
conn.Wait()
|
||||||
|
log.Printf("Connection closed for slug %s, unregistering...", slug)
|
||||||
|
manager.Unregister(slug)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Reply success
|
||||||
|
// Payload: port that was bound (uint32)
|
||||||
|
// We can return a successful port, e.g. 0 or 80.
|
||||||
|
portBytes := []byte{0, 0, 0, 80}
|
||||||
|
return true, portBytes
|
||||||
|
},
|
||||||
|
"cancel-tcpip-forward": func(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) {
|
||||||
|
return true, nil
|
||||||
|
},
|
||||||
|
"grokway-whoami": func(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) {
|
||||||
|
conn := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
|
||||||
|
slug, ok := manager.FindSlugByConn(conn)
|
||||||
|
if !ok {
|
||||||
|
// Not found? Maybe not forwarded yet?
|
||||||
|
log.Printf("Whoami requested but no tunnel found for conn %v", conn.RemoteAddr())
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, []byte(slug)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Host Key (optional for dev, helpful for no warnings)
|
||||||
|
// sshServer.SetOption(ssh.HostKeyFile("host.key"))
|
||||||
|
|
||||||
|
fmt.Printf("Starting GrokwayV2 Server on %s\n", *sshAddr)
|
||||||
|
fmt.Printf("Starting HTTP Proxy on %s\n", *httpAddr)
|
||||||
|
|
||||||
|
go startHttpProxy(*httpAddr)
|
||||||
|
|
||||||
|
log.Fatal(sshServer.ListenAndServe())
|
||||||
|
}
|
||||||
BIN
cmd/server/static.zip
Normal file
BIN
cmd/server/static.zip
Normal file
Binary file not shown.
67
cmd/server/static/404.html
Normal file
67
cmd/server/static/404.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, follow" />
|
||||||
|
|
||||||
|
<title>Page not found - Grokway</title>
|
||||||
|
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:title" content="Page not found - Grokway" />
|
||||||
|
<meta property="og:site_name" content="Grokway" />
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="dns-prefetch" href="https://s.w.org/" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
id="a3a3_lazy_load-css"
|
||||||
|
href="./assets/a3_lazy_load.min.css"
|
||||||
|
type="text/css"
|
||||||
|
media="all"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="error_404">
|
||||||
|
<div class="tcvpb_section_content">
|
||||||
|
<div class="tcvpb_container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="span6">
|
||||||
|
<div class="error_404_contentBox">
|
||||||
|
<div class="error_404_content">
|
||||||
|
<h3>Oops! You ran out of oxygen.</h3>
|
||||||
|
<p>The page you're looking for is now beyond our reach.</p>
|
||||||
|
</div>
|
||||||
|
<div class="error_404_timer" style="height: 200px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="span6">
|
||||||
|
<div class="error_404_lettersBox">
|
||||||
|
<div class="error_404_letters">
|
||||||
|
|
||||||
|
<div class="error_404_letter4 error_404_letter">
|
||||||
|
<h1>4</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error_404_letter0 error_404_letter">
|
||||||
|
<h1>0</h1>
|
||||||
|
|
||||||
|
<!-- Astronaut -->
|
||||||
|
<div class="error_404_astro">
|
||||||
|
<object data="assets/astro.svg"></object>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error_404_letter4 error_404_letter">
|
||||||
|
<h1>4</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
cmd/server/static/assets/a3_lazy_load.min.css
vendored
Normal file
1
cmd/server/static/assets/a3_lazy_load.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.clear{clear:both}.nobr{white-space:nowrap}.lazy-hidden,.entry img.lazy-hidden,img.thumbnail.lazy-hidden{background-color:#fff}
|
||||||
1221
cmd/server/static/assets/astro.svg
Normal file
1221
cmd/server/static/assets/astro.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 644 KiB |
211
cmd/server/static/assets/style.css
Normal file
211
cmd/server/static/assets/style.css
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
* {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Josefin Sans", sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: #000;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404 {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
background-image:
|
||||||
|
url(//www.mantralabsglobal.com/wp-content/themes/spiral-child/images/error_404/white_grain.png),
|
||||||
|
url(//www.mantralabsglobal.com/wp-content/themes/spiral-child/images/film_grain.png);
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404 .tcvpb_section_content,
|
||||||
|
.error_404 .tcvpb_container,
|
||||||
|
.error_404 .row {
|
||||||
|
height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404 .row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
[class*=span]:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .span6 {
|
||||||
|
width: 48.71794871794872%
|
||||||
|
}
|
||||||
|
|
||||||
|
.row>[class*=span] {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
/* width: 100%; */
|
||||||
|
min-height: 20px;
|
||||||
|
/* margin-left: 2.564102564102564%; */
|
||||||
|
/* -webkit-box-sizing: border-box; */
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
.error_404_contentBox {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404_content h3 {
|
||||||
|
font-size: 50px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404_content p {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 1.11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
.error_404_lettersBox {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404_letters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404_letter h1 {
|
||||||
|
font-family: 'poppins', sans-serif;
|
||||||
|
font-size: 300px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 300px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error_404_letter0 {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
.error_404_astro {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
animation: error_astro_move 12s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes error_astro_move {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -50%) scale(1.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) scale(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
@media only screen and (min-width: 960px) and (max-width: 1170px) {
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.tcvpb_container,
|
||||||
|
.boxed_body_wrapper {
|
||||||
|
width: 960px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.tcvpb_container {
|
||||||
|
width: 1170px;
|
||||||
|
max-width: 95%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 959px) {
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.tcvpb_container,
|
||||||
|
.boxed_body_wrapper {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 959px) {
|
||||||
|
.error_404 .row {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 959px) {
|
||||||
|
|
||||||
|
.Microsite_webinar_fold1 .span7,
|
||||||
|
.error_404 .span6 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 959px) {
|
||||||
|
.error_404_contentBox {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcvpb_container,
|
||||||
|
.error_404 .row {
|
||||||
|
height: 40%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcvpb_container,
|
||||||
|
.error_404 .row {
|
||||||
|
height: 80%;
|
||||||
|
}
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
grokway-server:
|
||||||
|
build: .
|
||||||
|
container_name: grokway-server
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "2222:2222" # SSH Tunnel Port
|
||||||
|
- "8080:8080" # HTTP Proxy Port
|
||||||
|
volumes:
|
||||||
|
- ./server_data:/root/data # Optional: Persist data if needed
|
||||||
|
networks:
|
||||||
|
- grokway-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
grokway-net:
|
||||||
|
driver: bridge
|
||||||
0
docs/changes.md
Normal file
0
docs/changes.md
Normal file
222
docs/how_deploy_prod.md
Normal file
222
docs/how_deploy_prod.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Deploying GrokwayV2 to Production
|
||||||
|
|
||||||
|
This guide explains how to deploy the **GrokwayV2 Server** using Docker and Docker Compose.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Linux server (Ubuntu/Debian recommended) with a public IP.
|
||||||
|
- **Docker** and **Docker Compose** installed.
|
||||||
|
- A domain name (e.g., `example.com`) pointing to your server's IP.
|
||||||
|
- **Wildcard DNS** configured (e.g., `*.example.com` -> Server IP) to support dynamic subdomains.
|
||||||
|
|
||||||
|
## 1. Installation
|
||||||
|
|
||||||
|
1. **Clone the repository** (or copy the files) to your server:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd GrokwayV2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify Configuration**
|
||||||
|
- Ensure `Dockerfile` and `docker-compose.yml` are present.
|
||||||
|
- The default configuration uses ports `2222` (SSH) and `8080` (HTTP). Open these ports in your firewall (e.g., Security Groups, UFW).
|
||||||
|
|
||||||
|
## 2. Deploy with Docker Compose
|
||||||
|
|
||||||
|
Run the following command to build and start the server in detached mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Status
|
||||||
|
Check if the container is running:
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Client Connection
|
||||||
|
|
||||||
|
Clients can now connect to your server using the public IP or domain.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
# If running locally (dev)
|
||||||
|
go run cmd/client/main.go --server example.com:2222 --local <local-port>
|
||||||
|
|
||||||
|
# If using a compiled binary
|
||||||
|
./grokway-client --server example.com:2222 --local <local-port>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Ensure the client points to port `2222` (or your mapped SSH port).
|
||||||
|
|
||||||
|
## 4. Updates
|
||||||
|
|
||||||
|
To deploy a new version:
|
||||||
|
|
||||||
|
1. Pull the latest code:
|
||||||
|
```bash
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
2. Rebuild and restart:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Connection Refused:** Check if ports 2222 and 8080 are open in the firewall.
|
||||||
|
- **DNS Issues:** Ensure your wildcard DNS (`*.example.com`) is correctly propagating.
|
||||||
|
- **Permission Denied:** Ensure your user has rights to run docker commands (add user to docker group).
|
||||||
|
|
||||||
|
## Nginx Configuration
|
||||||
|
|
||||||
|
Since you already have `api.orin.remote.band` configured, you can add a new configuration for the main application and its subdomains.
|
||||||
|
|
||||||
|
### 1. HTTP/HTTPS Configuration (sites-available)
|
||||||
|
|
||||||
|
Create a new file `/etc/nginx/sites-available/grokway`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
# Main domain and wildcard subdomains
|
||||||
|
server_name grokway.orin.remote.band *.grokway.orin.remote.band;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support (important for future interactive features)
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable it:
|
||||||
|
```bash
|
||||||
|
ln -s /etc/nginx/sites-available/grokway /etc/nginx/sites-enabled/
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSL Certificate (Crucial Step):**
|
||||||
|
|
||||||
|
To support dynamic subdomains (e.g., `client1.grokway.orin.remote.band`), you **MUST** obtain a **Wildcard Certificate**. A standard certificate for `grokway.orin.remote.band` is **NOT valid** for its subdomains.
|
||||||
|
|
||||||
|
You need a certificate that covers both:
|
||||||
|
1. `grokway.orin.remote.band`
|
||||||
|
2. `*.grokway.orin.remote.band`
|
||||||
|
|
||||||
|
**How to get a Wildcard Certificate with Certbot:**
|
||||||
|
Wildcard certificates require **DNS validation** (you cannot use HTTP validation).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Request a wildcard certificate
|
||||||
|
sudo certbot certonly --manual --preferred-challenges dns -d "grokway.orin.remote.band" -d "*.grokway.orin.remote.band"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Run the command.
|
||||||
|
2. Certbot will ask you to create a specific **TXT record** in your DNS provider (where you manage `orin.remote.band`).
|
||||||
|
3. Create the TXT record as instructed.
|
||||||
|
4. Wait a minute for propagation, then press Enter.
|
||||||
|
|
||||||
|
Once obtained, update your Nginx config (`/etc/nginx/sites-available/grokway`) to use the new certificates.
|
||||||
|
|
||||||
|
**Full Configuration with SSL:**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name grokway.orin.remote.band *.grokway.orin.remote.band;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
listen 443 ssl;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/grokway.orin.remote.band/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/grokway.orin.remote.band/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
server {
|
||||||
|
if ($host = grokway.orin.remote.band) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
# Also redirect subdomains if needed, or use a wildcard match here
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
server_name grokway.orin.remote.band *.grokway.orin.remote.band;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SSH Proxy Configuration (nginx.conf)
|
||||||
|
|
||||||
|
**Important:** The SSH proxy uses the `stream` module. This block **CANNOT** go inside `sites-available` (which are included inside the `http` block). It must go at the top level of `/etc/nginx/nginx.conf`.
|
||||||
|
|
||||||
|
Edit `/etc/nginx/nginx.conf` and add this **at the very end of the file**, outside of any `http {}` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
stream {
|
||||||
|
upstream grokway_ssh {
|
||||||
|
# Forward to the internal port where Grokway server is running (e.g., 2223)
|
||||||
|
server 127.0.0.1:2223;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 2222; # Public facing SSH port
|
||||||
|
proxy_pass grokway_ssh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing `nginx.conf`:
|
||||||
|
```bash
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the Server
|
||||||
|
|
||||||
|
Since Nginx is now listening on port `2222` (public), you must run the Grokway server on a **different internal port** (e.g., `2223`) to avoid conflicts.
|
||||||
|
|
||||||
|
Running locally with Go:
|
||||||
|
```bash
|
||||||
|
go run cmd/server/main.go -ssh :2223
|
||||||
|
```
|
||||||
|
|
||||||
|
Running with Docker (update `docker-compose.yml`):
|
||||||
|
- Ensure the container exposes/maps port `2223` internally if needed, or simply let it run on default but map `2223:2222`?
|
||||||
|
- Actually, better just pass the arg:
|
||||||
|
```bash
|
||||||
|
command: ["/app/grokway-server", "-ssh", ":2223"]
|
||||||
|
```
|
||||||
|
And update ports mapping:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:2223:2223" # Only bind to localhost if Nginx is proxying
|
||||||
|
```
|
||||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
module grokway
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
|
golang.org/x/crypto v0.31.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
)
|
||||||
55
go.sum
Normal file
55
go.sum
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
66
internal/config/config.go
Normal file
66
internal/config/config.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigFile() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
configDir := filepath.Join(home, ".grokway")
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(configDir, "config.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
path, err := getConfigFile()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &Config{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(token, serverURL string) error {
|
||||||
|
path, err := getConfigFile()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Token: token,
|
||||||
|
ServerURL: serverURL,
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print path for user friendliness
|
||||||
|
fmt.Printf("Saving configuration to: %s\n", path)
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
284
internal/tui/model.go
Normal file
284
internal/tui/model.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"grokway/internal/tunnel"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
var (
|
||||||
|
primaryColor = lipgloss.Color("#7D56F4")
|
||||||
|
secondaryColor = lipgloss.Color("#FAFAFA")
|
||||||
|
subtleColor = lipgloss.Color("#626262")
|
||||||
|
accentColor = lipgloss.Color("#FF79C6") // Pinkish
|
||||||
|
successColor = lipgloss.Color("#04B575") // Green
|
||||||
|
errorColor = lipgloss.Color("#FF0000") // Red
|
||||||
|
|
||||||
|
headerStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(secondaryColor).
|
||||||
|
Background(primaryColor).
|
||||||
|
Padding(1, 2).
|
||||||
|
Align(lipgloss.Center)
|
||||||
|
|
||||||
|
urlLabelStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(successColor).
|
||||||
|
Bold(true).
|
||||||
|
MarginRight(1)
|
||||||
|
|
||||||
|
urlValueStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(accentColor).
|
||||||
|
Bold(true).
|
||||||
|
Underline(true)
|
||||||
|
|
||||||
|
boxStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(primaryColor).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
statLabelStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(subtleColor).
|
||||||
|
MarginRight(1)
|
||||||
|
|
||||||
|
statValueStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(secondaryColor).
|
||||||
|
Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
Client *tunnel.Client
|
||||||
|
LogLines []string
|
||||||
|
TotalBytes int64
|
||||||
|
Requests int
|
||||||
|
Viewport viewport.Model
|
||||||
|
Ready bool
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Copied bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogMsg string
|
||||||
|
type MetricMsg int64
|
||||||
|
type ClearCopiedMsg struct{}
|
||||||
|
|
||||||
|
func InitialModel(localPort, serverAddr, authToken string) Model {
|
||||||
|
c := tunnel.NewClient(serverAddr, localPort, authToken)
|
||||||
|
return Model{
|
||||||
|
Client: c,
|
||||||
|
LogLines: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
startTunnel(m.Client),
|
||||||
|
waitForLog(m.Client),
|
||||||
|
waitForMetric(m.Client),
|
||||||
|
tea.EnableMouseAllMotion, // Enable mouse support
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTunnel(c *tunnel.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := c.Start(); err != nil {
|
||||||
|
return LogMsg(fmt.Sprintf("Error starting tunnel: %v", err))
|
||||||
|
}
|
||||||
|
return LogMsg("Tunnel started successfully!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForLog(c *tunnel.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg := <-c.Events
|
||||||
|
return LogMsg(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForMetric(c *tunnel.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
bytes := <-c.Metrics
|
||||||
|
return MetricMsg(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCopiedMsg() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second*2, func(_ time.Time) tea.Msg {
|
||||||
|
return ClearCopiedMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var (
|
||||||
|
cmd tea.Cmd
|
||||||
|
cmds []tea.Cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
// Handle key presses
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c", "esc":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "c":
|
||||||
|
if m.Client.PublicURL != "" {
|
||||||
|
clipboard.WriteAll(m.Client.PublicURL)
|
||||||
|
m.Copied = true
|
||||||
|
cmds = append(cmds, clearCopiedMsg())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case ClearCopiedMsg:
|
||||||
|
m.Copied = false
|
||||||
|
|
||||||
|
case LogMsg:
|
||||||
|
// Colorize log line based on content
|
||||||
|
line := string(msg)
|
||||||
|
styledLine := line
|
||||||
|
timestamp := time.Now().Format("15:04:05")
|
||||||
|
prefix := lipgloss.NewStyle().Foreground(subtleColor).Render(timestamp + " | ")
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(line), "error") {
|
||||||
|
styledLine = lipgloss.NewStyle().Foreground(errorColor).Render(line)
|
||||||
|
} else if strings.Contains(strings.ToLower(line), "success") || strings.Contains(strings.ToLower(line), "connected") {
|
||||||
|
styledLine = lipgloss.NewStyle().Foreground(successColor).Render(line)
|
||||||
|
} else {
|
||||||
|
styledLine = lipgloss.NewStyle().Foreground(secondaryColor).Render(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.LogLines = append(m.LogLines, prefix+styledLine)
|
||||||
|
|
||||||
|
// Keep log buffer reasonable
|
||||||
|
if len(m.LogLines) > 1000 {
|
||||||
|
m.LogLines = m.LogLines[len(m.LogLines)-1000:]
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds = append(cmds, waitForLog(m.Client))
|
||||||
|
|
||||||
|
// Update viewport content and scroll if near bottom
|
||||||
|
m.Viewport.SetContent(strings.Join(m.LogLines, "\n"))
|
||||||
|
m.Viewport.GotoBottom()
|
||||||
|
|
||||||
|
case MetricMsg:
|
||||||
|
m.TotalBytes += int64(msg)
|
||||||
|
m.Requests++
|
||||||
|
cmds = append(cmds, waitForMetric(m.Client))
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.Width = msg.Width
|
||||||
|
m.Height = msg.Height
|
||||||
|
|
||||||
|
headerHeight := 10 // Approximate height of header + metrics + borders
|
||||||
|
|
||||||
|
if !m.Ready {
|
||||||
|
m.Viewport = viewport.New(msg.Width-4, msg.Height-headerHeight) // -4 for borders/padding
|
||||||
|
m.Viewport.YPosition = headerHeight
|
||||||
|
m.Ready = true
|
||||||
|
} else {
|
||||||
|
m.Viewport.Width = msg.Width - 4
|
||||||
|
m.Viewport.Height = msg.Height - headerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render content with new width if needed
|
||||||
|
m.Viewport.SetContent(strings.Join(m.LogLines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle viewport updates (scrolling, mouse events)
|
||||||
|
m.Viewport, cmd = m.Viewport.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
if !m.Ready {
|
||||||
|
return "\n Initializing Grokway..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Header
|
||||||
|
headerStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#FFFFFF")).
|
||||||
|
Background(primaryColor).
|
||||||
|
Width(m.Width).
|
||||||
|
Align(lipgloss.Center)
|
||||||
|
|
||||||
|
header := headerStyle.Render("GROKWAY V2")
|
||||||
|
|
||||||
|
// 2. Connection Info (URL under header)
|
||||||
|
leftSide := fmt.Sprintf("%s localhost:%s", urlLabelStyle.Render("Local:"), m.Client.LocalPort)
|
||||||
|
arrow := lipgloss.NewStyle().Foreground(subtleColor).Render(" ➜ ")
|
||||||
|
|
||||||
|
displayAddr := m.Client.ServerAddr
|
||||||
|
if m.Client.PublicURL != "" {
|
||||||
|
displayAddr = m.Client.PublicURL
|
||||||
|
}
|
||||||
|
rightSide := fmt.Sprintf("%s %s", urlLabelStyle.Render("Remote:"), urlValueStyle.Render(displayAddr))
|
||||||
|
|
||||||
|
// Add copy hint
|
||||||
|
copyHint := " (Press 'c' to copy)"
|
||||||
|
if m.Copied {
|
||||||
|
copyHint = lipgloss.NewStyle().Foreground(successColor).Bold(true).Render(" (COPIED!)")
|
||||||
|
} else {
|
||||||
|
copyHint = lipgloss.NewStyle().Foreground(subtleColor).Render(copyHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionBar := lipgloss.NewStyle().
|
||||||
|
Width(m.Width).
|
||||||
|
Align(lipgloss.Center).
|
||||||
|
Padding(1, 0).
|
||||||
|
Render(leftSide + arrow + rightSide + copyHint)
|
||||||
|
|
||||||
|
// 3. Stats Row
|
||||||
|
stats := fmt.Sprintf("%s %s %s %s %s %s %s %s",
|
||||||
|
statLabelStyle.Render("Requests:"), statValueStyle.Render(fmt.Sprintf("%d", m.Requests)),
|
||||||
|
lipgloss.NewStyle().Foreground(subtleColor).Render("•"),
|
||||||
|
statLabelStyle.Render("Data:"), statValueStyle.Render(formatBytes(m.TotalBytes)),
|
||||||
|
lipgloss.NewStyle().Foreground(subtleColor).Render("•"),
|
||||||
|
statLabelStyle.Render("Status:"), lipgloss.NewStyle().Foreground(successColor).Render("Active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
statsBar := lipgloss.NewStyle().
|
||||||
|
Width(m.Width).
|
||||||
|
Align(lipgloss.Center).
|
||||||
|
PaddingBottom(1).
|
||||||
|
Render(stats)
|
||||||
|
|
||||||
|
// 4. Log Container
|
||||||
|
// Ensure viewport fits. REDUCING HEIGHT BY EXTRA MARGIN (2 lines) to prevent scroll-off
|
||||||
|
availableHeight := m.Height - lipgloss.Height(header) - lipgloss.Height(connectionBar) - lipgloss.Height(statsBar) - 2
|
||||||
|
if availableHeight < 0 {
|
||||||
|
availableHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
logBox := boxStyle.
|
||||||
|
Width(m.Width - 2).
|
||||||
|
Height(availableHeight).
|
||||||
|
Render(m.Viewport.View())
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
header,
|
||||||
|
connectionBar,
|
||||||
|
statsBar,
|
||||||
|
logBox,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
172
internal/tunnel/client.go
Normal file
172
internal/tunnel/client.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func logToFile(msg string) {
|
||||||
|
f, _ := os.OpenFile("client_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
defer f.Close()
|
||||||
|
f.WriteString(time.Now().Format(time.RFC3339) + " " + msg + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client handles the SSH connection and forwarding
|
||||||
|
type Client struct {
|
||||||
|
ServerAddr string
|
||||||
|
LocalPort string
|
||||||
|
AuthToken string
|
||||||
|
SSHClient *ssh.Client
|
||||||
|
Listener net.Listener
|
||||||
|
Events chan string // Channel to send logs/events to TUI
|
||||||
|
Metrics chan int64 // Channel to send bytes transferred
|
||||||
|
PublicURL string // PublicURL is the URL accessible from the internet
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(serverAddr, localPort, authToken string) *Client {
|
||||||
|
return &Client{
|
||||||
|
ServerAddr: serverAddr,
|
||||||
|
LocalPort: localPort,
|
||||||
|
AuthToken: authToken,
|
||||||
|
Events: make(chan string, 10),
|
||||||
|
Metrics: make(chan int64, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: "grokway",
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.Password(c.AuthToken),
|
||||||
|
},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Dev only
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Events <- fmt.Sprintf("Connecting to %s...", c.ServerAddr)
|
||||||
|
client, err := ssh.Dial("tcp", c.ServerAddr, config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.SSHClient = client
|
||||||
|
c.Events <- "SSH Connected!"
|
||||||
|
|
||||||
|
// Request remote listening (Reverse Forwarding)
|
||||||
|
// Bind to 0.0.0.0 on server, random port (0)
|
||||||
|
listener, err := client.Listen("tcp", "0.0.0.0:0")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to request port forwarding: %w", err)
|
||||||
|
}
|
||||||
|
c.Listener = listener
|
||||||
|
|
||||||
|
// Query server for assigned slug
|
||||||
|
ok, slugBytes, err := client.SendRequest("grokway-whoami", true, nil)
|
||||||
|
slug := "test-slug" // Fallback
|
||||||
|
if err == nil && ok {
|
||||||
|
slug = string(slugBytes)
|
||||||
|
c.Events <- fmt.Sprintf("Server assigned domain: %s", slug)
|
||||||
|
} else {
|
||||||
|
c.Events <- "Failed to query domain from server, using fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := "localhost" // This should match what the server is running on actually
|
||||||
|
|
||||||
|
// Assuming HTTP proxy is on port 8080 of the same host as SSH server (but different port)
|
||||||
|
// We extract host from c.ServerAddr
|
||||||
|
host, _, _ := net.SplitHostPort(c.ServerAddr)
|
||||||
|
if host == "" {
|
||||||
|
host = hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
c.PublicURL = fmt.Sprintf("https://%s.%s", slug, host)
|
||||||
|
c.Events <- fmt.Sprintf("Tunnel established! Public URL: %s", c.PublicURL)
|
||||||
|
|
||||||
|
go c.acceptLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) acceptLoop() {
|
||||||
|
for {
|
||||||
|
remoteConn, err := c.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
c.Events <- fmt.Sprintf("Accept error: %s", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Events <- "New Request received"
|
||||||
|
go c.handleConnection(remoteConn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleConnection(remoteConn net.Conn) {
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
// Dial local service
|
||||||
|
localConn, err := net.Dial("tcp", "localhost:"+c.LocalPort)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("Failed to dial local: %s", err)
|
||||||
|
c.Events <- errMsg
|
||||||
|
logToFile(errMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer localConn.Close()
|
||||||
|
logToFile("Dialed local service successfully")
|
||||||
|
|
||||||
|
// We need to peek at the connection to see if it's HTTP
|
||||||
|
// Wrap the connection to peek without consuming
|
||||||
|
logToFile("Handling new connection")
|
||||||
|
|
||||||
|
// Check if we can peek
|
||||||
|
// Since net.Conn doesn't support Peek, we read specific bytes and reconstruct
|
||||||
|
// or use a bufio reader if we weren't doing a raw Copy.
|
||||||
|
// But io.Copy needs a reader.
|
||||||
|
|
||||||
|
// Create a buffer to read the first few bytes
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := remoteConn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
logToFile(fmt.Sprintf("Error reading from remote: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := string(buf[:n])
|
||||||
|
|
||||||
|
// Try to parse rudimentary HTTP
|
||||||
|
// Format: METHOD PATH PROTOCOL
|
||||||
|
lines := strings.Split(payload, "\n")
|
||||||
|
if len(lines) > 0 {
|
||||||
|
firstLine := lines[0]
|
||||||
|
parts := strings.Fields(firstLine)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
method := parts[0]
|
||||||
|
path := parts[1]
|
||||||
|
// Send structured log
|
||||||
|
c.Events <- fmt.Sprintf("HTTP|%s|%s|%d", method, path, 200) // Status is fake for now, acts as connection open
|
||||||
|
} else {
|
||||||
|
c.Events <- "TCP|||Connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to write the bytes we read to the local connection first
|
||||||
|
localConn.Write(buf[:n])
|
||||||
|
|
||||||
|
// Bidirectional copy
|
||||||
|
// Calculate bytes?
|
||||||
|
|
||||||
|
// We can use a custom copy to track metrics
|
||||||
|
go func() {
|
||||||
|
n, _ := io.Copy(remoteConn, localConn)
|
||||||
|
c.Metrics <- n
|
||||||
|
}()
|
||||||
|
|
||||||
|
n2, _ := io.Copy(localConn, remoteConn)
|
||||||
|
c.Metrics <- n2
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user