diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11d5bbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +* +!/**/ +!*.* +!Dockerfile diff --git a/Phase-05/Dockerfile b/Phase-05/Dockerfile new file mode 100644 index 0000000..83f762d --- /dev/null +++ b/Phase-05/Dockerfile @@ -0,0 +1,29 @@ +# +FROM golang:1.22.5-alpine3.20 AS build + +WORKDIR /app + +RUN adduser -D -g '' -u 10001 builder +RUN chown -R builder:builder /app +USER builder + +COPY go.mod go.sum ./ +RUN go mod download + +COPY *.go ./ +RUN go build -o ./traceroute-api +# + +# +FROM alpine:3.20.0 AS final + +WORKDIR /app + +LABEL org.opencontainers.image.source=https://github.com/Star-Academy/Summer1403-Devops-Team12 + +COPY --from=build --chown=root:root /app/traceroute-api ./traceroute-api + +EXPOSE 8080 + +CMD ["./traceroute-api"] +# diff --git a/Phase-05/README.md b/Phase-05/README.md new file mode 100644 index 0000000..47d4f63 --- /dev/null +++ b/Phase-05/README.md @@ -0,0 +1,31 @@ +# How to run + +## build +```bash +$ go build -o main +``` + +## Run +```bash +# Listening to ICMP packets requires root privileges +$ sudo ./main +``` + +## Usage +```bash +$ curl http://localhost:8080/trace/{ip} +``` +```bash +$ curl http://localhost:8080/trace/{ip}?maxHops={max_hops} +``` + +## Example +```bash +$ curl http://localhost:8080/trace/8.8.8.8 +``` +```bash +$ curl http://localhost:8080/trace/google.com +``` +```bash +$ curl http://localhost:8080/trace/8.8.8.8?maxHops=10 +``` \ No newline at end of file diff --git a/Phase-05/config.go b/Phase-05/config.go new file mode 100644 index 0000000..6893cdf --- /dev/null +++ b/Phase-05/config.go @@ -0,0 +1,7 @@ +package main + +import ( + "os" +) + +var redisConnStr = defaultString(os.Getenv("REDIS_CONN_STR"), "redis://localhost:6379") diff --git a/Phase-05/dto.go b/Phase-05/dto.go new file mode 100644 index 0000000..2670e89 --- /dev/null +++ b/Phase-05/dto.go @@ -0,0 +1,19 @@ +package main + +type TraceHopResponse struct { + Hop int `json:"hop"` + IPAddr string `json:"ip"` + RTT int64 `json:"rtt"` +} + +func (hop *TraceHop) toTraceHopResponse(hopIndex int) *TraceHopResponse { + if hop == nil { + return &TraceHopResponse{Hop: hopIndex + 1, IPAddr: "", RTT: -1} + } else { + return &TraceHopResponse{ + Hop: hopIndex + 1, + IPAddr: hop.IPAddr.String(), + RTT: hop.RTT.Milliseconds(), + } + } +} diff --git a/Phase-05/go.mod b/Phase-05/go.mod new file mode 100644 index 0000000..1f232b2 --- /dev/null +++ b/Phase-05/go.mod @@ -0,0 +1,11 @@ +module phase05 + +go 1.22.5 + +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.22.0 // indirect +) diff --git a/Phase-05/go.sum b/Phase-05/go.sum new file mode 100644 index 0000000..320b9ba --- /dev/null +++ b/Phase-05/go.sum @@ -0,0 +1,10 @@ +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/Phase-05/main.go b/Phase-05/main.go new file mode 100644 index 0000000..c8a3cdc --- /dev/null +++ b/Phase-05/main.go @@ -0,0 +1,13 @@ +package main + +import "log" + +func main() { + err := initRedis() + if err != nil { + log.Fatal(err) + } + log.Println("Successfully connected to Redis") + + RunTraceRouteServer(":8080") +} diff --git a/Phase-05/server.go b/Phase-05/server.go new file mode 100644 index 0000000..b0a1409 --- /dev/null +++ b/Phase-05/server.go @@ -0,0 +1,62 @@ +package main + +import ( + "log" + "net/http" + "regexp" + "strconv" + "strings" +) + +var DomainRegex = regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`) +var AddrRegex = regexp.MustCompile(`^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`) + +func traceRouteHandler(w http.ResponseWriter, r *http.Request) { + path, _, _ := strings.Cut(r.URL.Path, "?") + trimmedPath := strings.Trim(path, "/") + addr := trimmedPath[strings.LastIndex(trimmedPath, "/")+1:] + + if !AddrRegex.MatchString(addr) && !DomainRegex.MatchString(addr) { + WriteBadRequest(w, "Invalid addr "+addr) + return + } + + maxHops, err := strconv.Atoi(defaultString(r.URL.Query().Get("maxHops"), "30")) + if err != nil { + WriteBadRequest(w, "maxHops must be an integer") + return + } + + hops, err := TraceRoute(addr, maxHops) + if err != nil { + WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + + result := make([]*TraceHopResponse, len(hops)) + for i, hop := range hops { + result[i] = hop.toTraceHopResponse(i) + } + + response, err := WriteJSON(w, result) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = saveToRedis(GenerateRedisKey(addr, maxHops), response) + if err != nil { + log.Println(err) + } +} + +func RunTraceRouteServer(listen string) { + handler := &RegexpHandler{} + handler.HandleFunc(regexp.MustCompile(`^/trace/[^/]+$`), traceRouteHandler) + + log.Printf("Listening on %s\n", listen) + err := http.ListenAndServe(listen, handler) + if err != nil { + log.Fatalf("Failed to start server. %v", err) + } +} diff --git a/Phase-05/storage.go b/Phase-05/storage.go new file mode 100644 index 0000000..697ef39 --- /dev/null +++ b/Phase-05/storage.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "log" + + "github.com/go-redis/redis/v8" +) + +var ( + ctx = context.Background() + rdb *redis.Client = nil +) + +func initRedis() error { + if rdb == nil { + opts, err := redis.ParseURL(redisConnStr) + if err != nil { + return err + } + + rdb = redis.NewClient(opts) + return rdb.Ping(ctx).Err() + } + + return nil +} + +func saveToRedis(key string, value []byte) error { + if rdb == nil { + log.Println("Redis not initialized. Initializing now...") + initRedis() + } + + err := rdb.Set(ctx, key, value, 0).Err() + if err != nil { + return err + } + + return nil +} diff --git a/Phase-05/traceroute.go b/Phase-05/traceroute.go new file mode 100644 index 0000000..7703302 --- /dev/null +++ b/Phase-05/traceroute.go @@ -0,0 +1,133 @@ +// Copyright © 2016 Alex +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "crypto/rand" + "fmt" + "net" + "os" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +const ( + ProtocolICMP = 1 + //ProtocolIPv6ICMP = 58 + ListenAddr = "0.0.0.0" +) + +type TraceHop struct { + IPAddr net.IP + RTT time.Duration +} + +func TraceRoute(addr string, maxHops int) ([]*TraceHop, error) { + c, err := icmp.ListenPacket("ip4:icmp", ListenAddr) + if err != nil { + panic(err) + } + defer c.Close() + + // Resolve any DNS (if used) and get the real IP of the target + dst, err := net.ResolveIPAddr("ip4", addr) + if err != nil { + return nil, err + } + + res := make([]*TraceHop, maxHops) + + for i := 0; i < maxHops; i++ { + finished, dst, dur, err := getNthHop(c, dst, i+1) + if err != nil { + res[i] = nil + } else { + res[i] = &TraceHop{IPAddr: dst.IP, RTT: dur} + if finished { + return res[:i+1], nil + } + } + + } + + return res, nil +} + +// Mostly based on https://github.com/golang/net/blob/master/icmp/ping_test.go +// All ye beware, there be dragons below... + +func getNthHop(c *icmp.PacketConn, dst *net.IPAddr, ttl int) (bool, *net.IPAddr, time.Duration, error) { + // Start listening for icmp replies + c.IPv4PacketConn().SetTTL(ttl) + + data := make([]byte, 64) + rand.Read(data) + + // Make a new ICMP message + m := icmp.Message{ + Type: ipv4.ICMPTypeEcho, Code: 0, + Body: &icmp.Echo{ + ID: os.Getpid() & 0xffff, Seq: 1, //<< uint(seq), // TODO + Data: data, + }, + } + b, err := m.Marshal(nil) + if err != nil { + return false, dst, 0, err + } + + // Send it + start := time.Now() + + n, err := c.WriteTo(b, dst) + if err != nil { + return false, dst, 0, err + } else if n != len(b) { + return false, dst, 0, fmt.Errorf("got %v; want %v", n, len(b)) + } + + // Wait for a reply + reply := make([]byte, 1500) + err = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if err != nil { + return false, dst, 0, err + } + n, peer, err := c.ReadFrom(reply) + if err != nil { + // fmt.Println("Unable to read!") + return false, dst, 0, err + } + duration := time.Since(start) + + // Pack it up boys, we're done here + rm, err := icmp.ParseMessage(ProtocolICMP, reply[:n]) + if err != nil { + return false, dst, 0, err + } + switch rm.Type { + case ipv4.ICMPTypeEchoReply: + return true, dst, duration, nil + case ipv4.ICMPTypeTimeExceeded: + // Convert peer to IPAddr + return false, &net.IPAddr{IP: peer.(*net.IPAddr).IP}, duration, nil + default: + return false, dst, 0, fmt.Errorf("got %+v from %v; want echo reply", rm, peer) + } +} diff --git a/Phase-05/utils.go b/Phase-05/utils.go new file mode 100644 index 0000000..24b3cad --- /dev/null +++ b/Phase-05/utils.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" + "time" +) + +func defaultString(s, def string) string { + if s == "" { + return def + } + return s +} + +type route struct { + pattern *regexp.Regexp + handler http.Handler +} + +type RegexpHandler struct { + routes []*route +} + +func (h *RegexpHandler) Handler(pattern *regexp.Regexp, handler http.Handler) { + h.routes = append(h.routes, &route{pattern, handler}) +} + +func (h *RegexpHandler) HandleFunc(pattern *regexp.Regexp, handler func(http.ResponseWriter, *http.Request)) { + h.routes = append(h.routes, &route{pattern, http.HandlerFunc(handler)}) +} + +func (h *RegexpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + for _, route := range h.routes { + if route.pattern.MatchString(r.URL.Path) { + route.handler.ServeHTTP(w, r) + return + } + } + // no pattern matched; send 404 response + http.NotFound(w, r) +} + +func WriteBadRequest(w http.ResponseWriter, msg string) []byte { + resp := WriteError(w, msg, http.StatusBadRequest) + log.Printf("Bad request: %s\n", resp) + return resp +} + +func WriteError(w http.ResponseWriter, msg string, code int) []byte { + w.WriteHeader(code) + resp, _ := json.Marshal(map[string]string{"error": msg}) + + log.Printf("%s: %s\n", http.StatusText(code), resp) + + w.Write(resp) + return resp +} + +func WriteJSON(w http.ResponseWriter, data interface{}) ([]byte, error) { + w.Header().Set("Content-Type", "application/json") + jsonData, err := json.Marshal(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil, err + } + + w.Write(jsonData) + + return jsonData, nil +} + +func GenerateRedisKey(addr string, maxHops int) string { + timestamp := time.Now().Unix() + return fmt.Sprintf("%s:%d:%d", addr, maxHops, timestamp) +}