Initial commit of utilities

Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
Jason A. Donenfeld
2021-05-27 12:17:12 +02:00
commit b52ec14d2d
20 changed files with 3764 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"bufio"
"flag"
"fmt"
"log"
"net"
"os"
"regexp"
"strings"
"sync"
"time"
"golang.zx2c4.com/irc/hbot"
)
func main() {
channelsArg := flag.String("channels", "", "channels to join, separated by commas")
serverArg := flag.String("server", "", "server and port")
nickArg := flag.String("nick", "", "nickname")
passwordArg := flag.String("password-file", "", "optional file with password")
messageArg := flag.String("message", "", "message with which to respond")
flag.Parse()
if matched, _ := regexp.MatchString(`^(#[a-zA-Z0-9_-]+,)*(#[a-zA-Z0-9_-]+)$`, *channelsArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid channels")
flag.Usage()
os.Exit(1)
}
if _, _, err := net.SplitHostPort(*serverArg); err != nil {
fmt.Fprintln(os.Stderr, "Invalid server")
flag.Usage()
os.Exit(1)
}
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9\[\]_-]{1,16}$`, *nickArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid nick")
flag.Usage()
os.Exit(1)
}
password := ""
if len(*passwordArg) > 0 {
f, err := os.Open(*passwordArg)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to open password file: %v\n", err)
os.Exit(1)
}
password, err = bufio.NewReader(f).ReadString('\n')
f.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to read password file: %v\n", err)
os.Exit(1)
}
if len(password) > 0 && password[len(password)-1] == '\n' {
password = password[:len(password)-1]
}
}
if len(*messageArg) == 0 {
fmt.Fprintln(os.Stderr, "Missing message")
flag.Usage()
os.Exit(1)
}
const messengerTimeout = time.Minute * 10
messengers := make(map[string]time.Time, 1024)
var messengersMu sync.Mutex
go func() {
for range time.Tick(messengerTimeout / 20) {
messengersMu.Lock()
for nick, last := range messengers {
if time.Since(last) >= messengerTimeout {
delete(messengers, nick)
}
}
messengersMu.Unlock()
}
}()
bot := hbot.NewBot(&hbot.Config{
Host: *serverArg,
Nick: *nickArg,
User: hbot.CommonBotUserPrefix + *nickArg,
Channels: strings.Split(*channelsArg, ","),
Logger: hbot.Logger{Verbosef: log.Printf, Errorf: log.Printf},
Password: password,
})
bot.AddTrigger(hbot.Trigger{
Condition: func(b *hbot.Bot, m *hbot.Message) bool {
if m.Command != "PRIVMSG" || strings.HasPrefix(m.Prefix.User, hbot.CommonBotUserPrefix) || strings.HasPrefix(m.Prefix.User, "~"+hbot.CommonBotUserPrefix) {
return false
}
nick := strings.ToLower(m.Prefix.Name)
messengersMu.Lock()
defer messengersMu.Unlock()
if last, ok := messengers[nick]; ok && time.Since(last) < messengerTimeout {
return false
}
if len(messengers) > 1024*1024*1024 {
return false
}
messengers[nick] = time.Now()
return true
},
Action: func(b *hbot.Bot, m *hbot.Message) {
message := *messageArg
target := m.Prefix.Name
if strings.Contains(m.Param(0), "#") {
target = m.Param(0)
if target[0] == '@' || target[0] == '+' {
target = target[1:]
}
message = m.Prefix.Name + ": " + message
}
log.Printf("Responding to %q in %q", m.Prefix.String(), target)
b.Msg(target, message)
},
})
for {
bot.Run()
time.Sleep(time.Second * 5)
}
}

177
cmd/ircmirror/ircwriters.go Normal file
View File

@@ -0,0 +1,177 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"bufio"
"container/list"
"log"
"math/rand"
"net"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.zx2c4.com/irc/hbot"
)
var words []string
func init() {
f, err := os.Open("/usr/share/dict/words")
if err != nil {
log.Fatalf("Unable to open dictionary: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
matcher := regexp.MustCompile(`^[a-zA-Z0-9_-]{3,}$`)
for scanner.Scan() {
word := scanner.Text()
if !matcher.MatchString(word) {
continue
}
words = append(words, word)
}
if len(words) == 0 {
log.Fatalln("Did not find any words in dictionary")
}
}
func randomNick() string {
return words[rand.Intn(len(words))] + words[rand.Intn(len(words))] + strconv.Itoa(rand.Intn(10000))
}
type ircWriter struct {
mu sync.Mutex
bot *hbot.Bot
nick string
mungedNick string
usageElem *list.Element
name string
}
type ircWriters struct {
mu sync.Mutex
byNick map[string]*ircWriter
byUsage list.List
server string
channel string
}
func (writers *ircWriters) runWriter(name string, dialer func(network, address string) (net.Conn, error)) {
writer := &ircWriter{name: name}
startNick := randomNick()
logf := func(format string, args ...interface{}) {
log.Printf("[DST %s] "+format, append([]interface{}{name}, args...)...)
}
writer.bot = hbot.NewBot(&hbot.Config{
Host: writers.server,
Nick: startNick,
User: hbot.CommonBotUserPrefix + startNick,
Channels: []string{writers.channel},
Dial: dialer,
Logger: hbot.Logger{Verbosef: logf, Errorf: logf},
})
go func() {
<-writer.bot.Joined()
writers.mu.Lock()
defer writers.mu.Unlock()
writer.mu.Lock()
defer writer.mu.Unlock()
writer.usageElem = writers.byUsage.PushBack(writer)
}()
writer.bot.AddTrigger(hbot.Trigger{
Condition: func(bot *hbot.Bot, m *hbot.Message) bool {
return (m.Command == "436" || m.Command == "433") && len(writer.nick) > 0
},
Action: func(bot *hbot.Bot, m *hbot.Message) {
logf("Failed with nick %q, trying %q\n", writer.mungedNick, writer.mungedNick+"_")
writer.mungedNick += "_"
if len(writer.mungedNick) > 16 {
usidx := strings.IndexByte(writer.mungedNick, '_')
uslen := len(writer.mungedNick[usidx:])
if uslen >= 16 {
writer.mungedNick = writer.nick + "-"
if len(writer.mungedNick) > 16 {
writer.mungedNick = writer.nick[:15] + "-"
}
} else {
writer.mungedNick = writer.mungedNick[:16-uslen] + writer.mungedNick[usidx:]
}
}
bot.SetNick(writer.mungedNick)
},
})
writer.bot.Run()
writers.mu.Lock()
writer.mu.Lock()
if writer.usageElem != nil {
writers.byUsage.Remove(writer.usageElem)
delete(writers.byNick, writer.nick)
}
writer.mu.Unlock()
writers.mu.Unlock()
}
func (writers *ircWriters) getWriter(nick string) *ircWriter {
writers.mu.Lock()
if writer, ok := writers.byNick[nick]; ok {
writer.mu.Lock()
if writer.nick == nick {
writers.byUsage.MoveToFront(writer.usageElem)
writers.mu.Unlock()
if writer.bot.Nick() != writer.mungedNick {
log.Printf("[DST %s] Changing nick to %q\n", writer.name, nick)
writer.bot.SetNick(writer.mungedNick)
}
return writer
}
writer.mu.Unlock()
}
if writers.byUsage.Len() == 0 {
writers.mu.Unlock()
return nil
}
writer := writers.byUsage.Back().Value.(*ircWriter)
writer.mu.Lock()
delete(writers.byNick, writer.nick)
writer.nick = nick
writer.mungedNick = nick + "-"
if len(writer.mungedNick) > 16 {
writer.mungedNick = nick[:15] + "-"
}
writers.byNick[nick] = writer
writers.byUsage.MoveToFront(writer.usageElem)
writers.mu.Unlock()
log.Printf("[DST %s] Changing nick to %q\n", writer.name, nick)
writer.bot.SetNick(writer.mungedNick)
return writer
}
func (writers *ircWriters) queueMessage(from, message string) {
writer := writers.getWriter(from)
if writer == nil {
time.AfterFunc(time.Second*3, func() {
writers.queueMessage(from, message)
})
return
}
log.Printf("[DST %s] Queueing message from %q\n", writer.name, from)
writer.bot.Msg(writers.channel, message)
writer.mu.Unlock()
}
func newIrcWriterGroup(server, channel string) *ircWriters {
return &ircWriters{
mu: sync.Mutex{},
byNick: make(map[string]*ircWriter, 400),
server: server,
channel: channel,
}
}

61
cmd/ircmirror/keycache.go Normal file
View File

@@ -0,0 +1,61 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"crypto/rand"
"encoding/base64"
"errors"
"log"
"os"
"strings"
)
const privateKeyCacheFile = "./private-key.cache"
func loadCachedPrivateKey() ([]byte, error) {
bytes, err := os.ReadFile(privateKeyCacheFile)
if err != nil {
return nil, err
}
log.Println("Loading cached private key from file")
privateKeyB64 := strings.TrimSpace(string(bytes))
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, err
}
if len(privateKey) != 32 {
return nil, errors.New("invalid private key")
}
return privateKey, nil
}
func saveCachedPrivateKey(privateKey []byte) error {
if len(privateKey) != 32 {
return errors.New("invalid private key")
}
return os.WriteFile(privateKeyCacheFile, []byte(base64.StdEncoding.EncodeToString(privateKey)+"\n"), 0600)
}
func loadOrGeneratePrivateKey() ([]byte, error) {
privateKey, err := loadCachedPrivateKey()
if err == nil {
return privateKey, nil
}
log.Println("Generating new private key")
var k [32]byte
_, err = rand.Read(k[:])
if err != nil {
return nil, err
}
k[0] &= 248
k[31] = (k[31] & 127) | 64
err = saveCachedPrivateKey(k[:])
if err != nil {
return nil, err
}
return k[:], nil
}

158
cmd/ircmirror/main.go Normal file
View File

@@ -0,0 +1,158 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"flag"
"fmt"
"log"
"math/rand"
"net"
"os"
"regexp"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
"golang.zx2c4.com/irc/hbot"
)
func raiseFileLimit() error {
var lim unix.Rlimit
err := unix.Getrlimit(syscall.RLIMIT_NOFILE, &lim)
if err != nil {
return err
}
if lim.Cur == lim.Max {
return nil
}
log.Printf("Raising file limit from %d to %d\n", lim.Cur, lim.Max)
lim.Cur = lim.Max
return unix.Setrlimit(syscall.RLIMIT_NOFILE, &lim)
}
func main() {
accountIdArg := flag.String("account-id", "", "account ID number")
srcChannelArg := flag.String("src-channel", "", "source channel")
dstChannelArg := flag.String("dst-channel", "", "destination channel")
srcServerArg := flag.String("src-server", "", "source server")
dstServerArg := flag.String("dst-server", "", "destination server")
flag.Parse()
if matched, _ := regexp.MatchString(`^[0-9]+$`, *accountIdArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid account ID")
flag.Usage()
os.Exit(1)
}
if matched, _ := regexp.MatchString(`^#[a-zA-Z0-9_-]+$`, *srcChannelArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid source channel")
flag.Usage()
os.Exit(1)
}
if matched, _ := regexp.MatchString(`^#[a-zA-Z0-9_-]+$`, *dstChannelArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid destination channel")
flag.Usage()
os.Exit(1)
}
if _, _, err := net.SplitHostPort(*srcServerArg); err != nil {
fmt.Fprintln(os.Stderr, "Invalid source server")
flag.Usage()
os.Exit(1)
}
if _, _, err := net.SplitHostPort(*dstServerArg); err != nil {
fmt.Fprintln(os.Stderr, "Invalid destination server")
flag.Usage()
os.Exit(1)
}
privateKey, err := loadOrGeneratePrivateKey()
if err != nil {
log.Fatalln(err)
}
endpoints, err := getVpnEndpoints()
if err != nil {
log.Fatalln(err)
}
vpnConf, err := registerVpnConf(privateKey, *accountIdArg)
if err != nil {
log.Fatalln(err)
}
rand.Seed(time.Now().UnixNano())
err = raiseFileLimit()
if err != nil {
log.Fatalln(err)
}
dialers, err := makeDialers(vpnConf, endpoints)
if err != nil {
log.Fatalln(err)
}
if len(dialers) < 2 {
log.Fatalln("Not enough dialers returned")
}
writers := newIrcWriterGroup(*dstServerArg, *dstChannelArg)
const writersPerIP = 10
type nextDialer struct {
sync.Mutex
*dialer
}
dialNext := func(nd *nextDialer, v4 bool) {
var last *dialer
for {
nd.Lock()
if nd.dialer == last {
nd.dialer = &dialers[rand.Intn(len(dialers))]
}
last = nd.dialer
nd.Unlock()
name := last.name
dial := last.dial
if v4 {
name += "-v4"
dial, _ = last.splitByAf()
} else {
name += "-v6"
_, dial = last.splitByAf()
}
writers.runWriter(name, dial)
}
}
for i := 0; i < vpnProviderMaxEndpointsInParallel/2; i++ {
nd := new(nextDialer)
for i := 0; i < writersPerIP; i++ {
go dialNext(nd, true)
go dialNext(nd, false)
}
}
srcDial := dialers[rand.Intn(len(dialers))]
logf := func(format string, args ...interface{}) {
log.Printf("[SRC %s] "+format, append([]interface{}{srcDial.name}, args...)...)
}
bot := hbot.NewBot(&hbot.Config{
Host: *srcServerArg,
Nick: randomNick(),
Channels: []string{*srcChannelArg},
Dial: srcDial.dial,
Logger: hbot.Logger{Verbosef: logf, Errorf: logf},
})
bot.AddTrigger(hbot.Trigger{
Condition: func(b *hbot.Bot, m *hbot.Message) bool {
return m.Param(0) == *srcChannelArg && m.Command == "PRIVMSG" &&
!strings.HasPrefix(m.Prefix.User, hbot.CommonBotUserPrefix) &&
!strings.HasPrefix(m.Prefix.User, "~"+hbot.CommonBotUserPrefix)
},
Action: func(b *hbot.Bot, m *hbot.Message) {
writers.queueMessage(m.Prefix.Name, m.Trailing())
},
})
for {
bot.Run()
time.Sleep(time.Second * 5)
}
}

