Compare commits
6 Commits
aa0e07a361
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736922ca77 | ||
|
|
8e473a5bae | ||
|
|
310d7e086c | ||
|
|
b5f6f42311 | ||
|
|
481f8c431d | ||
|
|
0865aba041 |
73
Makefile
Normal file
73
Makefile
Normal file
@@ -0,0 +1,73 @@
|
||||
# Makefile for Grokway Server
|
||||
|
||||
BINARY_NAME=grokway
|
||||
INSTALL_DIR=/opt/grokway
|
||||
SERVICE_NAME=grokway.service
|
||||
SYSTEMD_DIR=/etc/systemd/system
|
||||
|
||||
.PHONY: build clean install uninstall help
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build - Build the server binary"
|
||||
@echo " make install - Install the server as a systemd service (requires root)"
|
||||
@echo " make uninstall - Remove the server and service (requires root)"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
|
||||
build:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
go build -o $(BINARY_NAME) ./cmd/server
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
rm -f $(BINARY_NAME)
|
||||
|
||||
install:
|
||||
@if [ ! -f $(BINARY_NAME) ]; then \
|
||||
echo "Error: '$(BINARY_NAME)' binary not found."; \
|
||||
echo "Please run 'make build' first as a regular user."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
# Create user if not exists
|
||||
if ! id -u grokway >/dev/null 2>&1; then \
|
||||
echo "Creating grokway user..."; \
|
||||
useradd -r -s /bin/false grokway; \
|
||||
fi
|
||||
# Create directory
|
||||
mkdir -p $(INSTALL_DIR)
|
||||
# Copy binary
|
||||
cp $(BINARY_NAME) $(INSTALL_DIR)/
|
||||
# Copy static assets (maintaining structure needed by code)
|
||||
# The code expects ./cmd/server/static relative to CWD
|
||||
mkdir -p $(INSTALL_DIR)/cmd/server
|
||||
cp -r cmd/server/static $(INSTALL_DIR)/cmd/server/
|
||||
# Set permissions
|
||||
chown -R grokway:grokway $(INSTALL_DIR)
|
||||
chmod +x $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
# Install Service
|
||||
cp $(SERVICE_NAME) $(SYSTEMD_DIR)/
|
||||
# Update paths in service file just in case they were modified
|
||||
sed -i 's|WorkingDirectory=.*|WorkingDirectory=$(INSTALL_DIR)|g' $(SYSTEMD_DIR)/$(SERVICE_NAME)
|
||||
# execStart sed commented out to preserve arguments like --ssh :2223
|
||||
# sed -i 's|ExecStart=.*|ExecStart=$(INSTALL_DIR)/$(BINARY_NAME)|g' $(SYSTEMD_DIR)/$(SERVICE_NAME)
|
||||
# Instead, ensure the binary path is correct but keep args (this is tricky with sed, simpler to rely on repo file)
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable $(SERVICE_NAME)
|
||||
systemctl start $(SERVICE_NAME)
|
||||
@echo "Installation complete. Service started."
|
||||
@echo "Important: Edit $(SYSTEMD_DIR)/$(SERVICE_NAME) to set GROKWAY_TOKEN environment variable if needed."
|
||||
@echo "Then run: systemctl daemon-reload && systemctl restart $(SERVICE_NAME)"
|
||||
@echo "Check status directly with: systemctl status $(SERVICE_NAME)"
|
||||
|
||||
uninstall:
|
||||
@echo "Uninstalling $(BINARY_NAME)..."
|
||||
systemctl stop $(SERVICE_NAME) || true
|
||||
systemctl disable $(SERVICE_NAME) || true
|
||||
rm -f $(SYSTEMD_DIR)/$(SERVICE_NAME)
|
||||
rm -rf $(INSTALL_DIR)
|
||||
# Optional: remove user
|
||||
# userdel grokway || true
|
||||
systemctl daemon-reload
|
||||
@echo "Uninstallation complete."
|
||||
@@ -35,6 +35,8 @@ func main() {
|
||||
localPort := flag.String("local", "8080", "Local port to expose")
|
||||
serverAddrFlag := flag.String("server", "", "Grokway server address")
|
||||
tokenFlag := flag.String("token", "", "Authentication token (overrides config)")
|
||||
hostHeaderFlag := flag.String("host-header", "", "Custom Host header to send to local service")
|
||||
localHttpsFlag := flag.Bool("local-https", false, "Use HTTPS to connect to local service (implied if port is 443)")
|
||||
flag.Parse()
|
||||
|
||||
// Load config
|
||||
@@ -56,7 +58,7 @@ func main() {
|
||||
serverAddr = "localhost:2222"
|
||||
}
|
||||
|
||||
m := tui.InitialModel(*localPort, serverAddr, authToken)
|
||||
m := tui.InitialModel(*localPort, serverAddr, authToken, *hostHeaderFlag, *localHttpsFlag)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
|
||||
@@ -76,9 +76,8 @@ func (tm *TunnelManager) FindSlugByConn(conn *gossh.ServerConn) (string, bool) {
|
||||
// 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"))))
|
||||
// Serve static files for 404 page (namespaced to avoid collision with user apps)
|
||||
http.Handle("/_gw/", http.StripPrefix("/_gw/", http.FileServer(http.Dir("./cmd/server/static/assets"))))
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract subdomain to identify the tunnel
|
||||
@@ -126,32 +125,30 @@ func startHttpProxy(port string) {
|
||||
// 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)
|
||||
// 1. Hijack the connection to handle bidirectional traffic (WebSockets)
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clientConn, bufrw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
payload := make([]byte, 0) // dynamic payload constructing
|
||||
// Open channel to client
|
||||
// 2. 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
|
||||
srcPort := uint32(12345)
|
||||
|
||||
payload := make([]byte, 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))
|
||||
@@ -159,99 +156,40 @@ func startHttpProxy(port string) {
|
||||
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`.
|
||||
|
||||
// 3. Browser -> Backend (Write request + Copy raw stream)
|
||||
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.
|
||||
// Write the initial request (Method, Path, Headers)
|
||||
// This sets up the handshake or request.
|
||||
// Note: We use r.Write to reconstruct the request line and headers.
|
||||
// For WebSockets, the Body is empty, so this writes headers and returns.
|
||||
// For POSTs, it writes headers and tries to copy Body.
|
||||
if err := r.Write(ch); err != nil {
|
||||
log.Printf("Error writing request to backend: %v", err)
|
||||
return
|
||||
}
|
||||
// Important: Continue copying any subsequent data (like WebSocket frames)
|
||||
// from the hijacked buffer/connection to the channel.
|
||||
io.Copy(ch, bufrw)
|
||||
// e.g. when browser closes or stops sending, we are done here.
|
||||
}()
|
||||
|
||||
// 4. Backend -> Browser (Copy raw stream)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(clientConn, ch) // Copy response from backend to user
|
||||
io.Copy(clientConn, ch)
|
||||
// When backend closes connection, close browser connection
|
||||
clientConn.Close()
|
||||
}()
|
||||
|
||||
// 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()
|
||||
|
||||
})
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
id="a3a3_lazy_load-css"
|
||||
href="./assets/a3_lazy_load.min.css"
|
||||
href="/_gw/a3_lazy_load.min.css"
|
||||
type="text/css"
|
||||
media="all"
|
||||
/>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
<link rel="stylesheet" href="/_gw/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<section class="error_404">
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<!-- Astronaut -->
|
||||
<div class="error_404_astro">
|
||||
<object data="assets/astro.svg"></object>
|
||||
<object data="/_gw/astro.svg"></object>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
31
grokway.service
Normal file
31
grokway.service
Normal file
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Grokway Server Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# User/Group to run as.
|
||||
User=grokway
|
||||
Group=grokway
|
||||
|
||||
# Working Directory. This is important because the code relies on relative paths
|
||||
# like ./cmd/server/static. If we install everything to /opt/grokway, this works.
|
||||
WorkingDirectory=/opt/grokway
|
||||
|
||||
# Path to the executable.
|
||||
ExecStart=/opt/grokway/grokway --ssh :2223
|
||||
|
||||
# Environment variables.
|
||||
# You MUST set this to a secure token, otherwise a random one is generated on each start.
|
||||
#Environment=GROKWAY_TOKEN=your-very-secure-token
|
||||
|
||||
# Restart policy.
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Standard output/error logging.
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=grokway
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -68,8 +68,8 @@ type LogMsg string
|
||||
type MetricMsg int64
|
||||
type ClearCopiedMsg struct{}
|
||||
|
||||
func InitialModel(localPort, serverAddr, authToken string) Model {
|
||||
c := tunnel.NewClient(serverAddr, localPort, authToken)
|
||||
func InitialModel(localPort, serverAddr, authToken, hostHeader string, localHTTPS bool) Model {
|
||||
c := tunnel.NewClient(serverAddr, localPort, authToken, hostHeader, localHTTPS)
|
||||
return Model{
|
||||
Client: c,
|
||||
LogLines: []string{},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -23,6 +24,8 @@ type Client struct {
|
||||
ServerAddr string
|
||||
LocalPort string
|
||||
AuthToken string
|
||||
HostHeader string // New field for custom Host header
|
||||
LocalHTTPS bool // New field: connect to local service with HTTPS
|
||||
SSHClient *ssh.Client
|
||||
Listener net.Listener
|
||||
Events chan string // Channel to send logs/events to TUI
|
||||
@@ -30,11 +33,13 @@ type Client struct {
|
||||
PublicURL string // PublicURL is the URL accessible from the internet
|
||||
}
|
||||
|
||||
func NewClient(serverAddr, localPort, authToken string) *Client {
|
||||
func NewClient(serverAddr, localPort, authToken, hostHeader string, localHTTPS bool) *Client {
|
||||
return &Client{
|
||||
ServerAddr: serverAddr,
|
||||
LocalPort: localPort,
|
||||
AuthToken: authToken,
|
||||
HostHeader: hostHeader,
|
||||
LocalHTTPS: localHTTPS,
|
||||
Events: make(chan string, 10),
|
||||
Metrics: make(chan int64, 10),
|
||||
}
|
||||
@@ -110,37 +115,104 @@ func (c *Client) handleConnection(remoteConn net.Conn) {
|
||||
defer remoteConn.Close()
|
||||
|
||||
// Dial local service
|
||||
localConn, err := net.Dial("tcp", "localhost:"+c.LocalPort)
|
||||
var localConn net.Conn
|
||||
var err error
|
||||
|
||||
// Check if we should use TLS (explicit flag or port 443)
|
||||
// You might want to strip "443" from "localhost:443" if localPort includes hostname, but user input is just port usually?
|
||||
// User input is --local :443 or 443.
|
||||
useTLS := c.LocalHTTPS
|
||||
if c.LocalPort == "443" || strings.HasSuffix(c.LocalPort, ":443") {
|
||||
useTLS = true
|
||||
}
|
||||
|
||||
if useTLS {
|
||||
conf := &tls.Config{InsecureSkipVerify: true}
|
||||
localConn, err = tls.Dial("tcp", "localhost:"+c.LocalPort, conf)
|
||||
} else {
|
||||
localConn, err = net.Dial("tcp", "localhost:"+c.LocalPort)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to dial local: %s", err)
|
||||
errMsg := fmt.Sprintf("Failed to dial local (TLS=%v): %s", useTLS, err)
|
||||
c.Events <- errMsg
|
||||
logToFile(errMsg)
|
||||
return
|
||||
}
|
||||
defer localConn.Close()
|
||||
logToFile("Dialed local service successfully")
|
||||
logToFile(fmt.Sprintf("Dialed local service successfully (TLS=%v)", useTLS))
|
||||
|
||||
// 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)
|
||||
// Read first chunk to inspect headers
|
||||
buf := make([]byte, 8192) // Increased buffer for headers
|
||||
remoteConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, err := remoteConn.Read(buf)
|
||||
if err != nil {
|
||||
remoteConn.SetReadDeadline(time.Time{}) // Reset deadline
|
||||
|
||||
if err != nil && err != io.EOF && !os.IsTimeout(err) {
|
||||
logToFile(fmt.Sprintf("Error reading from remote: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload := string(buf[:n])
|
||||
|
||||
// Try to parse rudimentary HTTP
|
||||
// If it looks like HTTP, rewrite Host and add Proto headers
|
||||
if strings.Contains(payload, "HTTP/") {
|
||||
lines := strings.Split(payload, "\r\n")
|
||||
var newLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(line), "host:") {
|
||||
if c.HostHeader != "" {
|
||||
newLines = append(newLines, "Host: "+c.HostHeader)
|
||||
} else {
|
||||
// Default to localhost handling: keep original or set to localhost?
|
||||
// Usually localhost:port is safest if user didn't specify.
|
||||
newLines = append(newLines, "Host: localhost:"+c.LocalPort)
|
||||
}
|
||||
continue
|
||||
}
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// Insert X-Forwarded-Proto if missing (prevents redirects loop)
|
||||
if !strings.Contains(strings.ToLower(payload), "x-forwarded-proto:") {
|
||||
// Find end of headers (empty line)
|
||||
for i, line := range newLines {
|
||||
if line == "" { // End of headers
|
||||
// Insert before the empty line
|
||||
finalHeaders := append(newLines[:i], "X-Forwarded-Proto: https")
|
||||
finalHeaders = append(finalHeaders, newLines[i:]...)
|
||||
newLines = finalHeaders
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modifiedPayload := strings.Join(newLines, "\r\n")
|
||||
|
||||
// Log for debug
|
||||
logToFile("Rewritten Headers:\n" + modifiedPayload)
|
||||
|
||||
// Send modified headers
|
||||
localConn.Write([]byte(modifiedPayload))
|
||||
} else {
|
||||
// Not HTTP or couldn't parse, just forward as comes
|
||||
localConn.Write(buf[:n])
|
||||
}
|
||||
|
||||
// Try to parse rudimentary HTTP for TUI logs
|
||||
// Format: METHOD PATH PROTOCOL
|
||||
|
||||
// We use the payload we just processed (or the original if not modified? No, logs should show original request usually,
|
||||
// but here we have the payload string variable already.)
|
||||
|
||||
lines := strings.Split(payload, "\n")
|
||||
if len(lines) > 0 {
|
||||
firstLine := lines[0]
|
||||
@@ -149,19 +221,14 @@ func (c *Client) handleConnection(remoteConn net.Conn) {
|
||||
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
|
||||
c.Events <- fmt.Sprintf("HTTP|%s|%s|%d", method, path, 200)
|
||||
} 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
|
||||
|
||||
Reference in New Issue
Block a user