initial commit

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-27 02:26:17 +01:00
commit aa0e07a361
17 changed files with 18148 additions and 0 deletions

34
Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

66
cmd/client/main.go Normal file
View 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
View 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

Binary file not shown.

View 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>

View File

@@ -0,0 +1 @@
.clear{clear:both}.nobr{white-space:nowrap}.lazy-hidden,.entry img.lazy-hidden,img.thumbnail.lazy-hidden{background-color:#fff}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 644 KiB

View 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
View 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
View File

222
docs/how_deploy_prod.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}