84
cmd/ircmirror/net.go Normal file
View File

@@ -0,0 +1,84 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"encoding/hex"
"fmt"
"log"
"net"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
)
func makeNet(conf *vpnConf, endpoint vpnEndpoint) (*device.Device, *netstack.Net, error) {
var localAddresses, dnsServers []net.IP
for _, ip := range conf.ips {
localAddresses = append(localAddresses, ip.IPAddr().IP)
}
for _, ip := range conf.dnses {
dnsServers = append(dnsServers, ip.IPAddr().IP)
}
tun, stack, err := netstack.CreateNetTUN(localAddresses, dnsServers, 1420)
if err != nil {
return nil, nil, err
}
logf := func(format string, args ...interface{}) {
log.Printf("[NET %s] "+format, append([]interface{}{endpoint.name}, args...)...)
}
dev := device.NewDevice(tun, conn.NewStdNetBind(), &device.Logger{logf, logf})
err = dev.IpcSet(fmt.Sprintf("private_key=%s\npublic_key=%s\nendpoint=%s\nallowed_ip=0.0.0.0/0\nallowed_ip=::/0\n",
hex.EncodeToString(conf.privateKey), hex.EncodeToString(endpoint.publicKey), endpoint.endpoint.String()))
if err != nil {
return nil, nil, err
}
err = dev.Up()
if err != nil {
return nil, nil, err
}
return dev, stack, nil
}
type dialer struct {
name string
dial func(network, address string) (net.Conn, error)
}
func makeDialers(conf *vpnConf, endpoints []vpnEndpoint) (dialers []dialer, err error) {
var devs []*device.Device
defer func() {
if err != nil {
for _, dev := range devs {
dev.Close()
}
}
}()
for _, endpoint := range endpoints {
dev, stack, err := makeNet(conf, endpoint)
if err != nil {
return nil, err
}
devs = append(devs, dev)
dialers = append(dialers, dialer{endpoint.name, stack.Dial})
}
return dialers, nil
}
func (d *dialer) splitByAf() (v4, v6 func(network, address string) (net.Conn, error)) {
return func(network, address string) (net.Conn, error) {
if len(network) == 3 {
network += "4"
}
return d.dial(network, address)
}, func(network, address string) (net.Conn, error) {
if len(network) == 3 {
network += "6"
}
return d.dial(network, address)
}
}

