567 lines
14 KiB
Go
567 lines
14 KiB
Go
// Copyright (c) Liam Stanley <me@liamstanley.io>. All rights reserved. Use
|
|
// of this source code is governed by the MIT license that can be found in
|
|
// the LICENSE file.
|
|
|
|
package girc
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Messages are delimited with CR and LF line endings, we're using the last
|
|
// one to split the stream. Both are removed during parsing of the message.
|
|
const delim byte = '\n'
|
|
|
|
var endline = []byte("\r\n")
|
|
|
|
// ircConn represents an IRC network protocol connection, it consists of an
|
|
// Encoder and Decoder to manage i/o.
|
|
type ircConn struct {
|
|
io *bufio.ReadWriter
|
|
sock net.Conn
|
|
|
|
mu sync.RWMutex
|
|
// lastWrite is used to keep track of when we last wrote to the server.
|
|
lastWrite time.Time
|
|
// lastActive is the last time the client was interacting with the server,
|
|
// excluding a few background commands (PING, PONG, WHO, etc).
|
|
lastActive time.Time
|
|
// writeDelay is used to keep track of rate limiting of events sent to
|
|
// the server.
|
|
writeDelay time.Duration
|
|
// connected is true if we're actively connected to a server.
|
|
connected bool
|
|
// connTime is the time at which the client has connected to a server.
|
|
connTime *time.Time
|
|
// lastPing is the last time that we pinged the server.
|
|
lastPing time.Time
|
|
// lastPong is the last successful time that we pinged the server and
|
|
// received a successful pong back.
|
|
lastPong time.Time
|
|
pingDelay time.Duration
|
|
}
|
|
|
|
// Dialer is an interface implementation of net.Dialer. Use this if you would
|
|
// like to implement your own dialer which the client will use when connecting.
|
|
type Dialer interface {
|
|
// Dial takes two arguments. Network, which should be similar to "tcp",
|
|
// "tdp6", "udp", etc -- as well as address, which is the hostname or ip
|
|
// of the network. Note that network can be ignored if your transport
|
|
// doesn't take advantage of network types.
|
|
Dial(network, address string) (net.Conn, error)
|
|
}
|
|
|
|
// newConn sets up and returns a new connection to the server.
|
|
func newConn(conf Config, dialer Dialer, addr string) (*ircConn, error) {
|
|
if err := conf.isValid(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var conn net.Conn
|
|
var err error
|
|
|
|
if dialer == nil {
|
|
netDialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
|
|
if conf.Bind != "" {
|
|
var local *net.TCPAddr
|
|
local, err = net.ResolveTCPAddr("tcp", conf.Bind+":0")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
netDialer.LocalAddr = local
|
|
}
|
|
|
|
dialer = netDialer
|
|
}
|
|
|
|
if conn, err = dialer.Dial("tcp", addr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if conf.SSL {
|
|
var tlsConn net.Conn
|
|
tlsConn, err = tlsHandshake(conn, conf.TLSConfig, conf.Server, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conn = tlsConn
|
|
}
|
|
|
|
ctime := time.Now()
|
|
|
|
c := &ircConn{
|
|
sock: conn,
|
|
connTime: &ctime,
|
|
connected: true,
|
|
}
|
|
c.newReadWriter()
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func newMockConn(conn net.Conn) *ircConn {
|
|
ctime := time.Now()
|
|
c := &ircConn{
|
|
sock: conn,
|
|
connTime: &ctime,
|
|
connected: true,
|
|
}
|
|
c.newReadWriter()
|
|
|
|
return c
|
|
}
|
|
|
|
// ErrParseEvent is returned when an event cannot be parsed with ParseEvent().
|
|
type ErrParseEvent struct {
|
|
Line string
|
|
}
|
|
|
|
func (e ErrParseEvent) Error() string { return "unable to parse event: " + e.Line }
|
|
|
|
func (c *ircConn) decode() (event *Event, err error) {
|
|
line, err := c.io.ReadString(delim)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if event = ParseEvent(line); event == nil {
|
|
return nil, ErrParseEvent{line}
|
|
}
|
|
|
|
return event, nil
|
|
}
|
|
|
|
func (c *ircConn) encode(event *Event) error {
|
|
if _, err := c.io.Write(event.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
if _, err := c.io.Write(endline); err != nil {
|
|
return err
|
|
}
|
|
|
|
return c.io.Flush()
|
|
}
|
|
|
|
func (c *ircConn) newReadWriter() {
|
|
c.io = bufio.NewReadWriter(bufio.NewReader(c.sock), bufio.NewWriter(c.sock))
|
|
}
|
|
|
|
func tlsHandshake(conn net.Conn, conf *tls.Config, server string, validate bool) (net.Conn, error) {
|
|
if conf == nil {
|
|
conf = &tls.Config{ServerName: server, InsecureSkipVerify: !validate}
|
|
}
|
|
|
|
tlsConn := tls.Client(conn, conf)
|
|
return net.Conn(tlsConn), nil
|
|
}
|
|
|
|
// Close closes the underlying socket.
|
|
func (c *ircConn) Close() error {
|
|
return c.sock.Close()
|
|
}
|
|
|
|
// Connect attempts to connect to the given IRC server. Returns only when
|
|
// an error has occurred, or a disconnect was requested with Close(). Connect
|
|
// will only return once all client-based goroutines have been closed to
|
|
// ensure there are no long-running routines becoming backed up.
|
|
//
|
|
// Connect will wait for all non-goroutine handlers to complete on error/quit,
|
|
// however it will not wait for goroutine-based handlers.
|
|
//
|
|
// If this returns nil, this means that the client requested to be closed
|
|
// (e.g. Client.Close()). Connect will panic if called when the last call has
|
|
// not completed.
|
|
func (c *Client) Connect() error {
|
|
return c.internalConnect(nil, nil)
|
|
}
|
|
|
|
// DialerConnect allows you to specify your own custom dialer which implements
|
|
// the Dialer interface.
|
|
//
|
|
// An example of using this library would be to take advantage of the
|
|
// golang.org/x/net/proxy library:
|
|
//
|
|
// proxyUrl, _ := proxyURI, err = url.Parse("socks5://1.2.3.4:8888")
|
|
// dialer, _ := proxy.FromURL(proxyURI, &net.Dialer{Timeout: 5 * time.Second})
|
|
// _ := girc.DialerConnect(dialer)
|
|
func (c *Client) DialerConnect(dialer Dialer) error {
|
|
return c.internalConnect(nil, dialer)
|
|
}
|
|
|
|
// MockConnect is used to implement mocking with an IRC server. Supply a net.Conn
|
|
// that will be used to spoof the server. A useful way to do this is to so
|
|
// net.Pipe(), pass one end into MockConnect(), and the other end into
|
|
// bufio.NewReader().
|
|
//
|
|
// For example:
|
|
//
|
|
// client := girc.New(girc.Config{
|
|
// Server: "dummy.int",
|
|
// Port: 6667,
|
|
// Nick: "test",
|
|
// User: "test",
|
|
// Name: "Testing123",
|
|
// })
|
|
//
|
|
// in, out := net.Pipe()
|
|
// defer in.Close()
|
|
// defer out.Close()
|
|
// b := bufio.NewReader(in)
|
|
//
|
|
// go func() {
|
|
// if err := client.MockConnect(out); err != nil {
|
|
// panic(err)
|
|
// }
|
|
// }()
|
|
//
|
|
// defer client.Close(false)
|
|
//
|
|
// for {
|
|
// in.SetReadDeadline(time.Now().Add(300 * time.Second))
|
|
// line, err := b.ReadString(byte('\n'))
|
|
// if err != nil {
|
|
// panic(err)
|
|
// }
|
|
//
|
|
// event := girc.ParseEvent(line)
|
|
//
|
|
// if event == nil {
|
|
// continue
|
|
// }
|
|
//
|
|
// // Do stuff with event here.
|
|
// }
|
|
func (c *Client) MockConnect(conn net.Conn) error {
|
|
return c.internalConnect(conn, nil)
|
|
}
|
|
|
|
func (c *Client) internalConnect(mock net.Conn, dialer Dialer) error {
|
|
// We want to be the only one handling connects/disconnects right now.
|
|
c.mu.Lock()
|
|
|
|
if c.conn != nil {
|
|
panic("use of connect more than once")
|
|
}
|
|
|
|
// Reset the state.
|
|
c.state.reset()
|
|
|
|
if mock == nil {
|
|
// Validate info, and actually make the connection.
|
|
c.debug.Printf("connecting to %s...", c.Server())
|
|
conn, err := newConn(c.Config, dialer, c.Server())
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
c.conn = conn
|
|
} else {
|
|
c.conn = newMockConn(mock)
|
|
}
|
|
|
|
var ctx context.Context
|
|
ctx, c.stop = context.WithCancel(context.Background())
|
|
c.mu.Unlock()
|
|
|
|
errs := make(chan error, 4)
|
|
var wg sync.WaitGroup
|
|
// 4 being the number of goroutines we need to finish when this function
|
|
// returns.
|
|
wg.Add(4)
|
|
go c.execLoop(ctx, errs, &wg)
|
|
go c.readLoop(ctx, errs, &wg)
|
|
go c.sendLoop(ctx, errs, &wg)
|
|
go c.pingLoop(ctx, errs, &wg)
|
|
|
|
// Passwords first.
|
|
if c.Config.ServerPass != "" {
|
|
c.write(&Event{Command: PASS, Params: []string{c.Config.ServerPass}, Sensitive: true})
|
|
}
|
|
|
|
// List the IRCv3 capabilities, specifically with the max protocol we
|
|
// support. The IRCv3 specification doesn't directly state if this should
|
|
// be called directly before registration, or if it should be called
|
|
// after NICK/USER requests. It looks like non-supporting networks
|
|
// should ignore this, and some IRCv3 capable networks require this to
|
|
// occur before NICK/USER registration.
|
|
c.listCAP()
|
|
|
|
// Then nickname.
|
|
c.write(&Event{Command: NICK, Params: []string{c.Config.Nick}})
|
|
|
|
// Then username and realname.
|
|
if c.Config.Name == "" {
|
|
c.Config.Name = c.Config.User
|
|
}
|
|
|
|
c.write(&Event{Command: USER, Params: []string{c.Config.User, "*", "*"}, Trailing: c.Config.Name})
|
|
|
|
// Send a virtual event allowing hooks for successful socket connection.
|
|
c.RunHandlers(&Event{Command: INITIALIZED, Trailing: c.Server()})
|
|
|
|
// Wait for the first error.
|
|
var result error
|
|
select {
|
|
case <-ctx.Done():
|
|
c.debug.Print("received request to close, beginning clean up")
|
|
c.RunHandlers(&Event{Command: STOPPED, Trailing: c.Server()})
|
|
case err := <-errs:
|
|
c.debug.Print("received error, beginning clean up")
|
|
result = err
|
|
}
|
|
|
|
// Make sure that the connection is closed if not already.
|
|
c.mu.RLock()
|
|
if c.stop != nil {
|
|
c.stop()
|
|
}
|
|
c.conn.mu.Lock()
|
|
c.conn.connected = false
|
|
_ = c.conn.Close()
|
|
c.conn.mu.Unlock()
|
|
c.mu.RUnlock()
|
|
|
|
// Once we have our error/result, let all other functions know we're done.
|
|
c.debug.Print("waiting for all routines to finish")
|
|
|
|
// Wait for all goroutines to finish.
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
// This helps ensure that the end user isn't improperly using the client
|
|
// more than once. If they want to do this, they should be using multiple
|
|
// clients, not multiple instances of Connect().
|
|
c.mu.Lock()
|
|
c.conn = nil
|
|
c.mu.Unlock()
|
|
|
|
return result
|
|
}
|
|
|
|
// readLoop sets a timeout of 300 seconds, and then attempts to read from the
|
|
// IRC server. If there is an error, it calls Reconnect.
|
|
func (c *Client) readLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
|
|
c.debug.Print("starting readLoop")
|
|
defer c.debug.Print("closing readLoop")
|
|
|
|
var event *Event
|
|
var err error
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
wg.Done()
|
|
return
|
|
default:
|
|
_ = c.conn.sock.SetReadDeadline(time.Now().Add(300 * time.Second))
|
|
event, err = c.conn.decode()
|
|
if err != nil {
|
|
errs <- err
|
|
wg.Done()
|
|
return
|
|
}
|
|
|
|
c.rx <- event
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send sends an event to the server. Use Client.RunHandlers() if you are
|
|
// simply looking to trigger handlers with an event.
|
|
func (c *Client) Send(event *Event) {
|
|
if !c.Config.AllowFlood {
|
|
<-time.After(c.conn.rate(event.Len()))
|
|
}
|
|
|
|
if c.Config.GlobalFormat && event.Trailing != "" &&
|
|
(event.Command == PRIVMSG || event.Command == TOPIC || event.Command == NOTICE) {
|
|
event.Trailing = Fmt(event.Trailing)
|
|
}
|
|
|
|
c.write(event)
|
|
}
|
|
|
|
// write is the lower level function to write an event. It does not have a
|
|
// write-delay when sending events.
|
|
func (c *Client) write(event *Event) {
|
|
c.tx <- event
|
|
}
|
|
|
|
// rate allows limiting events based on how frequent the event is being sent,
|
|
// as well as how many characters each event has.
|
|
func (c *ircConn) rate(chars int) time.Duration {
|
|
_time := time.Second + ((time.Duration(chars) * time.Second) / 100)
|
|
|
|
c.mu.Lock()
|
|
if c.writeDelay += _time - time.Now().Sub(c.lastWrite); c.writeDelay < 0 {
|
|
c.writeDelay = 0
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.writeDelay > (8 * time.Second) {
|
|
return _time
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
|
|
c.debug.Print("starting sendLoop")
|
|
defer c.debug.Print("closing sendLoop")
|
|
|
|
var err error
|
|
|
|
for {
|
|
select {
|
|
case event := <-c.tx:
|
|
// Check if tags exist on the event. If they do, and message-tags
|
|
// isn't a supported capability, remove them from the event.
|
|
if event.Tags != nil {
|
|
c.state.RLock()
|
|
var in bool
|
|
for i := 0; i < len(c.state.enabledCap); i++ {
|
|
if c.state.enabledCap[i] == "message-tags" {
|
|
in = true
|
|
break
|
|
}
|
|
}
|
|
c.state.RUnlock()
|
|
|
|
if !in {
|
|
event.Tags = Tags{}
|
|
}
|
|
}
|
|
|
|
// Log the event.
|
|
if event.Sensitive {
|
|
c.debug.Printf("> %s ***redacted***", event.Command)
|
|
} else {
|
|
c.debug.Print("> ", StripRaw(event.String()))
|
|
}
|
|
if c.Config.Out != nil {
|
|
if pretty, ok := event.Pretty(); ok {
|
|
fmt.Fprintln(c.Config.Out, StripRaw(pretty))
|
|
}
|
|
}
|
|
|
|
c.conn.mu.Lock()
|
|
c.conn.lastWrite = time.Now()
|
|
|
|
if event.Command != PING && event.Command != PONG && event.Command != WHO {
|
|
c.conn.lastActive = c.conn.lastWrite
|
|
}
|
|
c.conn.mu.Unlock()
|
|
|
|
// Write the raw line.
|
|
_, err = c.conn.io.Write(event.Bytes())
|
|
if err == nil {
|
|
// And the \r\n.
|
|
_, err = c.conn.io.Write(endline)
|
|
if err == nil {
|
|
// Lastly, flush everything to the socket.
|
|
err = c.conn.io.Flush()
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
errs <- err
|
|
wg.Done()
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
wg.Done()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// ErrTimedOut is returned when we attempt to ping the server, and timed out
|
|
// before receiving a PONG back.
|
|
type ErrTimedOut struct {
|
|
// TimeSinceSuccess is how long ago we received a successful pong.
|
|
TimeSinceSuccess time.Duration
|
|
// LastPong is the time we received our last successful pong.
|
|
LastPong time.Time
|
|
// LastPong is the last time we sent a pong request.
|
|
LastPing time.Time
|
|
// Delay is the configured delay between how often we send a ping request.
|
|
Delay time.Duration
|
|
}
|
|
|
|
func (ErrTimedOut) Error() string { return "timed out during ping to server" }
|
|
|
|
func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
|
|
// Don't run the pingLoop if they want to disable it.
|
|
if c.Config.PingDelay <= 0 {
|
|
wg.Done()
|
|
return
|
|
}
|
|
|
|
c.debug.Print("starting pingLoop")
|
|
defer c.debug.Print("closing pingLoop")
|
|
|
|
c.conn.mu.Lock()
|
|
c.conn.lastPing = time.Now()
|
|
c.conn.lastPong = time.Now()
|
|
c.conn.mu.Unlock()
|
|
|
|
tick := time.NewTicker(c.Config.PingDelay)
|
|
defer tick.Stop()
|
|
|
|
started := time.Now()
|
|
past := false
|
|
|
|
for {
|
|
select {
|
|
case <-tick.C:
|
|
// Delay during connect to wait for the client to register, otherwise
|
|
// some ircd's will not respond (e.g. during SASL negotiation).
|
|
if !past {
|
|
if time.Since(started) < 30*time.Second {
|
|
continue
|
|
}
|
|
|
|
past = true
|
|
}
|
|
|
|
c.conn.mu.RLock()
|
|
if time.Since(c.conn.lastPong) > c.Config.PingDelay+(60*time.Second) {
|
|
// It's 60 seconds over what out ping delay is, connection
|
|
// has probably dropped.
|
|
errs <- ErrTimedOut{
|
|
TimeSinceSuccess: time.Since(c.conn.lastPong),
|
|
LastPong: c.conn.lastPong,
|
|
LastPing: c.conn.lastPing,
|
|
Delay: c.Config.PingDelay,
|
|
}
|
|
|
|
wg.Done()
|
|
c.conn.mu.RUnlock()
|
|
return
|
|
}
|
|
c.conn.mu.RUnlock()
|
|
|
|
c.conn.mu.Lock()
|
|
c.conn.lastPing = time.Now()
|
|
c.conn.mu.Unlock()
|
|
|
|
c.Cmd.Ping(fmt.Sprintf("%d", time.Now().UnixNano()))
|
|
case <-ctx.Done():
|
|
wg.Done()
|
|
return
|
|
}
|
|
}
|
|
}
|