IRC support

This commit is contained in:
Andy Dixon
2026-06-17 08:55:56 +01:00
parent 0e6f54901b
commit e1381e66d7
24 changed files with 2729 additions and 51 deletions
+47
View File
@@ -393,6 +393,7 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
if profile.ID == "" {
profile.ID = uuid.New().String()
}
normaliseProfileForSave(&profile)
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
if err != nil {
return models.SessionInfo{}, err
@@ -527,6 +528,52 @@ func (s *Service) SendBreak(sessionID string) error {
return s.sessions.SendBreak(sessionID)
}
// ---------------------------------------------------------------------------
// IRC-specific
// ---------------------------------------------------------------------------
// IRCHandleInput sends an IRC composer line, including slash commands.
func (s *Service) IRCHandleInput(sessionID string, target string, input string) error {
return s.sessions.IRCHandleInput(sessionID, target, input)
}
// IRCJoin joins an IRC room.
func (s *Service) IRCJoin(sessionID string, room string) error {
return s.sessions.IRCJoin(sessionID, room)
}
// IRCPart leaves an IRC room.
func (s *Service) IRCPart(sessionID string, room string) error {
return s.sessions.IRCPart(sessionID, room)
}
// IRCListRooms requests the server room list. Results are emitted as events.
func (s *Service) IRCListRooms(sessionID string) error {
return s.sessions.IRCListRooms(sessionID)
}
// IRCRooms returns currently joined IRC rooms.
func (s *Service) IRCRooms(sessionID string) ([]map[string]interface{}, error) {
rooms, err := s.sessions.IRCRooms(sessionID)
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(rooms))
for i, room := range rooms {
result[i] = map[string]interface{}{
"name": room.Name,
"topic": room.Topic,
"users": room.Users,
}
}
return result, nil
}
// IRCUsers returns the known users in a joined IRC room.
func (s *Service) IRCUsers(sessionID string, room string) ([]string, error) {
return s.sessions.IRCUsers(sessionID, room)
}
// ---------------------------------------------------------------------------
// Secret store
// ---------------------------------------------------------------------------
+767
View File
@@ -0,0 +1,767 @@
// Package ircclient wraps a full IRC client with Nexus-specific event and
// command handling, including optional SSH tunnelling.
package ircclient
import (
"crypto/tls"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/lrstanley/girc"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
const ServerTarget = "server"
const maxRoomListEntries = 2000
// Config describes an IRC connection.
type Config struct {
Server string
Port int
Nick string
User string
RealName string
UseTLS bool
ServerPass string
AutoJoinRooms []string
NickServIdentify bool
NickServAccount string
NickServPassword string
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHHostKeyCallback ssh.HostKeyCallback
ConnectTimeout time.Duration
OnEvent func(Event)
OnError func(error)
OnClose func()
}
// Event is a typed IRC event suitable for JSON delivery to the frontend.
type Event struct {
Type string `json:"type"`
Target string `json:"target,omitempty"`
From string `json:"from,omitempty"`
Text string `json:"text,omitempty"`
Command string `json:"command,omitempty"`
Timestamp int64 `json:"timestamp"`
Rooms []RoomInfo `json:"rooms,omitempty"`
Users []string `json:"users,omitempty"`
Topic string `json:"topic,omitempty"`
}
// RoomInfo describes a joined or listed IRC room.
type RoomInfo struct {
Name string `json:"name"`
Topic string `json:"topic,omitempty"`
Users int `json:"users,omitempty"`
}
// Client owns a girc client and optional SSH tunnel.
type Client struct {
cfg Config
client *girc.Client
sshClient *ssh.Client
agentConn net.Conn
mu sync.Mutex
connected bool
closing bool
listRooms []RoomInfo
}
// New creates an IRC client wrapper.
func New(cfg Config) *Client {
if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = 30 * time.Second
}
if cfg.User == "" {
cfg.User = cfg.Nick
}
if cfg.RealName == "" {
cfg.RealName = cfg.Nick
}
c := &Client{cfg: cfg}
c.client = girc.New(girc.Config{
Server: cfg.Server,
ServerPass: cfg.ServerPass,
Port: cfg.Port,
Nick: cfg.Nick,
User: cfg.User,
Name: cfg.RealName,
SSL: cfg.UseTLS && !cfg.SSHTunnel,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: cfg.Server,
},
AllowFlood: false,
})
c.registerHandlers()
return c
}
// Connect starts the IRC connection and returns once registration succeeds.
func (c *Client) Connect() error {
connectedCh := make(chan struct{}, 1)
errCh := make(chan error, 1)
var connectedOnce sync.Once
c.client.Handlers.Add(girc.CONNECTED, func(gc *girc.Client, e girc.Event) {
c.mu.Lock()
c.connected = true
c.mu.Unlock()
c.emit(Event{
Type: "system",
Target: ServerTarget,
Text: fmt.Sprintf("Connected to %s", gc.Server()),
Timestamp: eventTime(e),
})
c.identifyWithNickServ(gc)
if len(c.cfg.AutoJoinRooms) > 0 {
gc.Cmd.Join(c.cfg.AutoJoinRooms...)
}
c.emitRooms()
connectedOnce.Do(func() {
select {
case connectedCh <- struct{}{}:
default:
}
})
})
go func() {
var err error
if c.cfg.SSHTunnel {
dialer, dialerErr := c.newSSHDialer()
if dialerErr != nil {
select {
case errCh <- dialerErr:
default:
}
return
}
err = c.client.DialerConnect(dialer)
} else {
err = c.client.Connect()
}
c.mu.Lock()
wasClosing := c.closing
wasConnected := c.connected
c.connected = false
c.mu.Unlock()
if err != nil && !wasClosing {
c.emit(Event{
Type: "error",
Target: ServerTarget,
Text: err.Error(),
Timestamp: time.Now().UnixMilli(),
})
if c.cfg.OnError != nil {
c.cfg.OnError(err)
}
select {
case errCh <- err:
default:
}
}
if wasConnected && !wasClosing && c.cfg.OnClose != nil {
c.cfg.OnClose()
}
}()
select {
case <-connectedCh:
return nil
case err := <-errCh:
return err
case <-time.After(c.cfg.ConnectTimeout):
c.Close()
return fmt.Errorf("IRC connection timed out after %s", c.cfg.ConnectTimeout)
}
}
// Close disconnects from IRC and closes any SSH tunnel resources.
func (c *Client) Close() {
c.mu.Lock()
if c.closing {
c.mu.Unlock()
return
}
c.closing = true
c.mu.Unlock()
if c.client != nil {
c.client.Quit("Nexus Terminal disconnected")
c.client.Close()
}
if c.sshClient != nil {
_ = c.sshClient.Close()
c.sshClient = nil
}
if c.agentConn != nil {
_ = c.agentConn.Close()
c.agentConn = nil
}
}
// HandleInput parses a user-entered IRC composer line.
func (c *Client) HandleInput(target string, input string) error {
text := strings.TrimSpace(input)
if text == "" {
return nil
}
if target == "" {
target = ServerTarget
}
if !strings.HasPrefix(text, "/") || text == "/" {
if target == ServerTarget {
return fmt.Errorf("select a room or use /msg <nick> <message>")
}
c.client.Cmd.Message(target, text)
c.emitLocalMessage("message", target, text)
return nil
}
commandLine := strings.TrimSpace(strings.TrimPrefix(text, "/"))
if commandLine == "" {
return nil
}
fields := strings.Fields(commandLine)
cmd := strings.ToLower(fields[0])
args := strings.TrimSpace(commandLine[len(fields[0]):])
switch cmd {
case "me", "action":
if target == ServerTarget {
return fmt.Errorf("/me requires an active room or private tab")
}
if args == "" {
return fmt.Errorf("/me requires an action")
}
c.client.Cmd.Action(target, args)
c.emitLocalMessage("action", target, args)
case "msg", "query":
recipient, body, ok := splitFirstArg(args)
if !ok || body == "" {
return fmt.Errorf("/msg requires a target and message")
}
c.client.Cmd.Message(recipient, body)
c.emitLocalMessage("message", recipient, body)
case "notice":
recipient, body, ok := splitFirstArg(args)
if !ok || body == "" {
return fmt.Errorf("/notice requires a target and message")
}
c.client.Cmd.Notice(recipient, body)
c.emitLocalMessage("notice", recipient, body)
case "join", "j":
room, key, ok := splitFirstArg(args)
if !ok {
return fmt.Errorf("/join requires a room")
}
if key != "" {
c.client.Cmd.JoinKey(room, key)
} else {
c.client.Cmd.Join(room)
}
case "part", "leave":
room, msg, _ := splitFirstArg(args)
if room == "" {
room = target
}
if room == "" || room == ServerTarget {
return fmt.Errorf("/part requires a room")
}
if msg != "" {
c.client.Cmd.PartMessage(room, msg)
} else {
c.client.Cmd.Part(room)
}
case "quit":
if args == "" {
args = "Nexus Terminal disconnected"
}
c.client.Quit(args)
c.client.Close()
case "nick":
if args == "" {
return fmt.Errorf("/nick requires a nickname")
}
c.client.Cmd.Nick(strings.Fields(args)[0])
case "topic":
room, topic, _ := splitFirstArg(args)
if room == "" {
room = target
}
if room == "" || room == ServerTarget {
return fmt.Errorf("/topic requires a room")
}
c.client.Cmd.Topic(room, topic)
case "list":
if args == "" {
c.client.Cmd.List()
} else {
c.client.Cmd.List(strings.Fields(args)...)
}
case "names":
room := args
if room == "" && target != ServerTarget {
room = target
}
if room == "" {
return c.client.Cmd.SendRaw("NAMES")
}
return c.client.Cmd.SendRaw("NAMES " + room)
case "whois":
if args == "" {
return fmt.Errorf("/whois requires a nickname")
}
c.client.Cmd.Whois(strings.Fields(args)...)
case "who":
if args == "" {
if target == ServerTarget {
return fmt.Errorf("/who requires a target")
}
args = target
}
c.client.Cmd.Who(strings.Fields(args)...)
case "raw", "quote":
if args == "" {
return fmt.Errorf("/raw requires an IRC command")
}
return c.client.Cmd.SendRawNoSplit(args)
case "help":
c.emit(Event{
Type: "system",
Target: target,
Text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit",
Timestamp: time.Now().UnixMilli(),
})
default:
raw := strings.ToUpper(fields[0])
if args != "" {
raw += " " + args
}
return c.client.Cmd.SendRawNoSplit(raw)
}
return nil
}
// Join requests a room join.
func (c *Client) Join(room string) error {
room = strings.TrimSpace(room)
if room == "" {
return fmt.Errorf("room is required")
}
c.client.Cmd.Join(room)
return nil
}
// Part leaves a room.
func (c *Client) Part(room string) error {
room = strings.TrimSpace(room)
if room == "" {
return fmt.Errorf("room is required")
}
c.client.Cmd.Part(room)
return nil
}
// ListRooms requests a server-side room list. Results arrive as events.
func (c *Client) ListRooms() error {
c.mu.Lock()
c.listRooms = nil
c.mu.Unlock()
c.client.Cmd.List()
return nil
}
// Rooms returns currently joined rooms.
func (c *Client) Rooms() []RoomInfo {
if c.client == nil {
return nil
}
channels := c.client.Channels()
rooms := make([]RoomInfo, 0, len(channels))
for _, ch := range channels {
if ch == nil {
continue
}
rooms = append(rooms, RoomInfo{
Name: ch.Name,
Topic: ch.Topic,
Users: len(ch.UserList),
})
}
sort.Slice(rooms, func(i, j int) bool {
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
})
return rooms
}
// Users returns known users in a room.
func (c *Client) Users(room string) []string {
if c.client == nil {
return nil
}
ch := c.client.LookupChannel(room)
if ch == nil {
return []string{}
}
users := append([]string{}, ch.UserList...)
sort.Slice(users, func(i, j int) bool {
return strings.TrimLeft(users[i], "~&@%+") < strings.TrimLeft(users[j], "~&@%+")
})
return users
}
func (c *Client) registerHandlers() {
c.client.Handlers.Add(girc.PRIVMSG, c.handleMessage)
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
c.client.Handlers.Add(girc.PART, c.handlePart)
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
c.client.Handlers.Add(girc.NICK, c.handleNick)
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
c.client.Handlers.Add(girc.RPL_TOPIC, c.handleTopic)
c.client.Handlers.Add(girc.RPL_NAMREPLY, c.handleNames)
c.client.Handlers.Add(girc.RPL_ENDOFNAMES, c.handleEndOfNames)
c.client.Handlers.Add(girc.RPL_LIST, c.handleList)
c.client.Handlers.Add(girc.RPL_LISTEND, c.handleListEnd)
c.client.Handlers.Add(girc.ERROR, func(_ *girc.Client, e girc.Event) {
c.emit(Event{Type: "error", Target: ServerTarget, Text: e.Last(), Timestamp: eventTime(e)})
})
}
func (c *Client) handleMessage(gc *girc.Client, e girc.Event) {
if len(e.Params) < 2 || e.Source == nil {
return
}
target := e.Params[0]
if strings.EqualFold(target, gc.GetNick()) {
target = e.Source.Name
}
eventType := "message"
text := e.Last()
if e.IsAction() {
eventType = "action"
text = e.StripAction()
}
c.emit(Event{Type: eventType, Target: target, From: e.Source.Name, Text: text, Timestamp: eventTime(e)})
}
func (c *Client) handleNotice(gc *girc.Client, e girc.Event) {
target := ServerTarget
if len(e.Params) > 0 {
target = e.Params[0]
if target == "*" {
target = ServerTarget
}
if strings.EqualFold(target, gc.GetNick()) && e.Source != nil {
target = e.Source.Name
}
}
from := ""
if e.Source != nil {
from = e.Source.Name
}
c.emit(Event{Type: "notice", Target: target, From: from, Text: e.Last(), Timestamp: eventTime(e)})
}
func (c *Client) handleJoin(gc *girc.Client, e girc.Event) {
room := eventRoom(e)
if room == "" {
return
}
from := sourceName(e)
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
c.emitRooms()
c.emitUsers(room)
}
func (c *Client) handlePart(gc *girc.Client, e girc.Event) {
room := eventRoom(e)
if room == "" {
return
}
from := sourceName(e)
message := ""
if len(e.Params) > 1 {
message = strings.TrimSpace(e.Last())
}
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
c.emitRooms()
c.emitUsers(room)
}
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
from := sourceName(e)
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
c.emitUsersForAll()
}
func (c *Client) handleNick(gc *girc.Client, e girc.Event) {
from := sourceName(e)
newNick := e.Last()
c.emit(Event{Type: "nick", Target: ServerTarget, From: from, Text: newNick, Timestamp: eventTime(e)})
c.emitUsersForAll()
}
func (c *Client) handleTopic(gc *girc.Client, e girc.Event) {
room := ""
topic := e.Last()
if e.Command == girc.RPL_TOPIC && len(e.Params) >= 3 {
room = e.Params[1]
topic = e.Params[2]
} else if len(e.Params) >= 2 {
room = e.Params[0]
topic = e.Params[1]
}
if room == "" {
return
}
c.emit(Event{Type: "topic", Target: room, From: sourceName(e), Text: topic, Topic: topic, Timestamp: eventTime(e)})
c.emitRooms()
}
func (c *Client) handleNames(gc *girc.Client, e girc.Event) {
if len(e.Params) >= 4 {
c.emit(Event{Type: "users", Target: e.Params[2], Users: splitNames(e.Params[3]), Timestamp: eventTime(e)})
}
}
func (c *Client) handleEndOfNames(gc *girc.Client, e girc.Event) {
if len(e.Params) >= 2 {
c.emitUsers(e.Params[1])
}
}
func (c *Client) handleList(gc *girc.Client, e girc.Event) {
if len(e.Params) < 4 {
return
}
users, _ := strconv.Atoi(e.Params[2])
room := RoomInfo{Name: e.Params[1], Users: users, Topic: e.Params[3]}
c.mu.Lock()
if len(c.listRooms) < maxRoomListEntries {
c.listRooms = append(c.listRooms, room)
}
c.mu.Unlock()
}
func (c *Client) handleListEnd(gc *girc.Client, e girc.Event) {
c.mu.Lock()
rooms := append([]RoomInfo{}, c.listRooms...)
c.listRooms = nil
c.mu.Unlock()
sort.Slice(rooms, func(i, j int) bool {
if rooms[i].Users == rooms[j].Users {
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
}
return rooms[i].Users > rooms[j].Users
})
c.emit(Event{Type: "room-list", Target: ServerTarget, Rooms: rooms, Timestamp: eventTime(e)})
}
func (c *Client) identifyWithNickServ(gc *girc.Client) {
if !c.cfg.NickServIdentify || c.cfg.NickServPassword == "" {
return
}
if c.cfg.NickServAccount != "" {
gc.Cmd.Message("NickServ", fmt.Sprintf("IDENTIFY %s %s", c.cfg.NickServAccount, c.cfg.NickServPassword))
} else {
gc.Cmd.Message("NickServ", "IDENTIFY "+c.cfg.NickServPassword)
}
c.emit(Event{Type: "system", Target: ServerTarget, Text: "Sent NickServ identify request", Timestamp: time.Now().UnixMilli()})
}
func (c *Client) newSSHDialer() (*sshDialer, error) {
if c.cfg.SSHHostKeyCallback == nil {
return nil, fmt.Errorf("SSH host key callback is required")
}
authMethods, err := c.sshAuthMethods()
if err != nil {
return nil, err
}
sshAddr := fmt.Sprintf("%s:%d", c.cfg.SSHHost, c.cfg.SSHPort)
sshConfig := &ssh.ClientConfig{
User: c.cfg.SSHUsername,
Auth: authMethods,
HostKeyCallback: c.cfg.SSHHostKeyCallback,
Timeout: c.cfg.ConnectTimeout,
}
sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return nil, fmt.Errorf("SSH connection failed: %w", err)
}
c.sshClient = sshClient
return &sshDialer{
client: sshClient,
useTLS: c.cfg.UseTLS,
serverName: c.cfg.Server,
timeout: c.cfg.ConnectTimeout,
}, nil
}
func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) {
var authMethods []ssh.AuthMethod
if c.cfg.SSHAgentAuth && c.cfg.SSHAgentConn != nil {
c.agentConn = c.cfg.SSHAgentConn
ag := agent.NewClient(c.cfg.SSHAgentConn)
signers, err := ag.Signers()
if err == nil && len(signers) > 0 {
authMethods = append(authMethods, ssh.PublicKeys(signers...))
}
}
if len(c.cfg.SSHPrivateKey) > 0 {
var signer ssh.Signer
var err error
if c.cfg.SSHPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(c.cfg.SSHPrivateKey, []byte(c.cfg.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(c.cfg.SSHPrivateKey)
}
if err != nil {
return nil, fmt.Errorf("parsing SSH private key: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
if c.cfg.SSHPassword != "" {
authMethods = append(authMethods, ssh.Password(c.cfg.SSHPassword))
}
if len(authMethods) == 0 {
return nil, fmt.Errorf("no SSH authentication method available")
}
return authMethods, nil
}
func (c *Client) emitRooms() {
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emitUsers(room string) {
if room == "" {
return
}
c.emit(Event{Type: "users", Target: room, Users: c.Users(room), Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emitUsersForAll() {
for _, room := range c.Rooms() {
c.emitUsers(room.Name)
}
}
func (c *Client) emitLocalMessage(eventType string, target string, text string) {
c.emit(Event{Type: eventType, Target: target, From: c.client.GetNick(), Text: text, Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emit(event Event) {
if event.Timestamp == 0 {
event.Timestamp = time.Now().UnixMilli()
}
if event.Target == "" {
event.Target = ServerTarget
}
if c.cfg.OnEvent != nil {
c.cfg.OnEvent(event)
}
}
type sshDialer struct {
client *ssh.Client
useTLS bool
serverName string
timeout time.Duration
}
func (d *sshDialer) Dial(network, address string) (net.Conn, error) {
conn, err := d.client.Dial(network, address)
if err != nil {
return nil, err
}
if !d.useTLS {
return conn, nil
}
tlsConn := tls.Client(conn, &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: d.serverName,
})
if d.timeout > 0 {
_ = tlsConn.SetDeadline(time.Now().Add(d.timeout))
}
if err := tlsConn.Handshake(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("IRC TLS handshake failed: %w", err)
}
_ = tlsConn.SetDeadline(time.Time{})
return tlsConn, nil
}
func eventTime(e girc.Event) int64 {
if e.Timestamp.IsZero() {
return time.Now().UnixMilli()
}
return e.Timestamp.UnixMilli()
}
func eventRoom(e girc.Event) string {
if len(e.Params) == 0 {
return ""
}
return e.Params[0]
}
func sourceName(e girc.Event) string {
if e.Source == nil {
return ""
}
return e.Source.Name
}
func splitFirstArg(input string) (first string, rest string, ok bool) {
fields := strings.Fields(input)
if len(fields) == 0 {
return "", "", false
}
first = fields[0]
rest = strings.TrimSpace(input[len(first):])
return first, rest, true
}
func splitNames(input string) []string {
fields := strings.Fields(input)
names := make([]string, 0, len(fields))
for _, name := range fields {
name = strings.TrimSpace(name)
if name != "" {
names = append(names, name)
}
}
return names
}
+47
View File
@@ -13,6 +13,8 @@ const (
DefaultRAWPort = 23
DefaultGitHubPort = 443
DefaultVNCPort = 5900
DefaultIRCPort = 6667
DefaultIRCTLSPort = 6697
)
// Default timeout values (in seconds).
@@ -122,6 +124,19 @@ func NewProfile(protocol Protocol) Profile {
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
}
case ProtocolIRC:
p.Port = DefaultIRCPort
p.IRC = &IRCConfig{
Nick: "nexus",
RealName: "Nexus Terminal",
ServerPasswordMode: SecretModeAskEveryTime,
NickServMode: SecretModeSaved,
AutoJoinRooms: []string{},
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
AutoReconnect: true,
}
}
return p
@@ -186,6 +201,8 @@ func NormalizeProfileDefaults(p *Profile) {
normalizeGitHubConfig(p, defaults.GitHub)
case ProtocolVNC:
normalizeVNCConfig(p, defaults.VNC)
case ProtocolIRC:
normalizeIRCConfig(p, defaults.IRC)
}
}
@@ -201,6 +218,8 @@ func defaultProfileName(p *Profile) string {
return "GitHub"
case ProtocolVNC:
return "VNC"
case ProtocolIRC:
return "IRC"
case ProtocolRAW:
return "RAW TCP"
case ProtocolTelnet:
@@ -340,3 +359,31 @@ func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
}
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
}
func normalizeIRCConfig(p *Profile, defaults *IRCConfig) {
if p.IRC == nil {
cfg := *defaults
cfg.AutoJoinRooms = append([]string{}, defaults.AutoJoinRooms...)
p.IRC = &cfg
return
}
cfg := p.IRC
if cfg.Nick == "" {
cfg.Nick = defaults.Nick
}
if cfg.RealName == "" {
cfg.RealName = defaults.RealName
}
cfg.ServerPasswordMode = normalizeSecretMode(cfg.ServerPasswordMode, defaults.ServerPasswordMode)
cfg.NickServMode = normalizeSecretMode(cfg.NickServMode, defaults.NickServMode)
if cfg.AutoJoinRooms == nil {
cfg.AutoJoinRooms = []string{}
}
if cfg.SSHPort == 0 {
cfg.SSHPort = defaults.SSHPort
}
if cfg.SSHAuthMethod == "" {
cfg.SSHAuthMethod = defaults.SSHAuthMethod
}
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
}
+71 -29
View File
@@ -13,6 +13,7 @@ const (
ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
ProtocolIRC Protocol = "irc"
ProtocolLocal Protocol = "local"
)
@@ -85,6 +86,9 @@ type Profile struct {
// VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"`
// IRC-specific configuration
IRC *IRCConfig `json:"irc,omitempty"`
}
// SSHConfig holds SSH-specific settings for a connection profile.
@@ -102,9 +106,9 @@ type SSHConfig struct {
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
ProxyHost string `json:"proxyHost"`
ProxyPort int `json:"proxyPort"`
ProxyUsername string `json:"proxyUsername"`
@@ -144,12 +148,12 @@ const (
type PortForward struct {
ID string `json:"id"`
Direction PortForwardDirection `json:"direction"`
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
BindPort int `json:"bindPort"`
DestHost string `json:"destHost"` // target address to forward to
DestHost string `json:"destHost"` // target address to forward to
DestPort int `json:"destPort"`
Enabled bool `json:"enabled"` // whether to activate on connect
Label string `json:"label"` // optional descriptive label
Enabled bool `json:"enabled"` // whether to activate on connect
Label string `json:"label"` // optional descriptive label
}
// TelnetConfig holds Telnet-specific settings for a connection profile.
@@ -162,7 +166,7 @@ type TelnetConfig struct {
LineMode bool `json:"lineMode"`
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
}
// RAWConfig holds RAW TCP-specific settings for a connection profile.
@@ -221,33 +225,71 @@ type VNCConfig struct {
SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
}
// IRCConfig holds IRC-specific settings for a connection profile.
type IRCConfig struct {
// Nick is the primary nickname used when connecting to the IRC network.
Nick string `json:"nick"`
// RealName is the user-facing realname/gecos sent during registration.
RealName string `json:"realName"`
// UseTLS enables TLS for the IRC server connection.
UseTLS bool `json:"useTLS"`
// ServerPasswordRequired controls whether to prompt for an IRC server PASS secret.
ServerPasswordRequired bool `json:"serverPasswordRequired"`
ServerPasswordMode SecretMode `json:"serverPasswordMode"`
// NickServ settings. The password is resolved through the secret store.
NickServIdentify bool `json:"nickServIdentify"`
NickServAccount string `json:"nickServAccount"`
NickServMode SecretMode `json:"nickServMode"`
// AutoJoinRooms are joined automatically after registration.
AutoJoinRooms []string `json:"autoJoinRooms"`
// SSH tunnel settings — if enabled, IRC traffic is tunnelled through SSH.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
// Reconnect policy for unexpected disconnects.
AutoReconnect bool `json:"autoReconnect"`
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
}
// GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct {
FullName string `json:"fullName"`
Name string `json:"name"`
Owner string `json:"owner"`
Description string `json:"description"`
HTMLURL string `json:"htmlURL"`
DefaultBranch string `json:"defaultBranch"`
FullName string `json:"fullName"`
Name string `json:"name"`
Owner string `json:"owner"`
Description string `json:"description"`
HTMLURL string `json:"htmlURL"`
DefaultBranch string `json:"defaultBranch"`
LatestCommitter string `json:"latestCommitter"`
LatestCommitAt string `json:"latestCommitAt"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stars"`
PushedAt string `json:"pushedAt"` // ISO 8601
UpdatedAt string `json:"updatedAt"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stars"`
PushedAt string `json:"pushedAt"` // ISO 8601
UpdatedAt string `json:"updatedAt"`
}
// SessionState represents the lifecycle state of a connection session.
+227 -3
View File
@@ -20,6 +20,7 @@ import (
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient"
"github.com/andydixon/nexus/internal/ircclient"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/rawclient"
@@ -32,7 +33,7 @@ import (
"github.com/andydixon/nexus/internal/vncclient"
)
const maxVNCTunnelPrivateKeyBytes = 1 << 20
const maxTunnelPrivateKeyBytes = 1 << 20
// Session wraps a protocol client with metadata and state.
type Session struct {
@@ -43,6 +44,7 @@ type Session struct {
githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
localClient *localshell.Client // local shell PTY, nil for non-local sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile
@@ -139,6 +141,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
err = m.connectGitHub(sess, getSecret)
case models.ProtocolVNC:
err = m.connectVNC(sess, getSecret)
case models.ProtocolIRC:
err = m.connectIRC(sess, getSecret)
default:
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
}
@@ -539,6 +543,74 @@ func (m *Manager) SendBreak(sessionID string) error {
return sess.telnetClient.SendBreak()
}
// IRCHandleInput sends a user-entered composer line to an IRC session.
func (m *Manager) IRCHandleInput(sessionID string, target string, input string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.HandleInput(target, input)
}
// IRCJoin joins an IRC room.
func (m *Manager) IRCJoin(sessionID string, room string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.Join(room)
}
// IRCPart leaves an IRC room.
func (m *Manager) IRCPart(sessionID string, room string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.Part(room)
}
// IRCListRooms requests a server-side IRC room list.
func (m *Manager) IRCListRooms(sessionID string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.ListRooms()
}
// IRCRooms returns currently joined IRC rooms.
func (m *Manager) IRCRooms(sessionID string) ([]ircclient.RoomInfo, error) {
client, err := m.getIRC(sessionID)
if err != nil {
return nil, err
}
return client.Rooms(), nil
}
// IRCUsers returns known users in an IRC room.
func (m *Manager) IRCUsers(sessionID string, room string) ([]string, error) {
client, err := m.getIRC(sessionID)
if err != nil {
return nil, err
}
return client.Users(room), nil
}
func (m *Manager) getIRC(sessionID string) (*ircclient.Client, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.ircClient == nil {
return nil, fmt.Errorf("sessions: session %q is not an IRC connection", sessionID)
}
return sess.ircClient, nil
}
// WriteSessionText sends text with line ending via the RAW client.
func (m *Manager) WriteSessionText(sessionID string, text string) error {
m.mu.RLock()
@@ -1040,8 +1112,8 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
if info.IsDir() {
return fmt.Errorf("SSH key path is a directory")
}
if info.Size() > maxVNCTunnelPrivateKeyBytes {
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxVNCTunnelPrivateKeyBytes)
if info.Size() > maxTunnelPrivateKeyBytes {
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
}
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
if err != nil {
@@ -1091,6 +1163,139 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
return nil
}
// connectIRC establishes an IRC connection, optionally through an SSH tunnel,
// and forwards typed IRC events to the frontend.
func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (string, error)) error {
profile := sess.profile
ircConf := profile.IRC
if ircConf == nil {
return fmt.Errorf("IRC configuration missing")
}
serverPass := ""
if ircConf.ServerPasswordRequired {
getPass := m.buildSecretCallback(profile.ID, "irc-server-password", ircConf.ServerPasswordMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("IRC server password: %w", err)
}
serverPass = password
}
nickServPass := ""
if ircConf.NickServIdentify {
getPass := m.buildSecretCallback(profile.ID, "irc-nickserv-password", ircConf.NickServMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("NickServ password: %w", err)
}
nickServPass = password
}
cfg := ircclient.Config{
Server: profile.Host,
Port: profile.Port,
Nick: ircConf.Nick,
User: profile.Username,
RealName: ircConf.RealName,
UseTLS: ircConf.UseTLS,
ServerPass: serverPass,
AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...),
NickServIdentify: ircConf.NickServIdentify,
NickServAccount: ircConf.NickServAccount,
NickServPassword: nickServPass,
ConnectTimeout: time.Duration(profile.ConnectTimeout) * time.Second,
OnEvent: func(event ircclient.Event) { m.emitIRCEvent(sess.Info.ID, event) },
OnError: func(err error) { m.markSessionError(sess, err) },
OnClose: func() { m.handleSessionClosed(sess.Info.ID) },
SSHTunnel: ircConf.SSHTunnel,
SSHHost: ircConf.SSHHost,
SSHPort: ircConf.SSHPort,
SSHUsername: ircConf.SSHUsername,
SSHAuthMethod: ircConf.SSHAuthMethod,
SSHAgentAuth: ircConf.SSHAgentAuth,
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
}
if cfg.User == "" {
cfg.User = ircConf.Nick
}
if ircConf.SSHTunnel {
switch ircConf.SSHAuthMethod {
case "password":
getPass := m.buildSecretCallback(profile.ID, "irc-ssh-password", ircConf.SSHSecretMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("IRC SSH password: %w", err)
}
cfg.SSHPassword = password
case "publickey":
if ircConf.SSHPrivateKey != "" {
info, err := os.Stat(ircConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading IRC SSH key metadata: %w", err)
}
if info.IsDir() {
return fmt.Errorf("IRC SSH key path is a directory")
}
if info.Size() > maxTunnelPrivateKeyBytes {
return fmt.Errorf("IRC SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
}
keyData, err := os.ReadFile(ircConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading IRC SSH key: %w", err)
}
cfg.SSHPrivateKey = keyData
getPassphrase := m.buildSecretCallback(profile.ID, "irc-ssh-passphrase", ircConf.SSHSecretMode, getSecret)
passphrase, _ := getPassphrase()
cfg.SSHPassphrase = passphrase
}
}
if ircConf.SSHAgentAuth {
socketPath := os.Getenv("SSH_AUTH_SOCK")
if socketPath != "" {
agentConn, err := net.Dial("unix", socketPath)
if err == nil {
cfg.SSHAgentConn = agentConn
}
}
}
}
client := ircclient.New(cfg)
if err := client.Connect(); err != nil {
return err
}
sess.ircClient = client
return nil
}
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
m.emitEvent("session:irc:event", map[string]interface{}{
"sessionID": sessionID,
"event": event,
})
}
func (m *Manager) markSessionError(sess *Session, err error) {
if err == nil {
return
}
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sess.Info.ID,
"state": string(models.SessionError),
"error": err.Error(),
})
}
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
m.mu.RLock()
@@ -1728,6 +1933,10 @@ func (m *Manager) closeSession(sess *Session) {
sess.vncClient.Close()
sess.vncClient = nil
}
if sess.ircClient != nil {
sess.ircClient.Close()
sess.ircClient = nil
}
if sess.localClient != nil {
sess.localClient.Close()
sess.localClient = nil
@@ -1879,6 +2088,10 @@ func (m *Manager) shouldAutoReconnect(sess *Session) bool {
if sess.profile.RAW != nil {
return sess.profile.RAW.AutoReconnect
}
case models.ProtocolIRC:
if sess.profile.IRC != nil {
return sess.profile.IRC.AutoReconnect
}
}
return false
}
@@ -1895,6 +2108,14 @@ func (m *Manager) maxReconnectAttempts(sess *Session) int {
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
maxTries = sess.profile.Telnet.MaxReconnectTries
}
case models.ProtocolRAW:
if sess.profile.RAW != nil {
maxTries = 5
}
case models.ProtocolIRC:
if sess.profile.IRC != nil && sess.profile.IRC.MaxReconnectTries > 0 {
maxTries = sess.profile.IRC.MaxReconnectTries
}
}
return maxTries
}
@@ -1959,6 +2180,7 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
sess.sshClient = nil
sess.telnetClient = nil
sess.rawClient = nil
sess.ircClient = nil
sess.sftpClient = nil
sess.Info.State = models.SessionConnecting
m.mu.Unlock()
@@ -1976,6 +2198,8 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
err = m.connectTelnet(sess)
case models.ProtocolRAW:
err = m.connectRAW(sess)
case models.ProtocolIRC:
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(sessionID))
default:
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
}
@@ -0,0 +1,105 @@
-- Migration 005: Add IRC protocol support
-- Adds the 'irc' value to the protocol CHECK constraint and an irc_config column.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc')
),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
irc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT
OR IGNORE INTO profiles_new (
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
irc_config,
created_at,
updated_at
)
SELECT
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
NULL,
created_at,
updated_at
FROM
profiles;
DROP TABLE profiles;
ALTER TABLE
profiles_new RENAME TO profiles;
INSERT INTO
schema_version (version, applied_at)
VALUES
(5, datetime('now'));
+26 -14
View File
@@ -111,15 +111,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
ircJSON, _ := json.Marshal(p.IRC)
_, err := s.db.Exec(`INSERT INTO profiles (
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes,
@@ -127,7 +128,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
@@ -143,7 +144,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles WHERE id = ?`, id)
@@ -165,13 +166,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
ircJSON, _ := json.Marshal(p.IRC)
res, err := s.db.Exec(`UPDATE profiles SET
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?,
updated_at=?
WHERE id=?`,
p.DisplayName, p.Description, string(p.Protocol),
@@ -181,7 +183,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
p.UpdatedAt.Format(time.RFC3339),
p.ID,
)
@@ -215,7 +217,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles ORDER BY display_name`)
if err != nil {
@@ -234,7 +236,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
@@ -253,7 +255,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
if err != nil {
@@ -271,7 +273,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
if err != nil {
@@ -288,7 +290,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
if err != nil {
@@ -305,7 +307,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
if err != nil {
@@ -579,6 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
var (
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
ircJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
@@ -591,7 +594,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
&favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
&createdAt, &updatedAt,
)
if err != nil {
@@ -634,6 +637,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
if ircJSON.Valid && ircJSON.String != "" {
p.IRC = &models.IRCConfig{}
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
}
return p, nil
}
@@ -646,6 +653,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
var (
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
ircJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
@@ -658,7 +666,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
&favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
&createdAt, &updatedAt,
)
if err != nil {
@@ -701,6 +709,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
if ircJSON.Valid && ircJSON.String != "" {
p.IRC = &models.IRCConfig{}
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
}
result = append(result, p)
}
+105
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/andydixon/nexus/internal/models"
"github.com/lrstanley/girc"
)
// ValidationError describes a single field-level validation failure.
@@ -111,6 +112,15 @@ func ValidateProfile(p *models.Profile) error {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
case models.ProtocolIRC:
validateProfileHostPort(errs, p.Host, p.Port)
if p.IRC == nil {
errs.Add("irc", "IRC configuration is required for IRC protocol")
} else if err := ValidateIRCConfig(p.IRC); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
}
@@ -403,6 +413,101 @@ func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
return nil
}
// ValidateIRCConfig validates IRC connection settings.
func ValidateIRCConfig(cfg *models.IRCConfig) error {
errs := &ValidationErrors{}
if cfg == nil {
return &ValidationError{Field: "irc", Message: "IRC configuration must not be nil"}
}
nick := strings.TrimSpace(cfg.Nick)
if nick == "" {
errs.Add("irc.nick", "nickname is required")
} else if !girc.IsValidNick(nick) {
errs.Add("irc.nick", "nickname is not valid for IRC")
}
if strings.ContainsAny(cfg.RealName, "\r\n\x00") {
errs.Add("irc.realName", "real name must not contain control characters")
}
if !isValidSecretMode(cfg.ServerPasswordMode) {
errs.Add("irc.serverPasswordMode", fmt.Sprintf("unsupported secret mode %q", cfg.ServerPasswordMode))
}
if !isValidSecretMode(cfg.NickServMode) {
errs.Add("irc.nickServMode", fmt.Sprintf("unsupported secret mode %q", cfg.NickServMode))
}
if cfg.NickServIdentify && !isValidNickServAccount(cfg.NickServAccount) {
errs.Add("irc.nickServAccount", "NickServ account must not contain control characters")
}
seenRooms := map[string]bool{}
for i, room := range cfg.AutoJoinRooms {
field := fmt.Sprintf("irc.autoJoinRooms[%d]", i)
name := strings.TrimSpace(room)
if name == "" {
errs.Add(field, "room name must not be empty")
continue
}
if !girc.IsValidChannel(name) {
errs.Add(field, "room name must start with a valid IRC channel prefix")
}
key := strings.ToLower(name)
if seenRooms[key] {
errs.Add(field, "room is listed more than once")
}
seenRooms[key] = true
}
if cfg.SSHTunnel {
if err := ValidateHost(cfg.SSHHost); err != nil {
errs.Add("irc.sshHost", err.(*ValidationError).Message)
}
if err := ValidatePort(cfg.SSHPort); err != nil {
errs.Add("irc.sshPort", err.(*ValidationError).Message)
}
if strings.TrimSpace(cfg.SSHUsername) == "" {
errs.Add("irc.sshUsername", "SSH username is required when tunnelling")
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
errs.Add("irc.sshUsername", err.(*ValidationError).Message)
}
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
errs.Add("irc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
}
if !isValidSecretMode(cfg.SSHSecretMode) {
errs.Add("irc.sshSecretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SSHSecretMode))
}
}
if cfg.MaxReconnectTries < 0 {
errs.Add("irc.maxReconnectTries", "max reconnect attempts must not be negative")
}
if errs.HasErrors() {
return errs
}
return nil
}
func isValidSecretMode(mode models.SecretMode) bool {
switch mode {
case models.SecretModeSaved, models.SecretModeSessionOnly, models.SecretModeAskEveryTime:
return true
default:
return false
}
}
func isValidNickServAccount(account string) bool {
for _, r := range account {
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func isLoopbackHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "localhost" {