IRC support
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user