hii

A file-based IRC client inspired by ii

git clone https://git.8pit.net/hii.git

  1package main
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"crypto/tls"
  7	"crypto/x509"
  8	"flag"
  9	"fmt"
 10	"log"
 11	"net"
 12	"os"
 13	"os/signal"
 14	"os/user"
 15	"path/filepath"
 16	"regexp"
 17	"sort"
 18	"strings"
 19	"syscall"
 20	"time"
 21	"unicode"
 22
 23	"github.com/lrstanley/girc"
 24)
 25
 26var ircPath string
 27
 28const (
 29	logfn  = "log"
 30	nickfn = "usr"
 31	outfn  = "out"
 32	infn   = "in"
 33	idfn   = "id"
 34)
 35
 36const masterChan = ""
 37
 38type ircChan struct {
 39	done chan bool
 40	ln   net.Listener
 41}
 42
 43type ircDir struct {
 44	name string
 45	done chan bool
 46	fp   string
 47	ch   *ircChan
 48}
 49
 50var ircDirs = make(map[string]*ircDir)
 51
 52var (
 53	server     string
 54	clientKey  string
 55	clientCert string
 56	certs      string
 57	name       string
 58	prefix     string
 59	nick       string
 60	port       int
 61	useTLS     bool
 62	debug      bool
 63	sasl       bool
 64)
 65
 66var (
 67	errNotExist = fmt.Errorf("IRC directory doesn't exist")
 68	errExist    = fmt.Errorf("IRC directory already exists")
 69)
 70
 71var (
 72	mntRegex *regexp.Regexp
 73	logFile  *os.File
 74)
 75
 76var channelCmds = map[string]int{
 77	girc.JOIN:      0,
 78	girc.PART:      0,
 79	girc.KICK:      0,
 80	girc.MODE:      0,
 81	girc.TOPIC:     0,
 82	girc.NAMES:     0,
 83	girc.LIST:      0,
 84	girc.RPL_TOPIC: 1,
 85}
 86
 87func usage() {
 88	fmt.Fprintf(flag.CommandLine.Output(),
 89		"USAGE: %s [FLAGS] SERVER [TARGET...]\n\n"+
 90			"The following flags are supported:\n\n", os.Args[0])
 91	flag.PrintDefaults()
 92
 93	// Explicitly calling os.Exit here to be able to also use this
 94	// function when command-line arguments are missing. The Exit
 95	// status 2 is also used by flag.ExitOnError.
 96	os.Exit(2)
 97}
 98
 99func cleanup(client *girc.Client) {
100	client.Close()
101	for _, dir := range ircDirs {
102		err := removeListener(dir.name)
103		if err != nil {
104			log.Printf("couldn't remove %q: %s\n", dir.name, err)
105		}
106	}
107}
108
109func die(client *girc.Client, err error) {
110	cleanup(client)
111	log.Fatal(err)
112}
113
114func parseFlags() {
115	user, err := user.Current()
116	if err != nil {
117		log.Fatal(err)
118	}
119
120	// Flags are declared in this function instead of declaring them
121	// globally in order to properly utilize the os/user package.
122
123	flag.StringVar(&clientKey, "k", "", "key for certFP")
124	flag.StringVar(&clientCert, "c", "", "cert for certFP")
125	flag.StringVar(&certs, "r", "", "root certificates")
126	flag.StringVar(&name, "f", user.Username, "real name")
127	flag.StringVar(&prefix, "i", filepath.Join(user.HomeDir, "irc"), "directory path")
128	flag.StringVar(&nick, "n", user.Username, "nick")
129	flag.IntVar(&port, "p", 6667, "TCP port")
130	flag.BoolVar(&useTLS, "t", false, "use TLS")
131	flag.BoolVar(&debug, "d", false, "enable debug output")
132	flag.BoolVar(&sasl, "s", false, "attempt authentication using SASL EXTERNAL")
133
134	flag.Usage = usage
135	flag.Parse()
136
137	if flag.NArg() < 1 {
138		fmt.Fprintf(flag.CommandLine.Output(), "missing server argument\n")
139		usage()
140	}
141	server = flag.Arg(0)
142
143	if (clientKey == "" && clientCert != "") || (clientKey != "" && clientCert == "") {
144		log.Fatal("for certFP a certificate and key need to be provided")
145	}
146	if (clientKey != "" || clientCert != "" || certs != "") && !useTLS {
147		log.Fatal("certificates given but TLS wasn't enabled")
148	}
149	if sasl && clientKey == "" {
150		log.Fatal("SASL external enabled but no client certificates were provided")
151	}
152}
153
154func getTLSconfig() (*tls.Config, error) {
155	config := &tls.Config{ServerName: server}
156	if certs != "" {
157		data, err := os.ReadFile(certs)
158		if err != nil {
159			return nil, err
160		}
161
162		pool := x509.NewCertPool()
163		if !pool.AppendCertsFromPEM(data) {
164			return nil, fmt.Errorf("couldn't parse certificate %q", certs)
165		}
166
167		config.RootCAs = pool
168	}
169
170	if clientCert != "" && clientKey != "" {
171		cert, err := tls.LoadX509KeyPair(clientCert, clientKey)
172		if err != nil {
173			return nil, err
174		}
175
176		// Perform sanity check on x509 certificate of TLS client certificates.
177		// Unfourtunatly, cert.Leaf is discarded and thus the certificate needs
178		// to be parsed again <https://groups.google.com/g/golang-dev/c/VResvFj2vF8>.
179		x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
180		if err != nil {
181			return nil, err
182		}
183		now := time.Now()
184		if now.Before(x509Cert.NotBefore) || now.After(x509Cert.NotAfter) {
185			log.Println("WARNING: Your client certificate has expired or is not valid yet")
186		}
187
188		config.Certificates = []tls.Certificate{cert}
189	}
190
191	return config, nil
192}
193
194// Briefly modeled after the channel_normalize_path ii function.
195func normalize(name string) string {
196	mfunc := func(r rune) rune {
197		switch {
198		case r == '#' || r == '&' || r == '+' ||
199			r == '!' || r == '-':
200			return r
201		case r >= '0' && r <= '9':
202			return r
203		case r >= 'a' && r <= 'z':
204			return r
205		case r >= 'A' && r <= 'Z':
206			return unicode.ToLower(r)
207		default:
208			return '_'
209		}
210	}
211
212	return strings.Map(mfunc, name)
213}
214
215// Like os.WriteFile but doesn't truncate and appends instead.
216func appendFile(filename string, data []byte, perm os.FileMode) error {
217	file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, perm)
218	if err != nil {
219		return err
220	}
221	defer file.Close()
222
223	_, err = file.Write(data)
224	if err != nil {
225		return err
226	}
227
228	return nil
229}
230
231func hasMonitor(client *girc.Client) bool {
232	// XXX: Maximum amount of targets is currently not checked.
233	_, success := client.GetServerOption("MONITOR")
234	return success
235}
236
237func isMention(client *girc.Client, event *girc.Event) bool {
238	// Don't check for mentions in NOTICE commands as they are
239	// mostly automatically generated by bots (e.g. ChanServ).
240	if event.Command != girc.PRIVMSG {
241		return false
242	}
243
244	return event.Source.ID() != client.GetID() &&
245		(event.IsFromUser() || mntRegex.MatchString(event.Last()))
246}
247
248func getCmdChan(event *girc.Event) (string, bool) {
249	idx, ok := channelCmds[event.Command]
250	if !ok || len(event.Params) < idx+1 {
251		return "", false
252	}
253
254	chanName := event.Params[idx]
255	if girc.IsValidChannel(chanName) {
256		return chanName, true
257	}
258
259	return "", false
260}
261
262func getSourceDirs(client *girc.Client, event *girc.Event) ([]*string, error) {
263	var names []*string
264	if event.Source == nil {
265		return names, nil
266	}
267
268	user := client.LookupUser(event.Source.Name)
269	if user == nil && client.GetID() == event.Source.ID() {
270		return names, nil // User didn't join any channels yet
271	} else if user == nil {
272		return names, fmt.Errorf("user %q doesn't exist", event.Source.Name)
273	}
274
275	for _, dir := range ircDirs {
276		if user.Nick == girc.ToRFC1459(dir.name) ||
277			(dir.ch != nil && user.InChannel(dir.name)) ||
278			(dir.name != masterChan && user.Nick == client.GetID()) {
279			names = append(names, &dir.name)
280		}
281	}
282
283	return names, nil
284}
285
286func getEventDirs(client *girc.Client, event *girc.Event) ([]*string, error) {
287	name := masterChan
288	if event.IsFromChannel() {
289		name = event.Params[0]
290	} else if event.IsFromUser() {
291		name = event.Source.Name
292		if event.Source.ID() == client.GetID() {
293			name = event.Params[0]
294		}
295	} else {
296		switch event.Command {
297		case girc.QUIT, girc.NICK:
298			return getSourceDirs(client, event)
299		}
300
301		channel, isChanCmd := getCmdChan(event)
302		if isChanCmd {
303			name = channel
304		}
305	}
306
307	return []*string{&name}, nil
308}
309
310func storeName(dir *ircDir) error {
311	// We don't call fsync(3) on the created file which may result
312	// in a zero-length file on crash. But since we recreated it on
313	// every run this is not an issue and a small performance win.
314
315	tmpf, err := os.CreateTemp(dir.fp, ".tmp"+idfn)
316	if err != nil {
317		return err
318	}
319	defer tmpf.Close()
320
321	_, err = tmpf.WriteString(dir.name + "\n")
322	if err != nil {
323		os.Remove(tmpf.Name())
324		return err
325	}
326	err = tmpf.Chmod(0400)
327	if err != nil {
328		os.Remove(tmpf.Name())
329		return err
330	}
331
332	err = os.Rename(tmpf.Name(), filepath.Join(dir.fp, idfn))
333	if err != nil {
334		os.Remove(tmpf.Name())
335		return err
336	}
337
338	return nil
339}
340
341func createListener(client *girc.Client, name string) (*ircDir, error) {
342	key := normalize(name)
343	if idir, ok := ircDirs[key]; ok {
344		return idir, errExist
345	}
346
347	dir := filepath.Join(ircPath, key)
348	err := os.MkdirAll(dir, 0700)
349	if err != nil {
350		return nil, err
351	}
352
353	infp := filepath.Join(dir, infn)
354	err = syscall.Mkfifo(infp, syscall.S_IFIFO|0600)
355	if err != nil {
356		return nil, err
357	}
358
359	idir := &ircDir{name, make(chan bool, 1), dir, nil}
360	if name != masterChan {
361		err = storeName(idir)
362		if err != nil {
363			os.Remove(infp)
364			return nil, err
365		}
366	}
367
368	go recvInput(client, name, idir)
369	if girc.IsValidChannel(name) {
370		idir.ch = &ircChan{make(chan bool, 1), nil}
371
372		nickfp := filepath.Join(dir, nickfn)
373		idir.ch.ln, err = net.Listen("unix", nickfp)
374		if err != nil {
375			os.Remove(infp)
376			return nil, err
377		}
378
379		go serveNicks(client, name, idir)
380	} else if girc.IsValidNick(name) && hasMonitor(client) {
381		client.Cmd.Monitor('+', name)
382	}
383
384	ircDirs[key] = idir
385	return idir, nil
386}
387
388func removeListener(name string) error {
389	key := normalize(name)
390	dir, ok := ircDirs[key]
391	if !ok {
392		return errNotExist
393	}
394	defer delete(ircDirs, key)
395
396	infp := filepath.Join(dir.fp, infn)
397
398	// hack to gracefully terminate the recvInput goroutine.
399	// assertion: If infp exists recvInput must be running.
400	dir.done <- true
401	fifo, err := os.OpenFile(infp, os.O_WRONLY, 0600)
402	if err != nil && !os.IsNotExist(err) {
403		return err
404	}
405
406	defer os.Remove(infp)
407	fifo.Close()
408
409	ch := dir.ch
410	if ch != nil {
411		ch.done <- true
412		ch.ln.Close()
413	}
414
415	return nil
416}
417
418func handleInput(client *girc.Client, name, input string) error {
419	if input == "" {
420		return nil
421	} else if input[0] != '/' {
422		input = fmt.Sprintf("/%s %s :%s", girc.PRIVMSG, name, input)
423	}
424
425	if len(input) <= 1 {
426		return nil
427	}
428
429	input = input[1:]
430	event := girc.ParseEvent(input)
431	if event == nil {
432		return fmt.Errorf("couldn't parse input %q", input)
433	}
434
435	switch event.Command {
436	case girc.PRIVMSG, girc.NOTICE:
437		// If the client doesn't support IRCv3 echo-message then
438		// we just emulate it by running handlers on the event.
439		if !client.HasCapability("echo-message") {
440			event.Source = &girc.Source{Name: client.GetNick()}
441			client.RunHandlers(event)
442			event.Source = nil
443		}
444	case girc.JOIN:
445		if len(event.Params) >= 1 {
446			ch := event.Params[0]
447			idir, ok := ircDirs[normalize(ch)]
448			if ok && idir.name != ch {
449				return fmt.Errorf("can't join %q: name clash", ch)
450			}
451		}
452	}
453
454	client.Send(event)
455	return nil
456}
457
458func recvInput(client *girc.Client, name string, dir *ircDir) {
459	// This goroutine must not terminate, otherwise the
460	// OpenFile() call in removeListener may cause a deadlock.
461
462	infp := filepath.Join(dir.fp, infn)
463	for {
464		fifo, err := os.Open(infp)
465		select {
466		case <-dir.done:
467			return
468		default:
469			if err != nil {
470				continue
471			}
472
473			scanner := bufio.NewScanner(fifo)
474			for scanner.Scan() {
475				err = handleInput(client, name, scanner.Text())
476				if err != nil {
477					log.Println(err)
478				}
479			}
480
481			err = scanner.Err()
482			if err != nil {
483				log.Println(err)
484			}
485
486			fifo.Close()
487		}
488	}
489}
490
491func serveNicks(client *girc.Client, name string, dir *ircDir) {
492	for {
493		conn, err := dir.ch.ln.Accept()
494		select {
495		case <-dir.ch.done:
496			return
497		default:
498			if err != nil {
499				log.Println(err)
500				continue
501			}
502
503			ch := client.LookupChannel(name)
504			if ch != nil {
505				users := ch.Users(client)
506				sort.Slice(users, func(i, j int) bool {
507					return !users[i].LastActive.Before(users[j].LastActive)
508				})
509
510				var b bytes.Buffer
511				for _, user := range users {
512					b.WriteString(user.Nick + "\n")
513				}
514				_, err = conn.Write(b.Bytes())
515				if err != nil {
516					log.Println(err)
517				}
518			}
519
520			conn.Close()
521		}
522	}
523}
524
525func fmtEvent(event *girc.Event, strip bool) (string, bool) {
526	event.Echo = false // .Pretty() does not format echos
527	out, ok := event.Pretty()
528	if !ok {
529		return "", false
530	}
531
532	if strip && len(event.Params) >= 1 { // KICK, MODE, TOPIC, PRIVMSG, …
533		// Strip the user/channel name from the output string
534		// since this information is already encoded in the path.
535		prefix := fmt.Sprintf("[%s] ", event.Params[0])
536		out = strings.TrimPrefix(out, prefix)
537	}
538
539	filter := func(r rune) rune {
540		if unicode.IsPrint(r) {
541			return r
542		} else {
543			return -1
544		}
545	}
546
547	// Filter escape sequences and non-printable characters (e.g. \a, …).
548	out = strings.Map(filter, girc.StripRaw(out))
549	out = fmt.Sprintf("%v %s", event.Timestamp.Unix(), out)
550
551	return out, true
552}
553
554func writeMention(event *girc.Event) error {
555	out, ok := fmtEvent(event, false)
556	if !ok {
557		return nil
558	}
559
560	_, err := logFile.WriteString(out + "\n")
561	if err != nil {
562		return err
563	}
564
565	return nil
566}
567
568func writeEvent(client *girc.Client, event *girc.Event, name string) error {
569	out, ok := fmtEvent(event, true)
570	if !ok {
571		return nil
572	}
573
574	var suffix string
575	if event.IsFromUser() || event.IsFromChannel() {
576		if isMention(client, event) {
577			suffix = "\x07" // BEL character
578		} else if event.Source.ID() == client.GetID() {
579			suffix = "\x06" // ACK character
580		}
581	}
582
583	idir, err := createListener(client, name)
584	if err == errExist && idir.name != name {
585		return fmt.Errorf("name clash (%q vs. %q)", idir.name, name)
586	} else if err != nil && err != errExist {
587		return err
588	}
589
590	outfp := filepath.Join(idir.fp, outfn)
591	return appendFile(outfp, []byte(out+suffix+"\n"), 0600)
592}
593
594func handleMonitor(client *girc.Client, event girc.Event) {
595	targets := strings.Split(event.Last(), ",")
596	for _, target := range targets {
597		source := girc.ParseSource(target)
598
599		// User might have already been removed elsewhere or
600		// added in writeEvent already. Thus we ignore the
601		// double removal / creation error.
602
603		var err, expErr error
604		switch event.Command {
605		case girc.RPL_MONOFFLINE:
606			err = removeListener(source.Name)
607			expErr = errNotExist
608		case girc.RPL_MONONLINE:
609			_, err = createListener(client, source.Name)
610			expErr = errExist
611		default:
612			panic("unexpected command")
613		}
614
615		if err != nil && err != expErr {
616			log.Printf("couldn't monitor %q: %s\n", target, err)
617		}
618	}
619}
620
621func handlePart(client *girc.Client, event girc.Event) {
622	if len(event.Params) < 1 || event.Source == nil {
623		return
624	}
625	name := event.Params[0]
626
627	if event.Source.ID() == client.GetID() {
628		err := removeListener(name)
629		if err != nil {
630			log.Printf("couldn't remove %q after part: %s\n", name, err)
631		}
632	}
633}
634
635func handleKick(client *girc.Client, event girc.Event) {
636	if len(event.Params) < 2 ||
637		girc.ToRFC1459(event.Params[1]) != client.GetID() {
638		return
639	}
640	name := event.Params[0]
641
642	err := removeListener(name)
643	if err != nil {
644		log.Printf("couldn't remove %q after kick: %s\n", name, err)
645	}
646}
647
648func handleMsg(client *girc.Client, event girc.Event) {
649	if event.Source == nil {
650		return
651	} else if debug {
652		fmt.Println(event.String())
653	}
654
655	// Proper handling for CTCPs is not implemented and will never
656	// be implemented. Therefore we just ignore CTCPs except `/me`.
657	isCtcp, ctcp := event.IsCTCP()
658	if isCtcp && ctcp.Command != girc.CTCP_ACTION {
659		return
660	}
661
662	switch event.Command {
663	case girc.AWAY, girc.CAP_ACCOUNT, girc.CAP_CHGHOST:
664		return // Ignore, occurs too often.
665	case girc.PRIVMSG:
666		if isMention(client, &event) {
667			err := writeMention(&event)
668			if err != nil {
669				log.Println(err)
670			}
671		}
672	}
673
674	names, err := getEventDirs(client, &event)
675	if err != nil {
676		die(client, err)
677	}
678
679	for _, name := range names {
680		err := writeEvent(client, &event, *name)
681		if err != nil {
682			die(client, fmt.Errorf("%s: %v", filepath.Join(server, *name), err))
683		}
684	}
685}
686
687func addHandlers(client *girc.Client) {
688	client.Handlers.Add(girc.CONNECTED, func(c *girc.Client, e girc.Event) {
689		for _, target := range flag.Args()[1:] {
690			if girc.IsValidChannel(target) {
691				c.Cmd.Join(target)
692			} else if girc.IsValidNick(target) {
693				if hasMonitor(client) {
694					c.Cmd.Monitor('+', target)
695				} else {
696					log.Printf("can't monitor %q, monitor not supported\n", target)
697				}
698			} else {
699				log.Printf("invalid target %q\n", target)
700			}
701		}
702	})
703
704	client.Handlers.Add(girc.RPL_MONOFFLINE, handleMonitor)
705	client.Handlers.Add(girc.RPL_MONONLINE, handleMonitor)
706
707	client.Handlers.Add(girc.PART, handlePart)
708	client.Handlers.Add(girc.KICK, handleKick)
709
710	client.Handlers.Add(girc.ALL_EVENTS, handleMsg)
711}
712
713func newClient() (*girc.Client, error) {
714	var tlsconf *tls.Config
715	if useTLS {
716		var err error
717		tlsconf, err = getTLSconfig()
718		if err != nil {
719			return nil, err
720		}
721	}
722
723	config := girc.Config{
724		Server:      server,
725		Port:        port,
726		Nick:        nick,
727		User:        name,
728		SSL:         useTLS,
729		TLSConfig:   tlsconf,
730		DisableSTS:  true,
731		PingDelay:   1 * time.Minute,
732		PingTimeout: 3 * time.Minute,
733
734		// Enable https://ircv3.net/specs/extensions/echo-message
735		SupportedCaps: map[string][]string{
736			"echo-message": nil,
737		},
738	}
739
740	if sasl {
741		config.SASL = &girc.SASLExternal{}
742	}
743
744	client := girc.New(config)
745	addHandlers(client)
746
747	// Remove all CTCP handlers.
748	client.CTCP = &girc.CTCP{}
749
750	return client, nil
751}
752
753func initDir() error {
754	ircPath = filepath.Join(prefix, server)
755	err := os.MkdirAll(ircPath, 0700)
756	if err != nil {
757		return err
758	}
759
760	logFp := filepath.Join(ircPath, logfn)
761	logFile, err = os.OpenFile(logFp, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
762	if err != nil {
763		return err
764	}
765
766	return nil
767}
768
769func main() {
770	log.SetFlags(log.Lshortfile)
771	parseFlags()
772
773	err := initDir()
774	if err != nil {
775		log.Fatal(err)
776	}
777
778	mntRegex = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(nick) + `\b`)
779	client, err := newClient()
780	if err != nil {
781		log.Fatal(err)
782	}
783
784	sig := make(chan os.Signal, 1)
785	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
786	go func() {
787		<-sig
788		cleanup(client)
789		os.Exit(1)
790	}()
791
792	err = client.Connect()
793	cleanup(client)
794	if err != nil {
795		log.Fatal(err)
796	}
797}