This commit is contained in:
basil 2025-06-17 00:47:00 -04:00
parent a6ef3bf1b6
commit f174c4e02d
Signed by: basil
SSH key fingerprint: SHA256:y04xIFL/yqNaG9ae9Vl95vELtHfApGAIoOGLeVLP/fE
14 changed files with 626 additions and 1 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.gocache
protohacking

19
Justfile Normal file
View file

@ -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"

2
build.sh Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env ash
GOCACHE=/src/.gocache go build -o protohacking -ldflags "-s -w" .

19
cmd/budgetchat.go Normal file
View file

@ -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)
}

19
cmd/meanstoanend.go Normal file
View file

@ -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)
}

37
cmd/root.go Normal file
View file

@ -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)
}
}

19
cmd/smoke-test.go Normal file
View file

@ -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)
}

37
cmd/util.go Normal file
View file

@ -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)
}
}

27
go.mod
View file

@ -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
)

51
go.sum Normal file
View file

@ -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=

7
main.go Normal file
View file

@ -0,0 +1,7 @@
package main
import "git.red-panda.pet/pandaware/protohacking/cmd"
func main() {
cmd.Execute()
}

250
x/budgetchat/server.go Normal file
View file

@ -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)
}
}

106
x/meanstoanend/server.go Normal file
View file

@ -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)
}
}

32
x/smoketest/server.go Normal file
View file

@ -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)
}
}
}