initial commit
This commit is contained in:
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