diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69a34fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.gocache +protohacking diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..803bf24 --- /dev/null +++ b/Justfile @@ -0,0 +1,19 @@ +# this is my deploy script, u dont need to worry abt this + +# if ur wondering im on an arm macbook so when i deploy +# i need to compile to amd64, so i just do it in a docker container +build: + docker run \ + -v "$(pwd):/src" \ + --platform linux/amd64 \ + --rm \ + -w /src \ + golang:1.24-alpine \ + /src/build.sh +purge-remote: + ssh redbox "sudo rm /usr/local/bin/protohacking || true" +upload: + dd if=protohacking | ssh redbox "sudo dd of=/usr/local/bin/protohacking && sudo chmod 755 /usr/local/bin/protohacking" +push: build purge-remote upload +start-remote: push + ssh redbox "protohacking" diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..97e1da0 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env ash +GOCACHE=/src/.gocache go build -o protohacking -ldflags "-s -w" . diff --git a/cmd/budgetchat.go b/cmd/budgetchat.go new file mode 100644 index 0000000..921e7e5 --- /dev/null +++ b/cmd/budgetchat.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "git.red-panda.pet/pandaware/protohacking/x/budgetchat" + "github.com/spf13/cobra" +) + +var budgetChat = &cobra.Command{ + Use: "budgetchat", + Aliases: []string{"p3"}, + Short: "Problem 3: Budget Chat", + Run: func(cmd *cobra.Command, args []string) { + runServer(budgetchat.New) + }, +} + +func init() { + rootCmd.AddCommand(budgetChat) +} diff --git a/cmd/meanstoanend.go b/cmd/meanstoanend.go new file mode 100644 index 0000000..3a7fbcb --- /dev/null +++ b/cmd/meanstoanend.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "git.red-panda.pet/pandaware/protohacking/x/meanstoanend" + "github.com/spf13/cobra" +) + +var meansToAnEnd = &cobra.Command{ + Use: "meanstoanend", + Aliases: []string{"p2"}, + Short: "Problem 2: Means to an End", + Run: func(cmd *cobra.Command, args []string) { + runServer(meanstoanend.New) + }, +} + +func init() { + rootCmd.AddCommand(meansToAnEnd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..e9b29d0 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "log" + "net" + + "github.com/spf13/cobra" +) + +var ( + ip net.IP + port uint16 + proto string + v6 bool +) + +var rootCmd = &cobra.Command{ + Use: "protohacking", + Short: "Protohackers challenge servers", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + pflags := rootCmd.PersistentFlags() + pflags.Uint16VarP(&port, "port", "p", 8088, "Port to TCP listen on") + pflags.IPVarP(&ip, "ip", "i", net.IPv6unspecified, "Address to listen on") + pflags.BoolVarP(&v6, "v6", "6", true, "Whether to use IPv6") + pflags.StringVar(&proto, "proto", "tcp", "Either tcp or udp") +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Fatal("fatal error", "err", err) + } +} diff --git a/cmd/smoke-test.go b/cmd/smoke-test.go new file mode 100644 index 0000000..e6f17ae --- /dev/null +++ b/cmd/smoke-test.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "git.red-panda.pet/pandaware/protohacking/x/smoketest" + "github.com/spf13/cobra" +) + +var smokeTest = &cobra.Command{ + Use: "smoketest", + Aliases: []string{"p0"}, + Short: "Problem 0: Smoke Test", + Run: func(cmd *cobra.Command, args []string) { + runServer(smoketest.New) + }, +} + +func init() { + rootCmd.AddCommand(smokeTest) +} diff --git a/cmd/util.go b/cmd/util.go new file mode 100644 index 0000000..d66a561 --- /dev/null +++ b/cmd/util.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "net" + "net/netip" + + "github.com/charmbracelet/log" +) + +func runServer(server func(net.Listener) error) { + if proto != "tcp" && proto != "udp" { + log.Fatal("unsupported protocol, must be either tcp or udp") + } + + if v6 { + proto += "6" + } + + addr, ok := netip.AddrFromSlice(ip) + if !ok { + log.Fatal("invalid ip") + } + + ap := netip.AddrPortFrom(addr, port) + + listener, err := net.Listen(proto, ap.String()) + if err != nil { + log.Fatal("unable to listen", "err", err) + } + + log.Info("starting server", "proto", proto, "ip", ip, "port", port) + + err = server(listener) + if err != nil { + log.Fatal("error while handling connections", "err", err) + } +} diff --git a/go.mod b/go.mod index 3830047..bf540ec 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,28 @@ -module git.red-panda.pet/red/protohacking +module git.red-panda.pet/pandaware/protohacking go 1.24.3 + +require ( + github.com/charmbracelet/log v0.4.2 + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5b12562 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2737f49 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.red-panda.pet/pandaware/protohacking/cmd" + +func main() { + cmd.Execute() +} diff --git a/x/budgetchat/server.go b/x/budgetchat/server.go new file mode 100644 index 0000000..b4e31e9 --- /dev/null +++ b/x/budgetchat/server.go @@ -0,0 +1,250 @@ +package budgetchat + +import ( + "bufio" + "errors" + "net" + "strings" + "sync" + + "github.com/charmbracelet/log" +) + +func isGoodAscii(str string) bool { + // we disallow anything outside the printable ascii range + // that means no control characters + for _, c := range str { + if c < ' ' || c > '~' { + return false + } + } + + return true +} + +type client struct { + id uint + name string + conn net.Conn + reader *bufio.Reader + bad bool +} + +func (c *client) read() (string, error) { + // get the client's name + line, err := c.reader.ReadString('\n') + if err != nil { + log.Error("unable to read name from client", "err", err) + c.conn.Close() + return "", err + } + + return line[:len(line)-1], nil +} + +func (c *client) send(msg string) error { + if c.bad { + log.Warn("not sending to bad client", "remote", c.conn.RemoteAddr()) + return errors.New("trying to send to a bad client") + } + + _, err := c.conn.Write([]byte(msg)) + if err != nil { + c.bad = true + log.Warn("marking client as bad", "err", err) + c.conn.Close() + return err + } + + return nil +} + +type room struct { + inc uint + lock *sync.RWMutex + clients []*client +} + +func (r *room) whoishere() string { + r.lock.RLock() + defer r.lock.RUnlock() + + builder := new(strings.Builder) + builder.WriteString("* Currently online: ") + if len(r.clients) == 0 { + log.Info("nobody is here!") + builder.WriteString("nobody!\n") + return builder.String() + } + + for i, client := range r.clients { + if client == nil { + continue + } + + builder.WriteString(client.name) + if i != len(r.clients) { + builder.WriteString(", ") + } + } + + builder.WriteRune('\n') + return builder.String() +} + +func (r *room) leave(client *client) { + r.lock.Lock() + defer r.lock.Unlock() + + leaveMsg := "* " + client.name + " went offline!\n" + + // loop over the current clients, saving the index of the one we're removing + // and messaging everyone else that they left + removable := []int{} + for i, c := range r.clients { + if c.bad || c.id == client.id { + removable = append(removable, i) + } else { + c.send(leaveMsg) + } + } + + // remove the client who left and any bad connections + for _, index := range removable { + r.clients[index] = r.clients[len(r.clients)-1] + r.clients = r.clients[:len(r.clients)-1] + } +} + +func (r *room) addClient(client *client) { + r.lock.Lock() + defer r.lock.Unlock() + + client.id = r.inc + r.inc += 1 + r.clients = append(r.clients, client) +} + +func (r *room) join(conn net.Conn) { + log := log.Default().WithPrefix(conn.RemoteAddr().String()) + + log.Info("client connecting") + client := new(client) + client.conn = conn + client.reader = bufio.NewReader(conn) + client.bad = false + + // start by asking for a name + err := client.send("* Welcome, what's your name?\n") + if err != nil { + log.Warn("kicking client: unable to write welcome", "err", err) + conn.Close() + return + } + + // get the client's name + name, err := client.read() + if err != nil { + log.Warn("kicking client: unable to read name", "err", err) + return + } + + // check to make sure the name is ok + if len(name) > 64 { + log.Warn("kicking client: name too long") + client.send("* You've been kicked: name too long\n") + conn.Close() + return + } + + if !isGoodAscii(name) { + log.Warn("kicking client: name contains illegal characters") + client.send("* You've been kicked: name contains illegal characters\n") + conn.Close() + return + } + + // establish the name + client.name = name + log.Info("name established", "name", client.name) + + log.Info("sending room membership") + err = client.send(r.whoishere()) + if err != nil { + + } + + // announce the new client + joinMsg := "* " + client.name + " is now online\n" + for _, c := range r.clients { + c.send(joinMsg) + } + + // add the client to the room + r.addClient(client) + + log.Info("assigned id", "id", client.id) + + // start message loop + for { + line, err := client.read() + if err != nil { + break + } + + err = r.message(client, line) + // if they send a bad message we tell them and kick them + if err != nil { + log.Warn("kicking client: bad message", "err", err) + client.send("* You've been kicked: " + err.Error() + "\n") + break + } + } + + r.leave(client) + + return +} + +func (r *room) message(from *client, msg string) error { + r.lock.RLock() + defer r.lock.RUnlock() + + if len(msg) > 1000 { + return errors.New("message too long") + } + + if !isGoodAscii(msg) { + return errors.New("message contains illegal characters") + } + + out := "[" + from.name + "] " + msg + "\n" + + for _, client := range r.clients { + if client == nil { + continue + } + + if client.id == from.id { + continue + } + + client.send(out) + } + + return nil +} + +func New(listener net.Listener) error { + room := new(room) + room.lock = new(sync.RWMutex) + room.clients = []*client{} + + for { + conn, err := listener.Accept() + if err != nil { + return err + } + go room.join(conn) + } +} diff --git a/x/meanstoanend/server.go b/x/meanstoanend/server.go new file mode 100644 index 0000000..e0a35d3 --- /dev/null +++ b/x/meanstoanend/server.go @@ -0,0 +1,106 @@ +package meanstoanend + +import ( + "encoding/binary" + "errors" + "io" + "net" + + "github.com/charmbracelet/log" +) + +type message struct { + a int32 + b int32 +} + +type client struct { + conn net.Conn + prices map[int32]int32 + log *log.Logger + buf []byte +} + +func (c *client) handleMessage() error { + _, err := io.ReadFull(c.conn, c.buf) + if err != nil { + return err + } + + msg := new(message) + + _, err = binary.Decode(c.buf[1:5], binary.BigEndian, &msg.a) + if err != nil { + return err + } + + _, err = binary.Decode(c.buf[5:], binary.BigEndian, &msg.b) + if err != nil { + return err + } + + switch c.buf[0] { + case 'I': + c.prices[msg.a] = msg.b + case 'Q': + var i int64 + var mean int64 + + for k, v := range c.prices { + if k >= msg.a && k <= msg.b { + mean += int64(v) + i += 1 + } + } + + if i > 0 { + mean /= i + } + + binary.Write(c.conn, binary.BigEndian, int32(mean)) + default: + c.conn.Close() + return errors.New("bad message") + } + + return nil +} + +func acceptConn(conn net.Conn) { + log.Info("accepting connection", "remote", conn.RemoteAddr()) + + var err error + + c := new(client) + c.log = log.Default().WithPrefix(conn.RemoteAddr().String()) + + c.buf = make([]byte, 9) + c.conn = conn + c.prices = map[int32]int32{} + + defer c.conn.Close() + + for { + c.log.Debug("waiting for message", "remote", conn.RemoteAddr()) + err = c.handleMessage() + if err != nil { + if err != io.EOF { + c.log.Error("unable to read data", "err", err) + } + break + } + } + + c.log.Info("released connection") +} + +func New(listener net.Listener) error { + for { + conn, err := listener.Accept() + if err != nil { + return err + } + + go acceptConn(conn) + } +} diff --git a/x/smoketest/server.go b/x/smoketest/server.go new file mode 100644 index 0000000..209e2cc --- /dev/null +++ b/x/smoketest/server.go @@ -0,0 +1,32 @@ +package smoketest + +import ( + "bytes" + "io" + "net" +) + +func handleConn(conn net.Conn) error { + buf := &bytes.Buffer{} + + _, err := buf.ReadFrom(conn) + if err != nil { + return err + } + + _, err = io.Copy(conn, buf) + if err != nil { + return err + } + + return nil +} + +func New(listener net.Listener) error { + for { + conn, err := listener.Accept() + if err == nil { + go handleConn(conn) + } + } +}