1package main23import (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"2223 "github.com/lrstanley/girc"24)2526var ircPath string2728const (29 logfn = "log"30 nickfn = "usr"31 outfn = "out"32 infn = "in"33 idfn = "id"34)3536const masterChan = ""3738type ircChan struct {39 done chan bool40 ln net.Listener41}4243type ircDir struct {44 name string45 done chan bool46 fp string47 ch *ircChan48}4950var ircDirs = make(map[string]*ircDir)5152var (53 server string54 clientKey string55 clientCert string56 certs string57 name string58 prefix string59 nick string60 port int61 useTLS bool62 debug bool63 sasl bool64)6566var (67 errNotExist = fmt.Errorf("IRC directory doesn't exist")68 errExist = fmt.Errorf("IRC directory already exists")69)7071var (72 mntRegex *regexp.Regexp73 logFile *os.File74)7576var 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}8687func 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()9293 // Explicitly calling os.Exit here to be able to also use this94 // function when command-line arguments are missing. The Exit95 // status 2 is also used by flag.ExitOnError.96 os.Exit(2)97}9899func 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}108109func die(client *girc.Client, err error) {110 cleanup(client)111 log.Fatal(err)112}113114func parseFlags() {115 user, err := user.Current()116 if err != nil {117 log.Fatal(err)118 }119120 // Flags are declared in this function instead of declaring them121 // globally in order to properly utilize the os/user package.122123 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")133134 flag.Usage = usage135 flag.Parse()136137 if flag.NArg() < 1 {138 fmt.Fprintf(flag.CommandLine.Output(), "missing server argument\n")139 usage()140 }141 server = flag.Arg(0)142143 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}153154func 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, err160 }161162 pool := x509.NewCertPool()163 if !pool.AppendCertsFromPEM(data) {164 return nil, fmt.Errorf("couldn't parse certificate %q", certs)165 }166167 config.RootCAs = pool168 }169170 if clientCert != "" && clientKey != "" {171 cert, err := tls.LoadX509KeyPair(clientCert, clientKey)172 if err != nil {173 return nil, err174 }175176 // Perform sanity check on x509 certificate of TLS client certificates.177 // Unfourtunatly, cert.Leaf is discarded and thus the certificate needs178 // 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, err182 }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 }187188 config.Certificates = []tls.Certificate{cert}189 }190191 return config, nil192}193194// 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 r201 case r >= '0' && r <= '9':202 return r203 case r >= 'a' && r <= 'z':204 return r205 case r >= 'A' && r <= 'Z':206 return unicode.ToLower(r)207 default:208 return '_'209 }210 }211212 return strings.Map(mfunc, name)213}214215// 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 err220 }221 defer file.Close()222223 _, err = file.Write(data)224 if err != nil {225 return err226 }227228 return nil229}230231func hasMonitor(client *girc.Client) bool {232 // XXX: Maximum amount of targets is currently not checked.233 _, success := client.GetServerOption("MONITOR")234 return success235}236237func isMention(client *girc.Client, event *girc.Event) bool {238 // Don't check for mentions in NOTICE commands as they are239 // mostly automatically generated by bots (e.g. ChanServ).240 if event.Command != girc.PRIVMSG {241 return false242 }243244 return event.Source.ID() != client.GetID() &&245 (event.IsFromUser() || mntRegex.MatchString(event.Last()))246}247248func getCmdChan(event *girc.Event) (string, bool) {249 idx, ok := channelCmds[event.Command]250 if !ok || len(event.Params) < idx+1 {251 return "", false252 }253254 chanName := event.Params[idx]255 if girc.IsValidChannel(chanName) {256 return chanName, true257 }258259 return "", false260}261262func getSourceDirs(client *girc.Client, event *girc.Event) ([]*string, error) {263 var names []*string264 if event.Source == nil {265 return names, nil266 }267268 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 yet271 } else if user == nil {272 return names, fmt.Errorf("user %q doesn't exist", event.Source.Name)273 }274275 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 }282283 return names, nil284}285286func getEventDirs(client *girc.Client, event *girc.Event) ([]*string, error) {287 name := masterChan288 if event.IsFromChannel() {289 name = event.Params[0]290 } else if event.IsFromUser() {291 name = event.Source.Name292 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 }300301 channel, isChanCmd := getCmdChan(event)302 if isChanCmd {303 name = channel304 }305 }306307 return []*string{&name}, nil308}309310func storeName(dir *ircDir) error {311 // We don't call fsync(3) on the created file which may result312 // in a zero-length file on crash. But since we recreated it on313 // every run this is not an issue and a small performance win.314315 tmpf, err := os.CreateTemp(dir.fp, ".tmp"+idfn)316 if err != nil {317 return err318 }319 defer tmpf.Close()320321 _, err = tmpf.WriteString(dir.name + "\n")322 if err != nil {323 os.Remove(tmpf.Name())324 return err325 }326 err = tmpf.Chmod(0400)327 if err != nil {328 os.Remove(tmpf.Name())329 return err330 }331332 err = os.Rename(tmpf.Name(), filepath.Join(dir.fp, idfn))333 if err != nil {334 os.Remove(tmpf.Name())335 return err336 }337338 return nil339}340341func createListener(client *girc.Client, name string) (*ircDir, error) {342 key := normalize(name)343 if idir, ok := ircDirs[key]; ok {344 return idir, errExist345 }346347 dir := filepath.Join(ircPath, key)348 err := os.MkdirAll(dir, 0700)349 if err != nil {350 return nil, err351 }352353 infp := filepath.Join(dir, infn)354 err = syscall.Mkfifo(infp, syscall.S_IFIFO|0600)355 if err != nil {356 return nil, err357 }358359 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, err365 }366 }367368 go recvInput(client, name, idir)369 if girc.IsValidChannel(name) {370 idir.ch = &ircChan{make(chan bool, 1), nil}371372 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, err377 }378379 go serveNicks(client, name, idir)380 } else if girc.IsValidNick(name) && hasMonitor(client) {381 client.Cmd.Monitor('+', name)382 }383384 ircDirs[key] = idir385 return idir, nil386}387388func removeListener(name string) error {389 key := normalize(name)390 dir, ok := ircDirs[key]391 if !ok {392 return errNotExist393 }394 defer delete(ircDirs, key)395396 infp := filepath.Join(dir.fp, infn)397398 // hack to gracefully terminate the recvInput goroutine.399 // assertion: If infp exists recvInput must be running.400 dir.done <- true401 fifo, err := os.OpenFile(infp, os.O_WRONLY, 0600)402 if err != nil && !os.IsNotExist(err) {403 return err404 }405406 defer os.Remove(infp)407 fifo.Close()408409 ch := dir.ch410 if ch != nil {411 ch.done <- true412 ch.ln.Close()413 }414415 return nil416}417418func handleInput(client *girc.Client, name, input string) error {419 if input == "" {420 return nil421 } else if input[0] != '/' {422 input = fmt.Sprintf("/%s %s :%s", girc.PRIVMSG, name, input)423 }424425 if len(input) <= 1 {426 return nil427 }428429 input = input[1:]430 event := girc.ParseEvent(input)431 if event == nil {432 return fmt.Errorf("couldn't parse input %q", input)433 }434435 switch event.Command {436 case girc.PRIVMSG, girc.NOTICE:437 // If the client doesn't support IRCv3 echo-message then438 // 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 = nil443 }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 }453454 client.Send(event)455 return nil456}457458func recvInput(client *girc.Client, name string, dir *ircDir) {459 // This goroutine must not terminate, otherwise the460 // OpenFile() call in removeListener may cause a deadlock.461462 infp := filepath.Join(dir.fp, infn)463 for {464 fifo, err := os.Open(infp)465 select {466 case <-dir.done:467 return468 default:469 if err != nil {470 continue471 }472473 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 }480481 err = scanner.Err()482 if err != nil {483 log.Println(err)484 }485486 fifo.Close()487 }488 }489}490491func 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 return497 default:498 if err != nil {499 log.Println(err)500 continue501 }502503 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 })509510 var b bytes.Buffer511 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 }519520 conn.Close()521 }522 }523}524525func fmtEvent(event *girc.Event, strip bool) (string, bool) {526 event.Echo = false // .Pretty() does not format echos527 out, ok := event.Pretty()528 if !ok {529 return "", false530 }531532 if strip && len(event.Params) >= 1 { // KICK, MODE, TOPIC, PRIVMSG, …533 // Strip the user/channel name from the output string534 // since this information is already encoded in the path.535 prefix := fmt.Sprintf("[%s] ", event.Params[0])536 out = strings.TrimPrefix(out, prefix)537 }538539 filter := func(r rune) rune {540 if unicode.IsPrint(r) {541 return r542 } else {543 return -1544 }545 }546547 // 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)550551 return out, true552}553554func writeMention(event *girc.Event) error {555 out, ok := fmtEvent(event, false)556 if !ok {557 return nil558 }559560 _, err := logFile.WriteString(out + "\n")561 if err != nil {562 return err563 }564565 return nil566}567568func writeEvent(client *girc.Client, event *girc.Event, name string) error {569 out, ok := fmtEvent(event, true)570 if !ok {571 return nil572 }573574 var suffix string575 if event.IsFromUser() || event.IsFromChannel() {576 if isMention(client, event) {577 suffix = "\x07" // BEL character578 } else if event.Source.ID() == client.GetID() {579 suffix = "\x06" // ACK character580 }581 }582583 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 err588 }589590 outfp := filepath.Join(idir.fp, outfn)591 return appendFile(outfp, []byte(out+suffix+"\n"), 0600)592}593594func handleMonitor(client *girc.Client, event girc.Event) {595 targets := strings.Split(event.Last(), ",")596 for _, target := range targets {597 source := girc.ParseSource(target)598599 // User might have already been removed elsewhere or600 // added in writeEvent already. Thus we ignore the601 // double removal / creation error.602603 var err, expErr error604 switch event.Command {605 case girc.RPL_MONOFFLINE:606 err = removeListener(source.Name)607 expErr = errNotExist608 case girc.RPL_MONONLINE:609 _, err = createListener(client, source.Name)610 expErr = errExist611 default:612 panic("unexpected command")613 }614615 if err != nil && err != expErr {616 log.Printf("couldn't monitor %q: %s\n", target, err)617 }618 }619}620621func handlePart(client *girc.Client, event girc.Event) {622 if len(event.Params) < 1 || event.Source == nil {623 return624 }625 name := event.Params[0]626627 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}634635func handleKick(client *girc.Client, event girc.Event) {636 if len(event.Params) < 2 ||637 girc.ToRFC1459(event.Params[1]) != client.GetID() {638 return639 }640 name := event.Params[0]641642 err := removeListener(name)643 if err != nil {644 log.Printf("couldn't remove %q after kick: %s\n", name, err)645 }646}647648func handleMsg(client *girc.Client, event girc.Event) {649 if event.Source == nil {650 return651 } else if debug {652 fmt.Println(event.String())653 }654655 // Proper handling for CTCPs is not implemented and will never656 // be implemented. Therefore we just ignore CTCPs except `/me`.657 isCtcp, ctcp := event.IsCTCP()658 if isCtcp && ctcp.Command != girc.CTCP_ACTION {659 return660 }661662 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 }673674 names, err := getEventDirs(client, &event)675 if err != nil {676 die(client, err)677 }678679 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}686687func 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 })703704 client.Handlers.Add(girc.RPL_MONOFFLINE, handleMonitor)705 client.Handlers.Add(girc.RPL_MONONLINE, handleMonitor)706707 client.Handlers.Add(girc.PART, handlePart)708 client.Handlers.Add(girc.KICK, handleKick)709710 client.Handlers.Add(girc.ALL_EVENTS, handleMsg)711}712713func newClient() (*girc.Client, error) {714 var tlsconf *tls.Config715 if useTLS {716 var err error717 tlsconf, err = getTLSconfig()718 if err != nil {719 return nil, err720 }721 }722723 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,733734 // Enable https://ircv3.net/specs/extensions/echo-message735 SupportedCaps: map[string][]string{736 "echo-message": nil,737 },738 }739740 if sasl {741 config.SASL = &girc.SASLExternal{}742 }743744 client := girc.New(config)745 addHandlers(client)746747 // Remove all CTCP handlers.748 client.CTCP = &girc.CTCP{}749750 return client, nil751}752753func initDir() error {754 ircPath = filepath.Join(prefix, server)755 err := os.MkdirAll(ircPath, 0700)756 if err != nil {757 return err758 }759760 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 err764 }765766 return nil767}768769func main() {770 log.SetFlags(log.Lshortfile)771 parseFlags()772773 err := initDir()774 if err != nil {775 log.Fatal(err)776 }777778 mntRegex = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(nick) + `\b`)779 client, err := newClient()780 if err != nil {781 log.Fatal(err)782 }783784 sig := make(chan os.Signal, 1)785 signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)786 go func() {787 <-sig788 cleanup(client)789 os.Exit(1)790 }()791792 err = client.Connect()793 cleanup(client)794 if err != nil {795 log.Fatal(err)796 }797}