main
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}