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}