View File

@@ -0,0 +1,137 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"golang.org/x/crypto/curve25519"
"inet.af/netaddr"
)
const vpnProviderMaxEndpointsInParallel = 20
type vpnEndpoint struct {
publicKey []byte
endpoint netaddr.IPPort
name string
country string
}
type vpnConf struct {
privateKey []byte
ips []netaddr.IP
dnses []netaddr.IP
}
type apiRelaysWireGuardV1Key []byte
func (key *apiRelaysWireGuardV1Key) UnmarshalText(text []byte) error {
k, err := base64.StdEncoding.DecodeString(string(text))
if err != nil {
return err
}
if len(k) != 32 {
return errors.New("key must be 32 bytes")
}
*key = k
return nil
}
type apiRelaysWireGuardV1Relay struct {
Hostname string `json:"hostname"`
EndpointV4 netaddr.IP `json:"ipv4_addr_in"`
EndpointV6 netaddr.IP `json:"ipv6_addr_in"`
PublicKey apiRelaysWireGuardV1Key `json:"public_key"`
MultihopPort uint16 `json:"multihop_port"`
}
type apiRelaysWireGuardV1City struct {
Name string `json:"name"`
Code string `json:"code"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"Longitude"`
Relays []apiRelaysWireGuardV1Relay `json:"relays"`
}
type apiRelaysWireGuardV1Country struct {
Name string `json:"name"`
Code string `json:"code"`
Cities []apiRelaysWireGuardV1City `json:"cities"`
}
type apiRelaysWireGuardV1Root struct {
Countries []apiRelaysWireGuardV1Country `json:"countries"`
}
func getVpnEndpoints() ([]vpnEndpoint, error) {
log.Println("Getting VPN server list")
resp, err := http.Get("https://api.mullvad.net/public/relays/wireguard/v1/")
if err != nil {
return nil, err
}
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var relays apiRelaysWireGuardV1Root
err = json.Unmarshal(bytes, &relays)
if err != nil {
return nil, err
}
var endpoints []vpnEndpoint
for _, country := range relays.Countries {
for _, city := range country.Cities {
for _, relay := range city.Relays {
endpoints = append(endpoints, vpnEndpoint{
publicKey: relay.PublicKey,
endpoint: netaddr.IPPortFrom(relay.EndpointV4, 51820),
name: strings.TrimSuffix(relay.Hostname, "-wireguard"),
})
}
}
}
return endpoints, nil
}
func registerVpnConf(privateKey []byte, accountId string) (*vpnConf, error) {
log.Println("Registering VPN private key")
publicKey, err := curve25519.X25519(privateKey, curve25519.Basepoint)
if err != nil {
return nil, err
}
resp, err := http.PostForm("https://api.mullvad.net/wg/", url.Values{
"account": {accountId},
"pubkey": {base64.StdEncoding.EncodeToString(publicKey)},
})
if err != nil {
return nil, err
}
ipBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if match, _ := regexp.Match(`^[0-9a-f:/.,]+$`, ipBytes); !match {
return nil, fmt.Errorf("registration rejected: %q", string(ipBytes))
}
conf := &vpnConf{privateKey: privateKey, dnses: []netaddr.IP{netaddr.MustParseIP("193.138.218.74")}}
for _, ipStr := range strings.Split(string(ipBytes), ",") {
ip, err := netaddr.ParseIPPrefix(strings.TrimSpace(ipStr))
if err != nil {
return nil, err
}
conf.ips = append(conf.ips, ip.IP())
}
return conf, nil
}

