main
Raw Download raw file
  1// Command govnc is a VNC client and proxy server.
  2// It connects to a VNC server over WebSocket and provides:
  3//   - Interactive terminal for typing to the VNC session
  4//   - HTTP API for programmatic control
  5//   - WebSocket proxy for noVNC browser clients
  6//   - Clipboard synchronization via files
  7package main
  8
  9import (
 10	"context"
 11	"fmt"
 12	"io"
 13	"log/slog"
 14	"os"
 15	"os/signal"
 16	"strings"
 17	"syscall"
 18
 19	"github.com/chzyer/readline"
 20	"golang.org/x/sync/errgroup"
 21
 22	"goVNC/internal/cli"
 23	"goVNC/pkg/input"
 24	"goVNC/pkg/rfb"
 25	"goVNC/pkg/transport"
 26	"goVNC/pkg/web"
 27)
 28
 29func main() {
 30	err := run()
 31	if err != nil {
 32		slog.Error("startup failed", slog.String("error", err.Error()))
 33		os.Exit(1)
 34	}
 35}
 36
 37func run() error {
 38	// Parse flags
 39	cfg, err := cli.ParseFlags(os.Args[1:])
 40	if err != nil {
 41		return err
 42	}
 43
 44	// Configure logging
 45	setupLogging(cfg.Output.Verbose, cfg.Output.Silent)
 46
 47	// Validate URL
 48	if cfg.URL == "" {
 49		return fmt.Errorf("no URL provided. Use: govnc <URL> or set VNC_URL")
 50	}
 51
 52	// Create context with signal handling
 53	ctx, cancel := context.WithCancel(context.Background())
 54	defer cancel()
 55
 56	// Handle signals
 57	sigCh := make(chan os.Signal, 1)
 58	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 59	go func() {
 60		<-sigCh
 61		slog.Info("shutting down...")
 62		cancel()
 63	}()
 64
 65	// Connect to VNC server
 66	slog.Info("connecting", slog.String("url", cfg.URL))
 67
 68	dialer := transport.NewWebSocketDialer()
 69	dialOpts := cfg.Connection.ToDialOptions()
 70
 71	t, err := dialer.Dial(ctx, cfg.URL, dialOpts)
 72	if err != nil {
 73		return fmt.Errorf("connecting: %w", err)
 74	}
 75	defer t.Close()
 76
 77	// Create RFB client
 78	clientCfg := cfg.VNC.ToClientConfig()
 79	client := rfb.NewClient(t, clientCfg)
 80
 81	// Connect and handshake
 82	session, err := client.Connect(ctx)
 83	if err != nil {
 84		return fmt.Errorf("handshake: %w", err)
 85	}
 86
 87	slog.Info("connected",
 88		slog.String("name", session.Name),
 89		slog.Int("width", int(session.Width)),
 90		slog.Int("height", int(session.Height)))
 91
 92	// Handle one-shot modes
 93	if cfg.Input.TypeText != "" {
 94		return handleTypeAndExit(ctx, client, cfg.Input.TypeText)
 95	}
 96
 97	if cfg.Clipboard.ClipSend != "" {
 98		return handleClipSendAndExit(ctx, client, cfg.Clipboard.ClipSend)
 99	}
100
101	// Server mode
102	if cfg.Server.HTTPAddr != "" {
103		return runServerMode(ctx, client, cfg)
104	}
105
106	// Interactive mode
107	return runInteractiveMode(ctx, client, cfg, session)
108}
109
110func setupLogging(verbose int, silent bool) {
111	var level slog.Level
112	if silent {
113		level = slog.LevelError + 1 // Suppress all logs
114	} else {
115		switch verbose {
116		case 0:
117			level = slog.LevelInfo
118		case 1:
119			level = slog.LevelDebug
120		default:
121			level = slog.LevelDebug - 4 // Trace level
122		}
123	}
124
125	handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
126		Level: level,
127	})
128	slog.SetDefault(slog.New(handler))
129}
130
131func handleTypeAndExit(ctx context.Context, client *rfb.Client, text string) error {
132	slog.Info("typing", slog.Int("len", len(text)))
133	err := client.Type(ctx, text)
134	if err != nil {
135		return fmt.Errorf("typing: %w", err)
136	}
137	slog.Info("done")
138	return nil
139}
140
141func handleClipSendAndExit(ctx context.Context, client *rfb.Client, text string) error {
142	// Handle @file syntax
143	if strings.HasPrefix(text, "@") {
144		data, err := os.ReadFile(text[1:])
145		if err != nil {
146			return fmt.Errorf("reading file: %w", err)
147		}
148		text = string(data)
149	}
150
151	slog.Info("sending clipboard", slog.Int("len", len(text)))
152	err := client.SetClipboard(ctx, text)
153	if err != nil {
154		return fmt.Errorf("sending clipboard: %w", err)
155	}
156	slog.Info("done")
157	return nil
158}
159
160func runServerMode(ctx context.Context, client *rfb.Client, cfg *cli.Config) error {
161	serverCfg := cfg.Server.ToServerConfig()
162	server := web.NewServer(client, serverCfg)
163
164	// Set up clipboard handler if output dir specified
165	if cfg.Clipboard.ClipOutDir != "" {
166		clipMgr, err := input.NewClipboardManager(client, &input.ClipboardConfig{
167			SaveDir: cfg.Clipboard.ClipOutDir,
168		})
169		if err != nil {
170			return fmt.Errorf("creating clipboard manager: %w", err)
171		}
172		client.OnClipboard(clipMgr.OnReceive)
173	}
174
175	g, ctx := errgroup.WithContext(ctx)
176
177	// Run VNC message listener
178	g.Go(func() error {
179		return client.Listen(ctx)
180	})
181
182	// Run web server
183	g.Go(func() error {
184		return server.ListenAndServe(ctx)
185	})
186
187	slog.Info("server mode started",
188		slog.String("http", cfg.Server.HTTPAddr),
189		slog.String("api", cfg.Server.APIPrefix))
190
191	return g.Wait()
192}
193
194func runInteractiveMode(ctx context.Context, client *rfb.Client, cfg *cli.Config, session *rfb.Session) error {
195	g, ctx := errgroup.WithContext(ctx)
196
197	// Set up clipboard manager
198	clipMgr, err := input.NewClipboardManager(client, &input.ClipboardConfig{
199		WatchDir: cfg.Clipboard.ClipInDir,
200		SaveDir:  cfg.Clipboard.ClipOutDir,
201	})
202	if err != nil {
203		return fmt.Errorf("creating clipboard manager: %w", err)
204	}
205	client.OnClipboard(clipMgr.OnReceive)
206
207	// Run VNC message listener
208	g.Go(func() error {
209		return client.Listen(ctx)
210	})
211
212	// Run clipboard watcher if configured
213	if cfg.Clipboard.ClipInDir != "" {
214		g.Go(func() error {
215			return clipMgr.Start(ctx)
216		})
217	}
218
219	// Run stdin reader
220	if cfg.Input.InputFile == "" || cfg.Input.InputFile == "-" {
221		g.Go(func() error {
222			return readStdin(ctx, client, session.Name)
223		})
224	} else if cfg.Input.InputFile != "" {
225		g.Go(func() error {
226			return readInputFile(ctx, client, cfg.Input.InputFile)
227		})
228	}
229
230	return g.Wait()
231}
232
233func readStdin(ctx context.Context, client *rfb.Client, serverName string) error {
234	prompt := serverName + "> "
235	rl, err := readline.NewEx(&readline.Config{
236		Prompt:          prompt,
237		HistoryFile:     ".govnc_history",
238		InterruptPrompt: "^C",
239		EOFPrompt:       "exit",
240	})
241	if err != nil {
242		return fmt.Errorf("creating readline: %w", err)
243	}
244	defer rl.Close()
245
246	for {
247		select {
248		case <-ctx.Done():
249			return ctx.Err()
250		default:
251		}
252
253		line, err := rl.Readline()
254		if err == readline.ErrInterrupt {
255			continue
256		}
257		if err == io.EOF {
258			return nil
259		}
260		if err != nil {
261			return fmt.Errorf("reading line: %w", err)
262		}
263
264		line = line + "\n"
265		slog.Debug("typing", slog.String("line", strings.TrimSpace(line)))
266		err = client.Type(ctx, line)
267		if err != nil {
268			return fmt.Errorf("typing line: %w", err)
269		}
270	}
271}
272
273func readInputFile(ctx context.Context, client *rfb.Client, path string) error {
274	data, err := os.ReadFile(path)
275	if err != nil {
276		return fmt.Errorf("reading input file: %w", err)
277	}
278
279	slog.Info("typing from file",
280		slog.String("file", path),
281		slog.Int("len", len(data)))
282	err = client.Type(ctx, string(data))
283	if err != nil {
284		return fmt.Errorf("typing: %w", err)
285	}
286
287	return nil
288}