commit d952c6a454fcb87514b9bc0bd69f4468cf9613c6 Author: Kioubit Date: Mon Mar 6 20:02:36 2023 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62c8935 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5959a97 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# ColorPing +![IPv6 Canvas example](screenshot.png?raw=true) + +## How does it work? +Each IPv6 address in a /64 IPv6 subnet is associated to one pixel with color (RGB) information. +When an address is pinged, the corresponding pixel is changed on the canvas and displayed +to all viewers via a webpage. + +## Setup +Run and assign a /64 IPv6 subnet to the created interface named `canvas`. +The program needs to run as root or with the `CAP_NET_ADMIN` capability + +### Example +``` +./ColorPing +ip addr add fdcf:8538:9ad5:3333::1/64 dev canvas +ip link set up canvas +``` +### Ping format +``` +????:????:????:????:XXXX:YYYY:11RR:GGBB +``` +Where: +- ``????`` can be anything +- ``XXXX`` must be the target X coordinate of the canvas, encoded as hexadecimal +- ``YYYY`` must be the target Y coordinate of the canvas, encoded as hexadecimal +- ``RR`` target "red" value (0-255), encoded as hexadecimal +- ``GG`` target "green" value (0-255), encoded as hexadecimal +- ``BB`` target "blue" value (0-255), encoded as hexadecimal \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f2185a --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module ColorPing + +go 1.20 + +require github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 + +require golang.org/x/sys v0.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d97ca71 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/http.go b/http.go new file mode 100644 index 0000000..f63e7c0 --- /dev/null +++ b/http.go @@ -0,0 +1,220 @@ +package main + +import ( + "embed" + "html/template" + "log" + "math" + "net" + "net/http" + "strings" + "sync" + "time" +) + +//go:embed template.html +var embedFS embed.FS +var htmlTemplate *template.Template + +type clientState int + +const ( + INITIAL = 0 + ACTIVE = iota +) + +type client struct { + channel chan string + state clientState +} + +var ( + clientCounter uint32 = 0 + clientCounterMutex sync.Mutex + + clients = make(map[uint32]*client) + clientMutex sync.RWMutex +) + +func getClientID() uint32 { + clientCounterMutex.Lock() + defer clientCounterMutex.Unlock() + clientCounter++ + if clientCounter == math.MaxUint32 { + clientCounter = 0 + clearClients() + } + return clientCounter +} + +func clearClients() { + clientMutex.Lock() + defer clientMutex.Unlock() + clients = make(map[uint32]*client) +} + +func httpServer() { + var err error + + htmlTemplate = template.Must(template.ParseFS(embedFS, "template.html")) + http.HandleFunc("/stream", stream) + http.HandleFunc("/", serveRoot) + err = http.ListenAndServe("0.0.0.0:9090", nil) + if err != nil { + log.Fatalln(err) + } +} + +func getInterfaceBaseIP() string { + iFace, err := net.InterfaceByName(interfaceName) + if err != nil { + return "" + } + addresses, err := iFace.Addrs() + if err != nil { + return "" + } + gua := "" + ula := "" + for _, v := range addresses { + addr := v.String() + if !strings.Contains(addr, ":") { + continue + } + _, anet, err := net.ParseCIDR(addr) + if err != nil { + continue + } + if anet.IP.IsLinkLocalUnicast() { + continue + } + if anet.IP.IsGlobalUnicast() { + gua = strings.Split(anet.String(), "/")[0] + } + if anet.IP.IsPrivate() { + ula = strings.Split(anet.String(), "/")[0] + } + } + if gua != "" { + return gua + } else { + return ula + } +} + +func serveRoot(w http.ResponseWriter, r *http.Request) { + if r.RequestURI != "/" { + w.WriteHeader(404) + _, _ = w.Write([]byte("404 not found")) + return + } + type pageData struct { + BaseIP string + CanvasWidth int + CanvasHeight int + } + baseIP := getInterfaceBaseIP() + if len(baseIP) == 21 { + baseIP = strings.TrimSuffix(baseIP, ":") + } + err := htmlTemplate.Execute(w, pageData{ + BaseIP: baseIP, + CanvasHeight: 512, + CanvasWidth: 512, + }) + if err != nil { + log.Println(err) + } +} + +func streamServer() { + for { + clientMutex.RLock() + if len(clients) == 0 { + for { + if len(clients) == 0 { + clientMutex.RUnlock() + time.Sleep(1 * time.Second) + clientMutex.RLock() + } else { + break + } + } + } + + requiresInitial := false + requiresUpdate := false + for _, v := range clients { + if v.state == INITIAL { + requiresInitial = true + } else { + requiresUpdate = true + } + if requiresInitial && requiresUpdate { + break + } + } + + dataInitial, dataUpdate := getPicture(requiresInitial, requiresUpdate) + + for _, v := range clients { + if v.state == INITIAL { + v.state = ACTIVE + select { + case v.channel <- dataInitial: + default: + continue + } + } else { + if dataUpdate != "0" { + select { + case v.channel <- dataUpdate: + default: + continue + } + } + } + time.Sleep(400 * time.Millisecond) + } + clientMutex.RUnlock() + time.Sleep(10 * time.Millisecond) + } +} + +func stream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + messageChan := make(chan string, 40) + id := getClientID() + newClient := client{ + channel: messageChan, + state: INITIAL, + } + clientMutex.Lock() + clients[id] = &newClient + clientMutex.Unlock() + go func() { + // Listen for connection close + <-r.Context().Done() + clientMutex.Lock() + close(messageChan) + delete(clients, id) + clientMutex.Unlock() + }() + + for { + data := <-messageChan + if data == "" { + return + } + _, _ = w.Write([]byte(data)) + flusher.Flush() + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..165e606 --- /dev/null +++ b/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "fmt" + "github.com/songgao/water" + "image" + "image/color" + "image/png" + "log" + "sync" +) + +const interfaceName = "canvas" +const handlerCount = 4 + +func main() { + prePopulatePixelArray() + packetChan := make(chan *[]byte, 1000) + for i := 0; i < handlerCount; i++ { + go packetHandler(packetChan) + } + go startInterface(packetChan) + go streamServer() + fmt.Println("Kioubit ColorPing started") + fmt.Println("Interface name:", interfaceName) + httpServer() +} + +func prePopulatePixelArray() { + for x := 0; x < len(pixelArray); x++ { + for y := 0; y < len(pixelArray[x]); y++ { + pixelArray[x][y] = &pixel{ + r: uint8(0), + g: uint8(0), + b: uint8(0), + } + } + } +} + +func startInterface(packetChan chan *[]byte) { + config := water.Config{ + DeviceType: water.TUN, + } + config.Name = interfaceName + iFace, err := water.New(config) + if err != nil { + log.Fatal(err) + } + + for { + packet := make([]byte, 2000) + n, err := iFace.Read(packet) + if err != nil { + log.Fatal(err) + } + packet = packet[:n] + packetChan <- &packet + } +} + +func packetHandler(packetChan chan *[]byte) { + for { + packet := <-packetChan + if len(*packet) < 40 { + continue + } + if (*packet)[0] != 0x60 { + continue + } + destinationAddress := (*packet)[24:40] + relevant := destinationAddress[8:] + // FORMAT: XXXX:YYYY:11RR:GGBB + x := binary.BigEndian.Uint16(relevant[0:2]) + y := binary.BigEndian.Uint16(relevant[2:4]) + + if relevant[4] != 0x11 { + continue + } + + r := relevant[5] + g := relevant[6] + b := relevant[7] + + if x > 512 || y > 512 { + continue + } + + obj := pixelArray[x][y] + + obj.Lock() + if obj.r != r || obj.g != g || obj.b != b { + obj.r = r + obj.g = g + obj.b = b + obj.changed = true + } + obj.Unlock() + + } + +} + +type pixel struct { + sync.Mutex + r uint8 + g uint8 + b uint8 + changed bool +} + +// 0 - 513 +var pixelArray [513][513]*pixel + +func getPicture(fullUpdate bool, incrementalUpdate bool) (string, string) { + anyChange := false + canvasFullUpdate := image.NewRGBA(image.Rect(0, 0, 512, 512)) + canvasIncrementalUpdate := image.NewRGBA(image.Rect(0, 0, 512, 512)) + + for x := 0; x < len(pixelArray); x++ { + for y := 0; y < len(pixelArray[x]); y++ { + obj := pixelArray[x][y] + obj.Lock() + var newColor *color.RGBA + if incrementalUpdate { + if obj.changed { + newColor = &color.RGBA{ + R: obj.r, + G: obj.g, + B: obj.b, + A: 255, + } + obj.changed = false + anyChange = true + canvasIncrementalUpdate.SetRGBA(x, y, *newColor) + } else if !fullUpdate { + obj.Unlock() + continue + } + } + if newColor == nil { + newColor = &color.RGBA{ + R: obj.r, + G: obj.g, + B: obj.b, + A: 255, + } + } + obj.Unlock() + canvasFullUpdate.SetRGBA(x, y, *newColor) + } + } + + encoder := png.Encoder{ + CompressionLevel: png.BestSpeed, + } + + incrementalUpdateResult := "0" + if anyChange { + buff := new(bytes.Buffer) + err := encoder.Encode(buff, canvasIncrementalUpdate) + if err != nil { + log.Println(err.Error()) + } + incrementalUpdateResult = "event: u\ndata:" + base64.StdEncoding.EncodeToString(buff.Bytes()) + "\n\n" + } + + fullUpdateResult := "0" + if fullUpdate { + buff := new(bytes.Buffer) + err := encoder.Encode(buff, canvasFullUpdate) + if err != nil { + log.Println(err.Error()) + } + fullUpdateResult = "event: u\ndata:" + base64.StdEncoding.EncodeToString(buff.Bytes()) + "\n\n" + } + + return fullUpdateResult, incrementalUpdateResult +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..32f998b Binary files /dev/null and b/screenshot.png differ diff --git a/template.html b/template.html new file mode 100644 index 0000000..443d740 --- /dev/null +++ b/template.html @@ -0,0 +1,116 @@ + + + + + IPV6Canvas + + + +
+ +
+

IPv6 Canvas

+ ping {{.BaseIP}}XXXX:YYYY:11RR:GGBB +
Substitute coordinates and color, then ping. Values are hexadecimal.
+
Canvas size: {{.CanvasWidth}}x{{.CanvasHeight}}
+ Connection status: + Collapse +
+
+ +
+ + + \ No newline at end of file