178
cmd/wurgurboo/cgit.go Normal file
View File

@@ -0,0 +1,178 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"container/list"
"encoding/hex"
"encoding/xml"
"io"
"log"
"net/http"
"path"
"sync"
"time"
)
type CgitCommit struct {
Text string `xml:",chardata"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Author struct {
Text string `xml:",chardata"`
Name string `xml:"name"`
Email string `xml:"email"`
} `xml:"author"`
Published string `xml:"published"`
Link struct {
Text string `xml:",chardata"`
Rel string `xml:"rel,attr"`
Type string `xml:"type,attr"`
Href string `xml:"href,attr"`
} `xml:"link"`
ID string `xml:"id"`
Content []struct {
Text string `xml:",chardata"`
Type string `xml:"type,attr"`
Div struct {
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Pre string `xml:"pre"`
} `xml:"div"`
} `xml:"content"`
}
type cgitFeed struct {
XMLName xml.Name `xml:"feed"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Title string `xml:"title"`
Subtitle string `xml:"subtitle"`
Link struct {
Text string `xml:",chardata"`
Rel string `xml:"rel,attr"`
Type string `xml:"type,attr"`
Href string `xml:"href,attr"`
} `xml:"link"`
Entry []CgitCommit `xml:"entry"`
}
type cgitFeedStatus struct {
repo string
title string
seenEntries map[[32]byte]bool
orderedEntries list.List
}
type CgitFeedMonitorer struct {
feeds []*cgitFeedStatus
updates chan CgitFeedUpdate
ticker *time.Ticker
wg sync.WaitGroup
mu sync.Mutex
}
type CgitFeedUpdate struct {
RepoTitle string
Commit *CgitCommit
}
func NewCgitFeedMonitorer(pollInterval time.Duration) *CgitFeedMonitorer {
fm := &CgitFeedMonitorer{
updates: make(chan CgitFeedUpdate, 32),
ticker: time.NewTicker(pollInterval),
}
fm.wg.Add(1)
go func() {
defer fm.wg.Done()
for range fm.ticker.C {
fm.mu.Lock()
for _, feed := range fm.feeds {
fm.wg.Add(1)
go func(feed *cgitFeedStatus) {
defer fm.wg.Done()
fm.updateFeed(feed, true)
}(feed)
}
fm.mu.Unlock()
}
}()
return fm
}
func (fm *CgitFeedMonitorer) Stop() {
fm.ticker.Stop()
fm.wg.Wait()
close(fm.updates)
}
func (fm *CgitFeedMonitorer) Updates() <-chan CgitFeedUpdate {
return fm.updates
}
func (fm *CgitFeedMonitorer) AddFeed(repo string) {
go func() {
status := &cgitFeedStatus{
repo: repo,
title: path.Base(repo),
seenEntries: make(map[[32]byte]bool, 1024),
}
fm.updateFeed(status, false)
fm.mu.Lock()
fm.feeds = append(fm.feeds, status)
fm.mu.Unlock()
}()
}
func (fm *CgitFeedMonitorer) updateFeed(fs *cgitFeedStatus, alert bool) {
feed, err := fs.fetchFeed()
if err != nil {
log.Printf("Unable to fetch commits for %q: %v", fs.title, err)
return
}
for i := len(feed.Entry) - 1; i >= 0; i-- {
commit := &feed.Entry[i]
var commitID [32]byte
if hex.DecodedLen(len(commit.ID)) > len(commitID) {
continue
}
n, err := hex.Decode(commitID[:], []byte(commit.ID))
if err != nil || n < 20 {
continue
}
if _, ok := fs.seenEntries[commitID]; ok {
continue
}
fs.seenEntries[commitID] = true
fs.orderedEntries.PushBack(commitID)
for len(fs.seenEntries) > 1024 {
first := fs.orderedEntries.Front()
delete(fs.seenEntries, first.Value.([32]byte))
fs.orderedEntries.Remove(first)
}
if alert {
fm.updates <- CgitFeedUpdate{fs.title, commit}
}
}
}
func (fs *cgitFeedStatus) fetchFeed() (*cgitFeed, error) {
resp, err := http.Get(fs.repo + "/atom/")
if err != nil {
return nil, err
}
bytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
var feed cgitFeed
err = xml.Unmarshal(bytes, &feed)
if err != nil {
return nil, err
}
return &feed, nil
}

