|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "context" |
| 6 | + "crypto/rand" |
| 7 | + "errors" |
| 8 | + "flag" |
| 9 | + "fmt" |
| 10 | + "io" |
| 11 | + "net" |
| 12 | + "os" |
| 13 | + "os/signal" |
| 14 | + "strconv" |
| 15 | + "strings" |
| 16 | + "time" |
| 17 | + |
| 18 | + "gosuda.org/ivnp/client" |
| 19 | + "gosuda.org/ivnp/foundation" |
| 20 | +) |
| 21 | + |
| 22 | +const maxRunTime = 5 * time.Minute |
| 23 | + |
| 24 | +type ircConfig struct { |
| 25 | + samAddress string |
| 26 | + server string |
| 27 | + port int |
| 28 | + nick string |
| 29 | +} |
| 30 | + |
| 31 | +func main() { |
| 32 | + var timeout time.Duration |
| 33 | + config := ircConfig{} |
| 34 | + flag.StringVar(&config.samAddress, "sam", "127.0.0.1:7656", "SAM bridge address") |
| 35 | + flag.StringVar(&config.server, "server", "irc.postman.i2p", "IRC I2P hostname or destination") |
| 36 | + flag.IntVar(&config.port, "port", 6667, "IRC destination port") |
| 37 | + flag.StringVar(&config.nick, "nick", "", "IRC nickname") |
| 38 | + flag.DurationVar(&timeout, "timeout", maxRunTime, "overall connection timeout") |
| 39 | + flag.Parse() |
| 40 | + if timeout <= 0 || timeout > maxRunTime { |
| 41 | + fmt.Fprintln(os.Stderr, "timeout must be between 1ns and 5m") |
| 42 | + os.Exit(2) |
| 43 | + } |
| 44 | + if config.nick == "" { |
| 45 | + nick, err := randomNick() |
| 46 | + if err != nil { |
| 47 | + fmt.Fprintln(os.Stderr, err) |
| 48 | + os.Exit(1) |
| 49 | + } |
| 50 | + config.nick = nick |
| 51 | + } |
| 52 | + parent, stop := signal.NotifyContext(context.Background(), os.Interrupt) |
| 53 | + defer stop() |
| 54 | + ctx, cancel := context.WithTimeout(parent, timeout) |
| 55 | + defer cancel() |
| 56 | + if err := runIRC2P(ctx, config, os.Stdout); err != nil { |
| 57 | + fmt.Fprintln(os.Stderr, err) |
| 58 | + os.Exit(1) |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +func runIRC2P(ctx context.Context, config ircConfig, output io.Writer) error { |
| 63 | + if ctx == nil || config.samAddress == "" || config.server == "" { |
| 64 | + return errors.New("toyirc: invalid configuration") |
| 65 | + } |
| 66 | + if config.port < 1 || config.port > 65535 || !validNick(config.nick) { |
| 67 | + return errors.New("toyirc: invalid configuration") |
| 68 | + } |
| 69 | + address := net.JoinHostPort(config.server, strconv.Itoa(config.port)) |
| 70 | + var lastErr error |
| 71 | + for { |
| 72 | + attemptContext, cancel := context.WithTimeout(ctx, 90*time.Second) |
| 73 | + lastErr = runIRC2PAttempt(attemptContext, config, address) |
| 74 | + cancel() |
| 75 | + if lastErr == nil { |
| 76 | + _, err := fmt.Fprintf(output, "connected to %s as %s\n", address, config.nick) |
| 77 | + return err |
| 78 | + } |
| 79 | + if ctx.Err() != nil { |
| 80 | + return fmt.Errorf("toyirc: connect %s: %w", address, errors.Join(ctx.Err(), lastErr)) |
| 81 | + } |
| 82 | + timer := time.NewTimer(2 * time.Second) |
| 83 | + select { |
| 84 | + case <-ctx.Done(): |
| 85 | + timer.Stop() |
| 86 | + return fmt.Errorf("toyirc: connect %s: %w", address, errors.Join(ctx.Err(), lastErr)) |
| 87 | + case <-timer.C: |
| 88 | + } |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +func runIRC2PAttempt(ctx context.Context, config ircConfig, address string) error { |
| 93 | + network, err := client.SimpleAnonymousMessagingNew(client.SimpleAnonymousMessagingConfig{ |
| 94 | + Address: config.samAddress, SignatureType: foundation.SigningEdDSASHA512Ed25519, |
| 95 | + LeaseSetEncTypes: []foundation.CryptoKeyType{foundation.CryptoX25519}, |
| 96 | + }) |
| 97 | + if err != nil { |
| 98 | + return fmt.Errorf("toyirc: create SAM session: %w", err) |
| 99 | + } |
| 100 | + defer network.Close() |
| 101 | + if err = network.Start(ctx); err != nil { |
| 102 | + return fmt.Errorf("toyirc: start SAM session: %w", err) |
| 103 | + } |
| 104 | + connection, err := network.DialI2P(ctx, address) |
| 105 | + if err != nil { |
| 106 | + return fmt.Errorf("toyirc: connect %s: %w", address, err) |
| 107 | + } |
| 108 | + defer connection.Close() |
| 109 | + return exchangeIRC(ctx, connection, config.nick) |
| 110 | +} |
| 111 | + |
| 112 | +func exchangeIRC(ctx context.Context, connection net.Conn, nick string) error { |
| 113 | + if ctx == nil || connection == nil || !validNick(nick) { |
| 114 | + return errors.New("toyirc: invalid IRC connection") |
| 115 | + } |
| 116 | + if deadline, ok := ctx.Deadline(); ok { |
| 117 | + if err := connection.SetDeadline(deadline); err != nil { |
| 118 | + return err |
| 119 | + } |
| 120 | + } |
| 121 | + stop := context.AfterFunc(ctx, func() { _ = connection.SetDeadline(time.Now()) }) |
| 122 | + defer stop() |
| 123 | + if err := writeIRC(connection, "NICK "+nick); err != nil { |
| 124 | + return fmt.Errorf("toyirc: send NICK: %w", err) |
| 125 | + } |
| 126 | + if err := writeIRC(connection, "USER "+nick+" 0 * :IVNP integration client"); err != nil { |
| 127 | + return fmt.Errorf("toyirc: send USER: %w", err) |
| 128 | + } |
| 129 | + reader := bufio.NewReaderSize(connection, 1024) |
| 130 | + for { |
| 131 | + line, err := reader.ReadSlice('\n') |
| 132 | + if err != nil { |
| 133 | + if ctx.Err() != nil { |
| 134 | + return fmt.Errorf("toyirc: IRC deadline: %w", ctx.Err()) |
| 135 | + } |
| 136 | + return fmt.Errorf("toyirc: read IRC: %w", err) |
| 137 | + } |
| 138 | + if len(line) > 512 { |
| 139 | + return errors.New("toyirc: oversized IRC line") |
| 140 | + } |
| 141 | + message := strings.TrimSuffix(strings.TrimSuffix(string(line), "\n"), "\r") |
| 142 | + if strings.HasPrefix(message, "PING ") { |
| 143 | + if err = writeIRC(connection, "PONG "+strings.TrimPrefix(message, "PING ")); err != nil { |
| 144 | + return fmt.Errorf("toyirc: send PONG: %w", err) |
| 145 | + } |
| 146 | + continue |
| 147 | + } |
| 148 | + command := ircCommand(message) |
| 149 | + switch command { |
| 150 | + case "001": |
| 151 | + _ = writeIRC(connection, "QUIT :integration complete") |
| 152 | + return nil |
| 153 | + case "ERROR": |
| 154 | + return fmt.Errorf("toyirc: IRC server error: %s", message) |
| 155 | + } |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +func writeIRC(writer io.Writer, line string) error { |
| 160 | + if strings.ContainsAny(line, "\r\n") || len(line) > 510 { |
| 161 | + return errors.New("toyirc: invalid IRC line") |
| 162 | + } |
| 163 | + _, err := io.WriteString(writer, line+"\r\n") |
| 164 | + return err |
| 165 | +} |
| 166 | + |
| 167 | +func ircCommand(message string) string { |
| 168 | + fields := strings.Fields(message) |
| 169 | + if len(fields) == 0 { |
| 170 | + return "" |
| 171 | + } |
| 172 | + if strings.HasPrefix(fields[0], ":") { |
| 173 | + if len(fields) < 2 { |
| 174 | + return "" |
| 175 | + } |
| 176 | + return strings.ToUpper(fields[1]) |
| 177 | + } |
| 178 | + return strings.ToUpper(fields[0]) |
| 179 | +} |
| 180 | + |
| 181 | +func validNick(nick string) bool { |
| 182 | + if len(nick) < 1 || len(nick) > 16 { |
| 183 | + return false |
| 184 | + } |
| 185 | + for index, character := range nick { |
| 186 | + letter := character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' |
| 187 | + if letter || character == '_' || character == '-' { |
| 188 | + continue |
| 189 | + } |
| 190 | + digitAfterFirst := index > 0 && character >= '0' && character <= '9' |
| 191 | + if !digitAfterFirst { |
| 192 | + return false |
| 193 | + } |
| 194 | + } |
| 195 | + return true |
| 196 | +} |
| 197 | + |
| 198 | +func randomNick() (string, error) { |
| 199 | + var suffix [3]byte |
| 200 | + if _, err := rand.Read(suffix[:]); err != nil { |
| 201 | + return "", err |
| 202 | + } |
| 203 | + return fmt.Sprintf("iv%06x", suffix), nil |
| 204 | +} |
0 commit comments