Skip to content

Commit d36311e

Browse files
committed
feat(toyirc): add SAM IRC integration client
Connect through IVNP to irc.postman.i2p and verify registration plus PING/PONG handling in CI.
1 parent 41f305c commit d36311e

3 files changed

Lines changed: 389 additions & 0 deletions

File tree

.github/workflows/irc2p.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: irc2p tunnel integration
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
irc2p:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 8
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-go@v5
19+
with:
20+
go-version-file: go.mod
21+
cache: true
22+
- name: Build daemon
23+
shell: bash
24+
run: go build -o "$RUNNER_TEMP/ivnpd" ./cmd/ivnpd
25+
- name: Run real tunnel and IRC connection
26+
shell: bash
27+
run: |
28+
set -euo pipefail
29+
readonly run_dir="$RUNNER_TEMP/ivnp-irc2p"
30+
mkdir -p -- "$run_dir"
31+
curl --fail --location --silent --show-error --retry 3 --retry-all-errors \
32+
--output "$run_dir/privatehosts.txt" \
33+
https://raw.githubusercontent.com/i2p/i2p.i2p/master/installer/resources/hosts.txt
34+
cat >"$run_dir/ivnp.conf" <<EOF
35+
[router]
36+
version = 0.9.70
37+
[tunnel]
38+
enabled = true
39+
hops = 2
40+
exploratory_inbound_target = 1
41+
exploratory_outbound_target = 1
42+
exploratory_pool_capacity = 2
43+
client_inbound_target = 1
44+
client_outbound_target = 1
45+
client_pool_capacity = 2
46+
build_pending_capacity = 13
47+
lifetime = 10m
48+
renew_before = 30s
49+
maintenance_interval = 1s
50+
[sam]
51+
enabled = true
52+
listen_host = 127.0.0.1
53+
listen_port = 7656
54+
[addressbook]
55+
enabled = true
56+
privatehosts_path = $run_dir/privatehosts.txt
57+
subscriptions =
58+
[log]
59+
level = info
60+
format = text
61+
EOF
62+
"$RUNNER_TEMP/ivnpd" -config "$run_dir/ivnp.conf" -webui=false >"$run_dir/ivnpd.log" 2>&1 &
63+
daemon_pid=$!
64+
cleanup() {
65+
status=$?
66+
trap - EXIT
67+
kill "$daemon_pid" 2>/dev/null || true
68+
wait "$daemon_pid" 2>/dev/null || true
69+
if ((status != 0)); then
70+
cat "$run_dir/ivnpd.log" >&2
71+
fi
72+
exit "$status"
73+
}
74+
trap cleanup EXIT
75+
ready=false
76+
for _ in {1..90}; do
77+
if (exec 3<>/dev/tcp/127.0.0.1/7656) 2>/dev/null; then
78+
exec 3>&-
79+
ready=true
80+
break
81+
fi
82+
if ! kill -0 "$daemon_pid" 2>/dev/null; then
83+
printf 'ivnpd exited before SAM became ready\n' >&2
84+
exit 1
85+
fi
86+
sleep 1
87+
done
88+
if [[ $ready != true ]]; then
89+
printf 'SAM did not become ready\n' >&2
90+
exit 1
91+
fi
92+
IVNP_IRC2P_INTEGRATION=1 IVNP_IRC2P_SAM=127.0.0.1:7656 \
93+
go test ./cmd/toyirc -run '^TestIRC2PIntegration$' -count=1 -timeout=5m

cmd/toyirc/main.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
}

cmd/toyirc/main_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"cmp"
6+
"context"
7+
"fmt"
8+
"io"
9+
"net"
10+
"os"
11+
"strings"
12+
"testing"
13+
"time"
14+
)
15+
16+
func TestExchangeIRCCompletesWelcomeAndPong(t *testing.T) {
17+
clientConnection, serverConnection := net.Pipe()
18+
defer clientConnection.Close()
19+
defer serverConnection.Close()
20+
serverErr := make(chan error, 1)
21+
go func() {
22+
reader := bufio.NewReader(serverConnection)
23+
for _, prefix := range []string{"NICK ivtest", "USER ivtest "} {
24+
line, err := reader.ReadString('\n')
25+
if err != nil {
26+
serverErr <- err
27+
return
28+
}
29+
if !strings.HasPrefix(line, prefix) {
30+
serverErr <- fmt.Errorf("line %q does not start with %q", line, prefix)
31+
return
32+
}
33+
}
34+
if _, err := io.WriteString(serverConnection, "PING :nonce\r\n"); err != nil {
35+
serverErr <- err
36+
return
37+
}
38+
pong, err := reader.ReadString('\n')
39+
if err != nil {
40+
serverErr <- err
41+
return
42+
}
43+
if pong != "PONG :nonce\r\n" {
44+
serverErr <- fmt.Errorf("pong = %q", pong)
45+
return
46+
}
47+
_, err = io.WriteString(serverConnection, ":irc.example 001 ivtest :welcome\r\n")
48+
serverErr <- err
49+
}()
50+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
51+
defer cancel()
52+
if err := exchangeIRC(ctx, clientConnection, "ivtest"); err != nil {
53+
t.Fatal(err)
54+
}
55+
if err := <-serverErr; err != nil {
56+
t.Fatal(err)
57+
}
58+
}
59+
60+
func TestExchangeIRCRejectsOversizedLine(t *testing.T) {
61+
clientConnection, serverConnection := net.Pipe()
62+
defer clientConnection.Close()
63+
defer serverConnection.Close()
64+
go func() {
65+
reader := bufio.NewReader(serverConnection)
66+
_, _ = reader.ReadString('\n')
67+
_, _ = reader.ReadString('\n')
68+
_, _ = io.WriteString(serverConnection, strings.Repeat("x", 513)+"\n")
69+
}()
70+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
71+
defer cancel()
72+
if err := exchangeIRC(ctx, clientConnection, "ivtest"); err == nil || !strings.Contains(err.Error(), "oversized IRC line") {
73+
t.Fatalf("oversized line error = %v", err)
74+
}
75+
}
76+
77+
func TestIRC2PIntegration(t *testing.T) {
78+
if os.Getenv("IVNP_IRC2P_INTEGRATION") != "1" {
79+
t.Skip("set IVNP_IRC2P_INTEGRATION=1 to run the live irc2p check")
80+
}
81+
samAddress := cmp.Or(os.Getenv("IVNP_IRC2P_SAM"), "127.0.0.1:7656")
82+
server := cmp.Or(os.Getenv("IVNP_IRC2P_SERVER"), "irc.postman.i2p")
83+
nick, err := randomNick()
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
ctx, cancel := context.WithTimeout(context.Background(), maxRunTime-10*time.Second)
88+
defer cancel()
89+
if err = runIRC2P(ctx, ircConfig{samAddress: samAddress, server: server, port: 6667, nick: nick}, io.Discard); err != nil {
90+
t.Fatal(err)
91+
}
92+
}

0 commit comments

Comments
 (0)