101
cmd/wurgurboo/main.go Normal file
View File

@@ -0,0 +1,101 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2021 Jason A. Donenfeld. All Rights Reserved.
*/
package main
import (
"bufio"
"flag"
"fmt"
"log"
"net"
"os"
"regexp"
"time"
"golang.zx2c4.com/irc/hbot"
)
func main() {
channelArg := flag.String("channel", "", "channel to join")
serverArg := flag.String("server", "", "server and port")
nickArg := flag.String("nick", "", "nickname")
passwordArg := flag.String("password-file", "", "optional file with password")
flag.Parse()
if matched, _ := regexp.MatchString(`^#[a-zA-Z0-9_-]+$`, *channelArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid channel")
flag.Usage()
os.Exit(1)
}
if _, _, err := net.SplitHostPort(*serverArg); err != nil {
fmt.Fprintln(os.Stderr, "Invalid server")
flag.Usage()
os.Exit(1)
}
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9\[\]_-]{1,16}$`, *nickArg); !matched {
fmt.Fprintln(os.Stderr, "Invalid nick")
flag.Usage()
os.Exit(1)
}
password := ""
if len(*passwordArg) > 0 {
f, err := os.Open(*passwordArg)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to open password file: %v\n", err)
os.Exit(1)
}
password, err = bufio.NewReader(f).ReadString('\n')
f.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to read password file: %v\n", err)
os.Exit(1)
}
if len(password) > 0 && password[len(password)-1] == '\n' {
password = password[:len(password)-1]
}
}
feeds := NewCgitFeedMonitorer(time.Second * 10)
feeds.AddFeed("https://git.zx2c4.com/wireguard-linux/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-tools/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-linux-compat/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-windows/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-go/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-freebsd/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-openbsd/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-android/")
feeds.AddFeed("https://git.zx2c4.com/android-wireguard-module-builder/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-apple/")
feeds.AddFeed("https://git.zx2c4.com/wireguard-rs/")
feeds.AddFeed("https://git.zx2c4.com/wintun/")
feeds.AddFeed("https://git.zx2c4.com/wg-dynamic/")
bot := hbot.NewBot(&hbot.Config{
Host: *serverArg,
Nick: *nickArg,
Realname: "Your Friendly Neighborhood WurGur Bot",
Channels: []string{*channelArg},
Logger: hbot.Logger{Verbosef: log.Printf, Errorf: log.Printf},
Password: password,
})
urlShortener := regexp.MustCompile(`(.*\?id=[a-f0-9]{8})([a-f0-9]+)$`)
go func() {
for commit := range feeds.Updates() {
<-bot.Joined()
log.Printf("New commit %s in %s", commit.Commit.ID, commit.RepoTitle)
url := commit.Commit.Link.Href
if matches := urlShortener.FindStringSubmatch(url); len(matches) == 3 {
url = matches[1]
}
bot.Msg(*channelArg, fmt.Sprintf("\x01ACTION found a new commit in \x0303%s\x0f - \x0306%s\x0f - %s\x01", commit.RepoTitle, commit.Commit.Title, url))
}
}()
for {
bot.Run()
time.Sleep(time.Second * 5)
}
}