ttr.git


ttr

~ tiny traceroute in golang
git clone https://github.com/djnnvx/ttr.git
log | files | refs | archive | README | LICENSE

commit c35b7c769e02ec710d7d1decd86af51cf6563e4e
parent 02127842e986cc5998526138f11c389c64743db6
Author: djnn <[email protected]>
Date: Fri, 12 Dec 2025 22:58:45 +0900

feat(cli): reorganizing code

Diffstat:
A.github/workflows/release.yml | 30++++++++++++++++++++++++++++++
Acli/parser.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Acli/types.go | 16++++++++++++++++
Mgo.mod | 11+++++++++--
Mgo.sum | 11+++++++++++
Mmain.go | 204++-----------------------------------------------------------------------------
Anet/run.go | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

7 files changed, 330 insertions(+), 202 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
@@ -0,0 +1,30 @@
+name: Publish release on Github
+
+on:
+  push:
+    tags:
+      - "v*"
+
+permissions:
+  contents: write
+
+jobs:
+  goreleaser:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: 1.25
+      - name: Run GoReleaser
+        uses: goreleaser/goreleaser-action@v6
+        with:
+          distribution: goreleaser
+          version: "~> v2"
+          args: release --clean
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/cli/parser.go b/cli/parser.go
@@ -0,0 +1,52 @@
+package cli
+
+import (
+	"github.com/djnnvx/ttr/net"
+	"github.com/spf13/cobra"
+)
+
+const ASCII_ART = `
+
+ ███████████ ███████████ ███████████
+▒█▒▒▒███▒▒▒█▒█▒▒▒███▒▒▒█▒▒███▒▒▒▒▒███
+▒   ▒███  ▒ ▒   ▒███  ▒  ▒███    ▒███
+    ▒███        ▒███     ▒██████████
+    ▒███        ▒███     ▒███▒▒▒▒▒███
+    ▒███        ▒███     ▒███    ▒███
+    █████       █████    █████   █████
+   ▒▒▒▒▒       ▒▒▒▒▒    ▒▒▒▒▒   ▒▒▒▒▒
+
+
+
+ ====== version 0.1.0 ======== djnn.sh
+
+`
+
+func GetParser(opts *TtrOptions) *cobra.Command {
+
+	var cmd = &cobra.Command{
+		Use:     "ttr",
+		Short:   "tiny traceroute utility",
+		Long:    ASCII_ART,
+		Args:    cobra.MinimumNArgs(1),
+		Version: "0.1.0",
+		Run: func(cmd *cobra.Command, args []string) {
+
+			println(ASCII_ART)
+
+			ipType := "ip4"
+			if opts.ForceIPv6 {
+				ipType = "ip6"
+			}
+
+			target := args[0]
+			net.Run(target, ipType)
+
+		},
+	}
+
+	defaults := GetDefaultOptions()
+
+	cmd.Flags().BoolVarP(&opts.ForceIPv6, "v6", "", defaults.ForceIPv6, "Force IPv6 usage")
+	return cmd
+}
diff --git a/cli/types.go b/cli/types.go
@@ -0,0 +1,16 @@
+package cli
+
+type TtrOptions struct {
+
+	// Enable this to force IPv6
+	ForceIPv6 bool
+}
+
+func GetDefaultOptions() TtrOptions {
+
+	opts := TtrOptions{
+		ForceIPv6: false,
+	}
+
+	return opts
+}
diff --git a/go.mod b/go.mod
@@ -4,6 +4,13 @@ go 1.24.0
 
 toolchain go1.24.11
 
-require golang.org/x/net v0.48.0
+require (
+	github.com/spf13/cobra v1.10.2
+	golang.org/x/net v0.48.0
+)
 
