diff --git a/server/internal/services/consoleproxy.go b/server/internal/services/consoleproxy.go new file mode 100644 index 0000000..f960c6a --- /dev/null +++ b/server/internal/services/consoleproxy.go @@ -0,0 +1,122 @@ +package services + +import ( + "errors" + "fmt" + "log" + "net" + "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy" +) + +// ErrAgentOffline means the console cannot be opened because the target's agent +// is not on the command stream. Every console session is relayed by the agent, +// so this is fatal rather than a degraded mode. +var ErrAgentOffline = errors.New("agent is not connected") + +// ConsoleProxy is a pending relay: a bound listener guacd can dial and a +// dispatched command telling the agent to meet it. +type ConsoleProxy struct { + ProxyID string + Host string + Port int + + session *proxy.Session +} + +func (c *ConsoleProxy) Close() { + proxy.Default.Remove(c.ProxyID) + c.session.Close("") +} + +func (c *ConsoleProxy) Reason() string { return c.session.Reason() } + +func proxyListenHost() string { + if v := os.Getenv("PROXY_LISTEN_HOST"); v != "" { + return v + } + return "0.0.0.0" +} + +func proxyAdvertiseHost() string { + if v := os.Getenv("PROXY_ADVERTISE_HOST"); v != "" { + return v + } + return "server" +} + +func guacdAddr() string { + if v := os.Getenv("GUACD_ADDR"); v != "" { + return v + } + return "guacd:4822" +} + +// guacdHosts resolves guacd's address to the IPs allowed to claim a relay +// listener. An unresolvable host yields an empty set, which allows any source: +// refusing everything would take the console down entirely, so the narrower +// protections (ephemeral port, 10s window, single accept) carry it instead. +func guacdHosts(addr string) []string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if ip := net.ParseIP(host); ip != nil { + return []string{ip.String()} + } + ips, err := net.LookupHost(host) + if err != nil { + log.Printf("proxy: cannot resolve guacd host %q, allowing any relay source: %v", host, err) + return nil + } + return ips +} + +// DispatchOpenProxy tells the agent to dial its own loopback on port and relay +// it back under proxyID. +func DispatchOpenProxy(serverID, proxyID string, port uint32) error { + return Dispatcher.dispatch(serverID, &pb.ServerCommand{ + CommandId: proxyID, + OpenProxy: &pb.OpenProxyCmd{ProxyId: proxyID, Port: port}, + }) +} + +// OpenConsoleProxy binds a relay listener, registers it, and asks the agent to +// connect. The caller must Close the result. +func OpenConsoleProxy(instanceID, serverID string, targetPort int) (*ConsoleProxy, error) { + if !Dispatcher.IsConnected(serverID) { + return nil, ErrAgentOffline + } + + proxyID, err := proxy.NewID() + if err != nil { + return nil, fmt.Errorf("generate proxy id: %w", err) + } + + sess, err := proxy.NewSession(proxyListenHost(), guacdHosts(guacdAddr())) + if err != nil { + return nil, err + } + + proxy.Default.Add(&proxy.Entry{ + ProxyID: proxyID, + InstanceID: instanceID, + ServerID: serverID, + Session: sess, + }) + + if err := DispatchOpenProxy(serverID, proxyID, uint32(targetPort)); err != nil { + proxy.Default.Remove(proxyID) + sess.Close("dispatch_failed") + return nil, err + } + + return &ConsoleProxy{ + ProxyID: proxyID, + Host: proxyAdvertiseHost(), + Port: sess.Port(), + session: sess, + }, nil +}