-require golang.org/x/sys v0.39.0 // indirect
+require (
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -1,4 +1,15 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
 golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
 golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
 golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/main.go b/main.go
@@ -1,207 +1,11 @@
 package main
 
-import (
-	"encoding/binary"
-	"fmt"
-	"net"
-	"os"
-	"strings"
-	"time"
-
-	"golang.org/x/net/icmp"
-	"golang.org/x/net/ipv4"
-)
-
-const (
-	/* Configuration */
-	defaultMaxHops = 30
-	defaultTimeout = 1 * time.Second
-	probesPerHop   = 3
-	echoPayload    = "tiny-traceroute-djnn-sh"
-
-	/* Network Protocols & Packet sizing */
-	protocolICMP     = 1
-	readBufferSize   = 1500
-	minIPv4HeaderLen = 20
-	icmpHeaderLen    = 8
-	ipv4WordSize     = 4 /* IHL is measured in 32-bit (4-byte) words */
-	ihlMask          = 0x0f
-	processIDMask    = 0xffff
-)
+import "github.com/djnnvx/ttr/cli"
 
 func main() {
-	if len(os.Args) != 2 {
-		fmt.Printf("Usage: %s <host>\n", os.Args[0])
-		os.Exit(1)
-	}
-
-	destStr := os.Args[1]
-	destIP, err := net.ResolveIPAddr("ip4", destStr)
-	if err != nil {
-		fmt.Printf("Error resolving host: %v\n", err)
-		os.Exit(1)
-	}
-
-	fmt.Printf("traceroute to %s (%s), %d hops max\n", destStr, destIP.String(), defaultMaxHops)
-
-	c, pc, err := setupSocket()
-	if err != nil {
-		fmt.Printf("Socket error: %v\n", err)
-		return
-	}
-	defer c.Close()
-
-	traceID := os.Getpid() & processIDMask
-
-	for ttl := 1; ttl <= defaultMaxHops; ttl++ {
-		fmt.Printf("%2d  ", ttl)
-
-		var prevAddr string
-		destinationReached := false
-
-		for probe := 0; probe < probesPerHop; probe++ {
-			seq := (ttl * 100) + probe
-
-			peerAddr, rtt, err := executeProbe(c, pc, destIP, traceID, seq, ttl)
-			if err != nil {
-				fmt.Printf("* ")
-			} else {
-				printProbeResult(peerAddr, prevAddr, rtt)
-				prevAddr = peerAddr
-
-				if peerAddr == destIP.String() {
-					destinationReached = true
-				}
-			}
-		}
-
-		fmt.Println()
-
-		if destinationReached {
-			break
-		}
-	}
-}
-
-func setupSocket() (*icmp.PacketConn, *ipv4.PacketConn, error) {
-	c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
-	if err != nil {
-		return nil, nil, fmt.Errorf("listen packet: %w (ensure sudo/admin)", err)
-	}
-	return c, c.IPv4PacketConn(), nil
-}
-
-func executeProbe(c *icmp.PacketConn, pc *ipv4.PacketConn, dest *net.IPAddr, id, seq, ttl int) (string, time.Duration, error) {
-	if err := pc.SetTTL(ttl); err != nil {
-		return "", 0, err
-	}
-
-	start := time.Now()
-
-	if err := sendEchoRequest(c, dest, id, seq); err != nil {
-		return "", 0, err
-	}
 
-	peer, err := awaitResponse(pc, id, seq)
-	if err != nil {
-		return "", 0, err
-	}
-
-	return cleanIP(peer.String()), time.Since(start), nil
-}
-
-func sendEchoRequest(c *icmp.PacketConn, dest net.Addr, id, seq int) error {
-	msg := icmp.Message{
-		Type: ipv4.ICMPTypeEcho, Code: 0,
-		Body: &icmp.Echo{
-			ID: id, Seq: seq,
-			Data: []byte(echoPayload),
-		},
-	}
-	b, err := msg.Marshal(nil)
-	if err != nil {
-		return err
-	}
-	_, err = c.WriteTo(b, dest)
-	return err
-}
-
-func awaitResponse(pc *ipv4.PacketConn, expectedID, expectedSeq int) (net.Addr, error) {
-	pc.SetReadDeadline(time.Now().Add(defaultTimeout))
-	b := make([]byte, readBufferSize)
-
-	for {
-		n, _, peer, err := pc.ReadFrom(b)
-		if err != nil {
-			return nil, err
-		}
-
-		rm, err := icmp.ParseMessage(protocolICMP, b[:n])
-		if err != nil {
-			continue
-		}
-
-		switch rm.Type {
-		case ipv4.ICMPTypeEchoReply:
-			if echo, ok := rm.Body.(*icmp.Echo); ok {
-				if echo.ID == expectedID && echo.Seq == expectedSeq {
-					return peer, nil
-				}
-			}
-		case ipv4.ICMPTypeTimeExceeded:
-			if te, ok := rm.Body.(*icmp.TimeExceeded); ok {
-				if matchOriginalPacket(te.Data, expectedID, expectedSeq) {
-					return peer, nil
-				}
-			}
-		}
-	}
-}
-
-func matchOriginalPacket(data []byte, expectedID, expectedSeq int) bool {
-	if len(data) < minIPv4HeaderLen {
-		return false
-	}
-
-	/* Calculate Internet Header Length (IHL) */
-	ihl := int(data[0]&ihlMask) * ipv4WordSize
-
-	/* We need the IP header + 8 bytes of the inner ICMP packet */
-	if len(data) < ihl+icmpHeaderLen {
-		return false
-	}
-
-	innerICMP := data[ihl:]
-
-	/* Inner ICMP Structure: Type(1) + Code(1) + Checksum(2) + ID(2) + Seq(2) */
-	origID := binary.BigEndian.Uint16(innerICMP[4:6])
-	origSeq := binary.BigEndian.Uint16(innerICMP[6:8])
-
-	return int(origID) == expectedID && int(origSeq) == expectedSeq
-}
-
-func printProbeResult(peerIP, prevAddr string, rtt time.Duration) {
-	if peerIP != prevAddr {
-		fmt.Printf("%s ", resolveHostname(peerIP))
-	}
-	fmt.Printf("%.3f ms  ", float64(rtt.Microseconds())/1000.0)
-}
-
-func resolveHostname(ip string) string {
-	names, _ := net.LookupAddr(ip)
-	if len(names) > 0 {
-		hostname := names[0]
-		if strings.HasSuffix(hostname, ".") {
-			hostname = hostname[:len(hostname)-1]
-		}
-		return fmt.Sprintf("%s (%s)", hostname, ip)
-	}
-	return ip
-}
+	opts := cli.GetDefaultOptions()
+	parser := cli.GetParser(&opts)
 
-func cleanIP(addr string) string {
-	if host, _, err := net.SplitHostPort(addr); err == nil {
-		return host
-	}
-	return addr
+	parser.ExecuteC()
 }
diff --git a/net/run.go b/net/run.go
@@ -0,0 +1,208 @@
+package net
+
+import (
+	"encoding/binary"
+	"fmt"
+	"net"
+	"os"
+	"strings"
+	"time"
+
+	"golang.org/x/net/icmp"
+	"golang.org/x/net/ipv4"
+)
+
+const (
+	/* Configuration */
+	defaultMaxHops = 30
+	defaultTimeout = 1 * time.Second
+	probesPerHop   = 3
+	echoPayload    = "tiny-traceroute-djnn-sh"
+
+	/* Network Protocols & Packet sizing */
+	protocolICMP     = 1
+	readBufferSize   = 1500
+	minIPv4HeaderLen = 20
+	icmpHeaderLen    = 8
+	ipv4WordSize     = 4 /* IHL is measured in 32-bit (4-byte) words */
+	ihlMask          = 0x0f
+	processIDMask    = 0xffff
+)
+
+func Run(target string, ipType string) {
+	destIP, err := net.ResolveIPAddr("ip", target)
+	if err != nil {
+		fmt.Printf("Error resolving host: %v\n", err)
+		os.Exit(1)
+	}
+
+	fmt.Printf("traceroute to %s (%s), %d hops max\n", target, destIP.String(), defaultMaxHops)
+
+	c, pc, err := setupSocket(ipType)
+	if err != nil {
+		fmt.Printf("Socket error: %v\n", err)
+		return
+	}
+	defer c.Close()
+
+	traceID := os.Getpid() & processIDMask
+
+	for ttl := 1; ttl <= defaultMaxHops; ttl++ {
+		fmt.Printf("%2d  ", ttl)
+
+		var prevAddr string
+		destinationReached := false
+
+		for probe := 0; probe < probesPerHop; probe++ {
+			seq := (ttl * 100) + probe
+
+			peerAddr, rtt, err := executeProbe(c, pc, destIP, traceID, seq, ttl)
+			if err != nil {
+				fmt.Printf("* ")
+			} else {
+				printProbeResult(peerAddr, prevAddr, rtt)
+				prevAddr = peerAddr
+
+				if peerAddr == destIP.String() {
+					destinationReached = true
+				}
+			}
+		}
+
+		fmt.Println()
+
+		if destinationReached {
+			break
+		}
+	}
+}
+
+func setupSocket(ipVersion string) (*icmp.PacketConn, *ipv4.PacketConn, error) {
+	protocol := "ip4:icmp"
+	network := "0.0.0.0"
+	if ipVersion == "ip6" {
+		protocol = "ip6:ipv6-icmp"
+		network = "::"
+	}
+
+	c, err := icmp.ListenPacket(protocol, network)
+	if err != nil {
+		return nil, nil, fmt.Errorf("listen packet: %w (ensure sudo/admin)", err)
+	}
+	return c, c.IPv4PacketConn(), nil
+}
+
+func executeProbe(c *icmp.PacketConn, pc *ipv4.PacketConn, dest *net.IPAddr, id, seq, ttl int) (string, time.Duration, error) {
+	if err := pc.SetTTL(ttl); err != nil {
+		return "", 0, err
+	}
+
+	start := time.Now()
+
+	if err := sendEchoRequest(c, dest, id, seq); err != nil {
+		return "", 0, err
+	}
+
+	peer, err := awaitResponse(pc, id, seq)
+	if err != nil {
+		return "", 0, err
+	}
+
+	return cleanIP(peer.String()), time.Since(start), nil
+}
+
+func sendEchoRequest(c *icmp.PacketConn, dest net.Addr, id, seq int) error {
+	msg := icmp.Message{
+		Type: ipv4.ICMPTypeEcho, Code: 0,
+		Body: &icmp.Echo{
+			ID: id, Seq: seq,
+			Data: []byte(echoPayload),
+		},
+	}
+	b, err := msg.Marshal(nil)
+	if err != nil {
+		return err
+	}
+	_, err = c.WriteTo(b, dest)
+	return err
+}
+
+func awaitResponse(pc *ipv4.PacketConn, expectedID, expectedSeq int) (net.Addr, error) {
+	pc.SetReadDeadline(time.Now().Add(defaultTimeout))
+	b := make([]byte, readBufferSize)
+
+	for {
+		n, _, peer, err := pc.ReadFrom(b)
+		if err != nil {
+			return nil, err
+		}
+
+		rm, err := icmp.ParseMessage(protocolICMP, b[:n])
+		if err != nil {
+			continue
+		}
+
+		switch rm.Type {
+		case ipv4.ICMPTypeEchoReply:
+			if echo, ok := rm.Body.(*icmp.Echo); ok {
+				if echo.ID == expectedID && echo.Seq == expectedSeq {
+					return peer, nil
+				}
+			}
+		case ipv4.ICMPTypeTimeExceeded:
+			if te, ok := rm.Body.(*icmp.TimeExceeded); ok {
+				if matchOriginalPacket(te.Data, expectedID, expectedSeq) {
+					return peer, nil
+				}
+			}
+		}
+	}
+}
+
+func matchOriginalPacket(data []byte, expectedID, expectedSeq int) bool {
+	if len(data) < minIPv4HeaderLen {
+		return false
+	}
+
+	/* Calculate Internet Header Length (IHL) */
+	ihl := int(data[0]&ihlMask) * ipv4WordSize
+
+	/* We need the IP header + 8 bytes of the inner ICMP packet */
+	if len(data) < ihl+icmpHeaderLen {
+		return false
+	}
+
+	innerICMP := data[ihl:]
+
+	/* Inner ICMP Structure: Type(1) + Code(1) + Checksum(2) + ID(2) + Seq(2) */
+	origID := binary.BigEndian.Uint16(innerICMP[4:6])
+	origSeq := binary.BigEndian.Uint16(innerICMP[6:8])
+
+	return int(origID) == expectedID && int(origSeq) == expectedSeq
+}
+
+func printProbeResult(peerIP, prevAddr string, rtt time.Duration) {
+	if peerIP != prevAddr {
+		fmt.Printf("%s ", resolveHostname(peerIP))
+	}
+	fmt.Printf("%.3f ms  ", float64(rtt.Microseconds())/1000.0)
+}
+
+func resolveHostname(ip string) string {
+	names, _ := net.LookupAddr(ip)
+	if len(names) > 0 {
+		hostname := names[0]
+		if strings.HasSuffix(hostname, ".") {
+			hostname = hostname[:len(hostname)-1]
+		}
+		return fmt.Sprintf("%s (%s)", hostname, ip)
+	}
+	return ip
+}
+
+func cleanIP(addr string) string {
+	if host, _, err := net.SplitHostPort(addr); err == nil {
+		return host
+	}
+	return addr
+}