diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78cfe2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/pcap-web-tools +*.pcap diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8403983 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: build run test vet + +build: + go build -o pcap-web-tools . + +run: + go run . -addr localhost:8080 + +test: + go test ./... + +vet: + go vet ./... diff --git a/README.md b/README.md index 8c0e7b9..109cf8e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Builds a single-packet pcap file from pasted hex. It walks IPv6 extension headers and uses the final destination from a routing header in the pseudo-header. Fragments only get their IP header fixed. + The end of the layer 3 field marks where the headers end. The fix uses that boundary for two more fields: + - **TCP data offset**: set to the length of the TCP header, including options, in the layer 3 field. + - **SCTP chunk length**: set for the last chunk whose header starts in the layer 3 field. It covers the rest of the packet, so paste chunk data without padding. If the payload already includes padding, the tool keeps a chunk length that matches. Chunks bundled before it keep their own lengths. + + The IPv4 IHL and the IPv6 extension header lengths are taken as entered, because they are what says where the IP options end. If an IPv4 option doesn't fit inside the IHL, you get a warning. When the layer 3 field is empty, these two fields are left unchanged. + The IP header is taken to start at the layer 3 field. If that field is empty, the tool parses the link-layer header to find it: Ethernet (including VLAN and QinQ), Linux SLL or SLL2. - **Link type**: *Auto* picks Raw IP when layer 2 is empty and layer 3 is set, and Ethernet otherwise. You can also pick a link type from the list or enter any numeric LINKTYPE. - **Preview** shows the fixups that were applied and a hex dump coloured by layer, with changed bytes underlined. **Download pcap** returns the file. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56fc4c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module pcap-web-tools + +go 1.27.1 diff --git a/internal/hexutil/hex.go b/internal/hexutil/hex.go new file mode 100644 index 0000000..571d9d5 --- /dev/null +++ b/internal/hexutil/hex.go @@ -0,0 +1,41 @@ +// Package hexutil decodes hex strings in the loose formats people paste. +package hexutil + +import ( + "encoding/hex" + "fmt" + "strings" +) + +// Decode decodes a hex string, tolerating common formatting: +// whitespace and newlines, ':' '-' ',' '.' separators, and "0x" / "\x" byte prefixes. +// So "45 00 00 3c", "45:00:00:3c", "0x45,0x00", "\x45\x00" and "4500003c" all work. +func Decode(s string) ([]byte, error) { + s = strings.NewReplacer(`\x`, " ", `\X`, " ", "0x", " ", "0X", " ").Replace(s) + + digits := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case isHexDigit(c): + digits = append(digits, c) + case c == ' ' || c == '\t' || c == '\n' || c == '\r' || + c == ':' || c == '-' || c == ',' || c == '.': + // separator + default: + return nil, fmt.Errorf("invalid character %q in hex input", c) + } + } + if len(digits)%2 != 0 { + return nil, fmt.Errorf("odd number of hex digits (%d)", len(digits)) + } + out := make([]byte, len(digits)/2) + if _, err := hex.Decode(out, digits); err != nil { + return nil, err + } + return out, nil +} + +func isHexDigit(c byte) bool { + return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' +} diff --git a/internal/hexutil/hex_test.go b/internal/hexutil/hex_test.go new file mode 100644 index 0000000..ea3263a --- /dev/null +++ b/internal/hexutil/hex_test.go @@ -0,0 +1,44 @@ +package hexutil + +import ( + "bytes" + "testing" +) + +func TestDecode(t *testing.T) { + want := []byte{0x45, 0x00, 0x00, 0x3c} + for _, in := range []string{ + "4500003c", + "45 00 00 3c", + "45:00:00:3C", + "45-00-00-3c", + "0x45, 0x00, 0x00, 0x3c", + `\x45\x00\x00\x3c`, + "4500\n003c\n", + " 45\t00 00\r\n3c ", + } { + got, err := Decode(in) + if err != nil { + t.Errorf("Decode(%q): %v", in, err) + continue + } + if !bytes.Equal(got, want) { + t.Errorf("Decode(%q) = %x, want %x", in, got, want) + } + } +} + +func TestDecodeEmpty(t *testing.T) { + got, err := Decode(" \n ") + if err != nil || len(got) != 0 { + t.Errorf("Decode(blank) = %x, %v", got, err) + } +} + +func TestDecodeErrors(t *testing.T) { + for _, in := range []string{"450", "45 0g", "45 00 zz"} { + if _, err := Decode(in); err == nil { + t.Errorf("Decode(%q): expected error", in) + } + } +} diff --git a/internal/inet/fix.go b/internal/inet/fix.go new file mode 100644 index 0000000..6cffac0 --- /dev/null +++ b/internal/inet/fix.go @@ -0,0 +1,381 @@ +// Package inet recalculates length fields and checksums of IPv4/IPv6 packets +// and their transport headers (TCP, UDP, ICMP, ICMPv6, SCTP). +package inet + +import ( + "encoding/binary" + "errors" + "fmt" + "hash/crc32" +) + +// IP protocol numbers. +const ( + protoHopByHop = 0 + protoICMP = 1 + protoTCP = 6 + protoUDP = 17 + protoRouting = 43 + protoFragment = 44 + protoAH = 51 + protoICMPv6 = 58 + protoDstOpts = 60 + protoSCTP = 132 +) + +// Change records a field rewritten by Fix. +type Change struct { + Field string + Old string + New string +} + +// Result describes what Fix did. +type Result struct { + Changes []Change // fields that were rewritten (including ones whose value did not change) + Notes []string // informational messages, e.g. why a checksum was skipped +} + +func (r *Result) change16(field string, b []byte, v uint16) { + old := binary.BigEndian.Uint16(b) + binary.BigEndian.PutUint16(b, v) + r.Changes = append(r.Changes, Change{field, fmt.Sprintf("0x%04x (%d)", old, old), fmt.Sprintf("0x%04x (%d)", v, v)}) +} + +func (r *Result) notef(format string, args ...any) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +// Fix rewrites, in place, the length fields and checksums of the IPv4 or IPv6 +// packet in pkt. pkt must start at the IP header and extend to the end of the +// packet; everything after the IP header is taken as the IP payload. +// +// hdrLen is the number of leading bytes of pkt that are headers, i.e. where +// the user's header input ends and the payload begins, or 0 if unknown. When +// known it is used to set the TCP data offset and the length of the SCTP +// chunk that the payload belongs to; when unknown those fields are left alone. +// The IPv4 IHL and IPv6 extension header lengths are always taken as given. +func Fix(pkt []byte, hdrLen int) (Result, error) { + if len(pkt) == 0 { + return Result{}, errors.New("no IP header data") + } + switch v := pkt[0] >> 4; v { + case 4: + return fixIPv4(pkt, hdrLen) + case 6: + return fixIPv6(pkt, hdrLen) + default: + return Result{}, fmt.Errorf("not an IPv4/IPv6 packet: IP version nibble is %d", v) + } +} + +func fixIPv4(pkt []byte, hdrLen int) (Result, error) { + var r Result + if len(pkt) < 20 { + return r, fmt.Errorf("IPv4 header truncated: %d bytes, need at least 20", len(pkt)) + } + ihl := int(pkt[0]&0x0f) * 4 + if ihl < 20 { + return r, fmt.Errorf("invalid IPv4 header length (IHL) %d bytes", ihl) + } + if ihl > len(pkt) { + return r, fmt.Errorf("IPv4 header length (IHL) %d exceeds packet length %d", ihl, len(pkt)) + } + if len(pkt) > 0xffff { + return r, fmt.Errorf("IPv4 packet length %d exceeds 65535", len(pkt)) + } + if off := badIPv4Option(pkt[20:ihl]); off >= 0 { + r.notef("IPv4 option at offset %d does not fit in the header length (IHL %d bytes); check IHL", 20+off, ihl) + } + + r.change16("IPv4 total length", pkt[2:4], uint16(len(pkt))) + binary.BigEndian.PutUint16(pkt[10:12], 0) + r.change16("IPv4 header checksum", pkt[10:12], checksum(pkt[:ihl], 0)) + + flagsFrag := binary.BigEndian.Uint16(pkt[6:8]) + if flagsFrag&0x2000 != 0 || flagsFrag&0x1fff != 0 { + r.notef("IPv4 packet is a fragment; transport header not changed") + return r, nil + } + + proto := pkt[9] + payload := pkt[ihl:] + pseudo := sum(pkt[12:16], 0) + pseudo = sum(pkt[16:20], pseudo) + pseudo += uint32(proto) + uint32(len(payload)) + fixTransport(&r, proto, payload, transportHdrLen(&r, hdrLen, ihl), pseudo, false) + return r, nil +} + +// badIPv4Option walks IPv4 options and returns the offset of the first option +// that runs past the end of opts, or -1 if they are well formed. +func badIPv4Option(opts []byte) int { + for i := 0; i < len(opts); { + switch opts[i] { + case 0: // end of option list, the rest is padding + return -1 + case 1: // no-op + i++ + continue + } + if i+1 >= len(opts) || opts[i+1] < 2 || i+int(opts[i+1]) > len(opts) { + return i + } + i += int(opts[i+1]) + } + return -1 +} + +func fixIPv6(pkt []byte, hdrLen int) (Result, error) { + var r Result + if len(pkt) < 40 { + return r, fmt.Errorf("IPv6 header truncated: %d bytes, need 40", len(pkt)) + } + if len(pkt)-40 > 0xffff { + return r, fmt.Errorf("IPv6 payload length %d exceeds 65535 (jumbograms not supported)", len(pkt)-40) + } + r.change16("IPv6 payload length", pkt[4:6], uint16(len(pkt)-40)) + + dst := pkt[24:40] + next := pkt[6] + off := 40 + for isIPv6ExtHeader(next) { + if off+8 > len(pkt) { + r.notef("IPv6 extension header truncated; transport header not changed") + return r, nil + } + var hlen int + switch next { + case protoFragment: + hlen = 8 + fragOffMore := binary.BigEndian.Uint16(pkt[off+2 : off+4]) + if fragOffMore&0xfff9 != 0 { // fragment offset != 0 or M flag set + r.notef("IPv6 packet is a fragment; transport header not changed") + return r, nil + } + case protoAH: + hlen = (int(pkt[off+1]) + 2) * 4 + default: + hlen = (int(pkt[off+1]) + 1) * 8 + } + if off+hlen > len(pkt) { + r.notef("IPv6 extension header truncated; transport header not changed") + return r, nil + } + if next == protoRouting { + if d := routingFinalDst(pkt[off : off+hlen]); d != nil { + dst = d + } + } + next = pkt[off] + off += hlen + } + + payload := pkt[off:] + pseudo := sum(pkt[8:24], 0) + pseudo = sum(dst, pseudo) + pseudo += uint32(len(payload)) + uint32(next) + fixTransport(&r, next, payload, transportHdrLen(&r, hdrLen, off), pseudo, true) + return r, nil +} + +// transportHdrLen converts the header length given to Fix into the number of +// transport-layer header bytes, or -1 if unknown. +func transportHdrLen(r *Result, hdrLen, ipHdrLen int) int { + if hdrLen <= 0 { + return -1 + } + if hdrLen < ipHdrLen { + r.notef("IP header (%d bytes including options/extension headers) extends past the layer 3 field (%d bytes)", ipHdrLen, hdrLen) + return 0 + } + return hdrLen - ipHdrLen +} + +func isIPv6ExtHeader(proto byte) bool { + switch proto { + case protoHopByHop, protoRouting, protoFragment, protoAH, protoDstOpts: + return true + } + return false +} + +// routingFinalDst returns the final destination address carried in an IPv6 +// routing header, which the upper-layer pseudo header must use (RFC 8200 8.1). +// It returns nil when the IPv6 destination address field should be used. +func routingFinalDst(rh []byte) []byte { + routingType, segmentsLeft := rh[2], rh[3] + if segmentsLeft == 0 { + return nil + } + switch routingType { + case 0: // deprecated source route: last address in the list + n := int(rh[1]) / 2 + if n > 0 && 8+n*16 <= len(rh) { + return rh[8+(n-1)*16 : 8+n*16] + } + case 2, 4: // Mobile IPv6 home address / Segment Routing: first address in the list + if len(rh) >= 24 { + return rh[8:24] + } + } + return nil +} + +// fixTransport fixes the transport header at the start of payload. hdrLen is +// the number of transport header bytes the user entered before the payload, +// or -1 if unknown. +func fixTransport(r *Result, proto byte, payload []byte, hdrLen int, pseudo uint32, ipv6 bool) { + switch { + case proto == protoTCP: + if len(payload) < 20 { + r.notef("TCP header truncated (%d bytes); TCP checksum not recalculated", len(payload)) + return + } + fixTCPDataOffset(r, payload, hdrLen) + binary.BigEndian.PutUint16(payload[16:18], 0) + r.change16("TCP checksum", payload[16:18], checksum(payload, pseudo)) + + case proto == protoUDP: + if len(payload) < 8 { + r.notef("UDP header truncated (%d bytes); UDP length and checksum not recalculated", len(payload)) + return + } + r.change16("UDP length", payload[4:6], uint16(len(payload))) + binary.BigEndian.PutUint16(payload[6:8], 0) + cs := checksum(payload, pseudo) + if cs == 0 { + cs = 0xffff // zero means "no checksum" in UDP + } + r.change16("UDP checksum", payload[6:8], cs) + + case proto == protoICMP && !ipv6: + if len(payload) < 4 { + r.notef("ICMP header truncated (%d bytes); ICMP checksum not recalculated", len(payload)) + return + } + binary.BigEndian.PutUint16(payload[2:4], 0) + r.change16("ICMP checksum", payload[2:4], checksum(payload, 0)) + + case proto == protoICMPv6 && ipv6: + if len(payload) < 4 { + r.notef("ICMPv6 header truncated (%d bytes); ICMPv6 checksum not recalculated", len(payload)) + return + } + binary.BigEndian.PutUint16(payload[2:4], 0) + r.change16("ICMPv6 checksum", payload[2:4], checksum(payload, pseudo)) + + case proto == protoSCTP: + if len(payload) < 12 { + r.notef("SCTP common header truncated (%d bytes); SCTP checksum not recalculated", len(payload)) + return + } + fixSCTPChunkLength(r, payload, hdrLen) + old := binary.BigEndian.Uint32(payload[8:12]) + binary.BigEndian.PutUint32(payload[8:12], 0) + // CRC32c is transmitted least significant byte first (RFC 9260 appendix A). + binary.LittleEndian.PutUint32(payload[8:12], crc32.Checksum(payload, castagnoli)) + v := binary.BigEndian.Uint32(payload[8:12]) + r.Changes = append(r.Changes, Change{"SCTP checksum (CRC32c)", fmt.Sprintf("0x%08x", old), fmt.Sprintf("0x%08x", v)}) + + default: + r.notef("IP protocol %d: no transport checksum to recalculate", proto) + } +} + +// fixTCPDataOffset sets the TCP data offset to the TCP header length (with +// options) entered in the layer 3 field. +func fixTCPDataOffset(r *Result, tcp []byte, hdrLen int) { + switch { + case hdrLen < 0: + return + case hdrLen == 0: + r.notef("TCP header is in the payload field; TCP data offset not changed") + return + case hdrLen < 20 || hdrLen > 60 || hdrLen%4 != 0: + r.notef("TCP header in the layer 3 field is %d bytes (must be 20-60 and a multiple of 4); TCP data offset not changed", hdrLen) + return + } + old := int(tcp[12] >> 4) + tcp[12] = byte(hdrLen/4)<<4 | tcp[12]&0x0f + r.Changes = append(r.Changes, Change{"TCP data offset", + fmt.Sprintf("%d (%d bytes)", old, old*4), fmt.Sprintf("%d (%d bytes)", hdrLen/4, hdrLen)}) +} + +// fixSCTPChunkLength sets the length of the SCTP chunk that the payload +// belongs to: the last chunk whose header starts in the layer 3 field. Chunks +// before it are skipped using their own length fields. The chunk is taken to +// extend to the end of the packet, so the payload must not include padding +// unless the chunk length is already correct. +func fixSCTPChunkLength(r *Result, sctp []byte, hdrLen int) { + switch { + case len(sctp) == 12: // no chunks + return + case hdrLen < 0: + r.notef("SCTP chunk lengths are only recalculated when the layer 3 field is used; not changed") + return + case hdrLen < 16: + r.notef("SCTP chunk header is not in the layer 3 field; chunk length not changed") + return + } + + off := 12 + for { + clen := int(binary.BigEndian.Uint16(sctp[off+2:])) + next := off + (clen+3)&^3 + if clen >= 4 && next+4 <= hdrLen { + off = next + continue + } + + rem := len(sctp) - off + newLen := rem + if clen >= 4 && clen <= rem && (clen+3)&^3 == rem { + newLen = clen // already correct; the rest is padding + } + if newLen > 0xffff { + r.notef("SCTP chunk would be %d bytes, more than 65535; chunk length not changed", newLen) + return + } + r.change16(fmt.Sprintf("SCTP %s chunk length", sctpChunkName(sctp[off])), sctp[off+2:off+4], uint16(newLen)) + if rem%4 != 0 { + r.notef("SCTP %s chunk is %d bytes, not padded to a multiple of 4", sctpChunkName(sctp[off]), rem) + } + return + } +} + +func sctpChunkName(t byte) string { + names := map[byte]string{ + 0: "DATA", 1: "INIT", 2: "INIT ACK", 3: "SACK", 4: "HEARTBEAT", 5: "HEARTBEAT ACK", + 6: "ABORT", 7: "SHUTDOWN", 8: "SHUTDOWN ACK", 9: "ERROR", 10: "COOKIE ECHO", + 11: "COOKIE ACK", 14: "SHUTDOWN COMPLETE", 64: "I-DATA", + } + if n, ok := names[t]; ok { + return n + } + return fmt.Sprintf("type %d", t) +} + +var castagnoli = crc32.MakeTable(crc32.Castagnoli) + +// sum adds data as big-endian 16-bit words to s (RFC 1071). +func sum(data []byte, s uint32) uint32 { + n := len(data) &^ 1 + for i := 0; i < n; i += 2 { + s += uint32(data[i])<<8 | uint32(data[i+1]) + } + if len(data)%2 == 1 { + s += uint32(data[len(data)-1]) << 8 + } + return s +} + +// checksum returns the Internet checksum of data, starting from the partial sum s. +func checksum(data []byte, s uint32) uint16 { + s = sum(data, s) + for s>>16 != 0 { + s = s&0xffff + s>>16 + } + return ^uint16(s) +} diff --git a/internal/inet/fix_test.go b/internal/inet/fix_test.go new file mode 100644 index 0000000..97b09fe --- /dev/null +++ b/internal/inet/fix_test.go @@ -0,0 +1,244 @@ +package inet + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" + + "pcap-web-tools/internal/pcap" +) + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + if err != nil { + t.Fatal(err) + } + return b +} + +// The want packets were generated by Fix and validated with tshark's checksum checks. +// Each test zeroes the length/checksum fields and checks Fix restores them. +func TestFix(t *testing.T) { + tests := []struct { + name string + want string + hdrLen int // header length passed to Fix (0 = unknown) + zero [][2]int // byte ranges cleared before calling Fix + changes int // expected number of changes, if not len(zero) + notes []string // expected substrings of the notes, in order + }{ + { + name: "IPv4 UDP", + want: "45000028000140004011b970c0a80001c0a80002303904d20014117668656c6c6f2c207063617021", + zero: [][2]int{{2, 4}, {10, 12}, {24, 26}, {26, 28}}, + }, + { + name: "IPv4 with options, TCP, odd length payload", + want: "46000036123440004006118b0a0000010a00000201010100c350005000000001000000005018fffff9700000474554202f0d0a0d0a2e", + hdrLen: 44, + zero: [][2]int{{2, 4}, {10, 12}, {36, 37}, {40, 42}}, + }, + { + name: "IPv4 ICMP", + want: "45000021000100004001aa22c0a80001080808080800f54babcd00017071727374", + zero: [][2]int{{2, 4}, {10, 12}, {22, 24}}, + }, + { + name: "IPv4 SCTP", + want: "45000034000040004084b8f2c0a80001c0a800020b5904d200000000a05c7b6a0100001400000001000100000001000100000001", + zero: [][2]int{{2, 4}, {10, 12}, {28, 32}}, + notes: []string{"only recalculated when the layer 3 field is used"}, + }, + { + name: "IPv6 hop-by-hop UDP", + want: "600000000016000020010db800000000000000000000000120010db800000000000000000000000211000000000000001f900035000e1bfcdeadbeefcafe", + zero: [][2]int{{4, 6}, {52, 54}, {54, 56}}, + }, + { + name: "IPv6 ICMPv6", + want: "60000000000d3a40fe800000000000000000000000000001fe800000000000000000000000000002800046b7123400016162636465", + zero: [][2]int{{4, 6}, {42, 44}}, + }, + { + name: "IPv6 segment routing header UDP uses final destination", + want: "6000000000242b4020010db800000000000000000000000120010db80000000000000000000000aa110204010000000020010db80000000000000000000000ff1f900035000c7f9901020304", + zero: [][2]int{{4, 6}, {68, 70}, {70, 72}}, + }, + { + name: "IPv4 options, TCP SYN with options and no payload", + want: "460000401234400040067f7d0a0000010a00000294040000c35000500000000100000000a002ffff705b0000020405b40402080a000000010000000001030307", + hdrLen: 64, + zero: [][2]int{{2, 4}, {10, 12}, {36, 37}, {40, 42}}, + }, + { + name: "IPv4 options, TCP with options and payload", + want: "4600003d1234400040067f800a0000010a00000294040000c350005000000001000000008018ffffd4a500000101080a0000000100000000474554202f", + hdrLen: 56, + zero: [][2]int{{2, 4}, {10, 12}, {36, 37}, {40, 42}}, + }, + { + name: "IPv4 options, SCTP DATA without padding", + want: "460000391234400040847f060a0000010a00000294040000c35004d200000001866854b30003001500000001000100000000000068656c6c6f", + hdrLen: 52, + zero: [][2]int{{2, 4}, {10, 12}, {32, 36}, {38, 40}}, + notes: []string{"not padded"}, + }, + { + name: "IPv4 options, SCTP DATA with padding keeps correct chunk length", + want: "4600003c1234400040847f030a0000010a00000294040000c35004d200000001ef89c5b10003001500000001000100000000000068656c6c6f000000", + hdrLen: 52, + zero: [][2]int{{2, 4}, {10, 12}, {32, 36}}, + changes: 4, + }, + { + name: "SCTP INIT entirely in layer 3", + want: "460000381234400040847f070a0000010a00000294040000c35004d200000001c3d6cafe0100001400000001000100000001000100000001", + hdrLen: 56, + zero: [][2]int{{2, 4}, {10, 12}, {32, 36}, {38, 40}}, + }, + { + name: "IPv6 SCTP SACK bundled before DATA", + want: "600000000034844020010db800000000000000000000000120010db8000000000000000000000002c35004d200000001634380c903000010000000000000ffff00000000000300180000000100010000000000000102030405060708", + hdrLen: 84, + zero: [][2]int{{4, 6}, {48, 52}, {70, 72}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + want := mustHex(t, tt.want) + pkt := bytes.Clone(want) + for _, z := range tt.zero { + clear(pkt[z[0]:z[1]]) + } + res, err := Fix(pkt, tt.hdrLen) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(pkt, want) { + t.Errorf("got %x\nwant %x", pkt, want) + } + changes := tt.changes + if changes == 0 { + changes = len(tt.zero) + } + if len(res.Changes) != changes { + t.Errorf("got %d changes, want %d: %+v", len(res.Changes), changes, res.Changes) + } + checkNotes(t, res.Notes, tt.notes) + }) + } +} + +func checkNotes(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Errorf("notes %q, want %q", got, want) + return + } + for i := range want { + if !strings.Contains(got[i], want[i]) { + t.Errorf("note %q does not contain %q", got[i], want[i]) + } + } +} + +func TestFixNotes(t *testing.T) { + const ( + ip4TCP = "45000000000140004006 0000 c0a80001c0a80002" + tcpHdr = "c35000500000000100000000 5002ffff 0000 0000" + ip4SCTP = "45000000000140004084 0000 c0a80001c0a80002" + sctpHdr = "c35004d2 00000001 00000000" + ) + tests := []struct { + name string + pkt string + hdrLen int + notes []string + }{ + {"TCP header in payload field", ip4TCP + tcpHdr, 20, []string{"TCP header is in the payload field"}}, + {"TCP header length not a multiple of 4", ip4TCP + tcpHdr + "0101", 42, []string{"must be 20-60"}}, + {"IP header extends past layer 3", ip4TCP + tcpHdr, 12, []string{"extends past the layer 3 field", "TCP header is in the payload field"}}, + {"IPv4 option overruns IHL", "46000000000140004006 0000 c0a80001c0a80002 01 44 08 00" + tcpHdr, 0, []string{"does not fit in the header length"}}, + {"SCTP chunk header in payload field", ip4SCTP + sctpHdr + "00030000 00000001 00010000 00000000", 32, []string{"chunk header is not in the layer 3 field"}}, + {"SCTP without chunks", ip4SCTP + sctpHdr, 32, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := Fix(mustHex(t, tt.pkt), tt.hdrLen) + if err != nil { + t.Fatal(err) + } + checkNotes(t, res.Notes, tt.notes) + }) + } +} + +func TestFixIPv4Fragment(t *testing.T) { + // More-fragments flag set: header fixed, UDP left alone. + pkt := mustHex(t, "45000000000120004011 0000 c0a80001c0a80002 303904d200000000 aabb") + res, err := Fix(pkt, 0) + if err != nil { + t.Fatal(err) + } + if len(res.Changes) != 2 || len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "fragment") { + t.Errorf("changes %+v notes %v", res.Changes, res.Notes) + } + if !bytes.Equal(pkt[24:28], []byte{0, 0, 0, 0}) { + t.Errorf("UDP header modified: %x", pkt[20:28]) + } +} + +func TestFixTruncatedTransport(t *testing.T) { + pkt := mustHex(t, "45000000000140004006 0000 c0a80001c0a80002 c350") + res, err := Fix(pkt, 0) + if err != nil { + t.Fatal(err) + } + if len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "TCP header truncated") { + t.Errorf("notes %v", res.Notes) + } +} + +func TestFixErrors(t *testing.T) { + for name, in := range map[string]string{ + "empty": "", + "not IP": "0011223344", + "short IPv4": "4500001c", + "IHL too small": "41000000000000004011000000000000000000000000", + "IHL beyond end": "4f000000000000004011000000000000000000000000", + "short IPv6": "6000000000001140", + } { + if _, err := Fix(mustHex(t, in), 0); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestFindIP(t *testing.T) { + tests := []struct { + name string + frame string + linkType uint32 + want int + wantErr bool + }{ + {"ethernet", "001122334455 66778899aabb 0800 45", pcap.LinkTypeEthernet, 14, false}, + {"ethernet IPv6", "001122334455 66778899aabb 86dd 60", pcap.LinkTypeEthernet, 14, false}, + {"vlan", "001122334455 66778899aabb 8100 0064 0800 45", pcap.LinkTypeEthernet, 18, false}, + {"qinq", "001122334455 66778899aabb 88a8 0001 8100 0064 86dd 60", pcap.LinkTypeEthernet, 22, false}, + {"arp", "ffffffffffff 66778899aabb 0806 0001", pcap.LinkTypeEthernet, 0, true}, + {"truncated", "001122334455", pcap.LinkTypeEthernet, 0, true}, + {"raw", "45", pcap.LinkTypeRaw, 0, false}, + {"sll", "0000 0001 0006 66778899aabb 0000 0800 45", pcap.LinkTypeLinuxSLL, 16, false}, + {"sll2", "86dd 0000 00000002 0001 00 06 66778899aabb0000 60", pcap.LinkTypeLinuxSLL2, 20, false}, + {"unsupported", "00", pcap.LinkTypeIEEE80211, 0, true}, + } + for _, tt := range tests { + got, err := FindIP(mustHex(t, tt.frame), tt.linkType) + if (err != nil) != tt.wantErr || got != tt.want { + t.Errorf("%s: FindIP = %d, %v; want %d, err=%v", tt.name, got, err, tt.want, tt.wantErr) + } + } +} diff --git a/internal/inet/locate.go b/internal/inet/locate.go new file mode 100644 index 0000000..5de024c --- /dev/null +++ b/internal/inet/locate.go @@ -0,0 +1,58 @@ +package inet + +import ( + "encoding/binary" + "fmt" + + "pcap-web-tools/internal/pcap" +) + +const ( + etherTypeIPv4 = 0x0800 + etherTypeIPv6 = 0x86dd + etherTypeVLAN = 0x8100 + etherTypeQinQ = 0x88a8 +) + +// FindIP returns the offset of the IP header within a link-layer frame of the +// given pcap link type, by parsing the link-layer header. +func FindIP(frame []byte, linkType uint32) (int, error) { + switch linkType { + case pcap.LinkTypeRaw: + return 0, nil + + case pcap.LinkTypeEthernet: + off := 12 + for { + if off+2 > len(frame) { + return 0, fmt.Errorf("Ethernet header truncated") + } + et := binary.BigEndian.Uint16(frame[off:]) + if et == etherTypeVLAN || et == etherTypeQinQ { + off += 4 + continue + } + return etherIP(et, off+2) + } + + case pcap.LinkTypeLinuxSLL: + if len(frame) < 16 { + return 0, fmt.Errorf("Linux cooked (SLL) header truncated") + } + return etherIP(binary.BigEndian.Uint16(frame[14:]), 16) + + case pcap.LinkTypeLinuxSLL2: + if len(frame) < 20 { + return 0, fmt.Errorf("Linux cooked v2 (SLL2) header truncated") + } + return etherIP(binary.BigEndian.Uint16(frame[0:]), 20) + } + return 0, fmt.Errorf("cannot locate the IP header for link type %s", pcap.LinkTypeName(linkType)) +} + +func etherIP(etherType uint16, off int) (int, error) { + if etherType != etherTypeIPv4 && etherType != etherTypeIPv6 { + return 0, fmt.Errorf("link-layer payload is not IP (EtherType 0x%04x)", etherType) + } + return off, nil +} diff --git a/internal/pcap/writer.go b/internal/pcap/writer.go new file mode 100644 index 0000000..c84b80f --- /dev/null +++ b/internal/pcap/writer.go @@ -0,0 +1,81 @@ +// Package pcap writes classic libpcap capture files. +// +// Format reference: https://www.ietf.org/archive/id/draft-ietf-opsawg-pcap-04.html +package pcap + +import ( + "encoding/binary" + "fmt" + "io" + "time" +) + +// DefaultSnapLen is the snapshot length written to the file header. +const DefaultSnapLen = 262144 + +const magicMicroseconds = 0xa1b2c3d4 + +// Common link-layer header types (LINKTYPE_* values from tcpdump.org). +const ( + LinkTypeEthernet uint32 = 1 + LinkTypeRaw uint32 = 101 + LinkTypeIEEE80211 uint32 = 105 + LinkTypeLinuxSLL uint32 = 113 + LinkTypeLinuxSLL2 uint32 = 276 +) + +// LinkTypeName returns a human readable name for well known link types. +func LinkTypeName(lt uint32) string { + switch lt { + case LinkTypeEthernet: + return "Ethernet" + case LinkTypeRaw: + return "Raw IP" + case LinkTypeIEEE80211: + return "IEEE 802.11" + case LinkTypeLinuxSLL: + return "Linux cooked (SLL)" + case LinkTypeLinuxSLL2: + return "Linux cooked v2 (SLL2)" + } + return fmt.Sprintf("LINKTYPE %d", lt) +} + +// Writer writes packets to a pcap file. The file header is written by NewWriter. +type Writer struct { + w io.Writer + snapLen uint32 +} + +// NewWriter writes a pcap file header (microsecond resolution, little endian) +// for the given link type and returns a Writer for appending packets. +func NewWriter(w io.Writer, linkType uint32) (*Writer, error) { + var hdr [24]byte + binary.LittleEndian.PutUint32(hdr[0:], magicMicroseconds) + binary.LittleEndian.PutUint16(hdr[4:], 2) // version major + binary.LittleEndian.PutUint16(hdr[6:], 4) // version minor + // hdr[8:16]: thiszone and sigfigs, always zero + binary.LittleEndian.PutUint32(hdr[16:], DefaultSnapLen) + binary.LittleEndian.PutUint32(hdr[20:], linkType) + if _, err := w.Write(hdr[:]); err != nil { + return nil, err + } + return &Writer{w: w, snapLen: DefaultSnapLen}, nil +} + +// WritePacket appends one packet record with the given timestamp. +func (w *Writer) WritePacket(ts time.Time, data []byte) error { + if len(data) > int(w.snapLen) { + return fmt.Errorf("packet length %d exceeds snaplen %d", len(data), w.snapLen) + } + var hdr [16]byte + binary.LittleEndian.PutUint32(hdr[0:], uint32(ts.Unix())) + binary.LittleEndian.PutUint32(hdr[4:], uint32(ts.Nanosecond()/1000)) + binary.LittleEndian.PutUint32(hdr[8:], uint32(len(data))) // captured length + binary.LittleEndian.PutUint32(hdr[12:], uint32(len(data))) // original length + if _, err := w.w.Write(hdr[:]); err != nil { + return err + } + _, err := w.w.Write(data) + return err +} diff --git a/internal/pcap/writer_test.go b/internal/pcap/writer_test.go new file mode 100644 index 0000000..dce54c1 --- /dev/null +++ b/internal/pcap/writer_test.go @@ -0,0 +1,36 @@ +package pcap + +import ( + "bytes" + "encoding/hex" + "testing" + "time" +) + +func TestWriter(t *testing.T) { + var buf bytes.Buffer + w, err := NewWriter(&buf, LinkTypeRaw) + if err != nil { + t.Fatal(err) + } + ts := time.Unix(0x01020304, 5000) + if err := w.WritePacket(ts, []byte{0xaa, 0xbb}); err != nil { + t.Fatal(err) + } + + want := "d4c3b2a1" + "0200" + "0400" + "00000000" + "00000000" + "00000400" + "65000000" + + "04030201" + "05000000" + "02000000" + "02000000" + "aabb" + if got := hex.EncodeToString(buf.Bytes()); got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} + +func TestWriterRejectsOversizedPacket(t *testing.T) { + w, err := NewWriter(&bytes.Buffer{}, LinkTypeEthernet) + if err != nil { + t.Fatal(err) + } + if err := w.WritePacket(time.Now(), make([]byte, DefaultSnapLen+1)); err == nil { + t.Error("expected error for oversized packet") + } +} diff --git a/internal/web/hex2pcap.go b/internal/web/hex2pcap.go new file mode 100644 index 0000000..ffb52eb --- /dev/null +++ b/internal/web/hex2pcap.go @@ -0,0 +1,268 @@ +package web + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "pcap-web-tools/internal/hexutil" + "pcap-web-tools/internal/inet" + "pcap-web-tools/internal/pcap" +) + +const maxFormBytes = 2 << 20 + +type linkTypeOption struct { + Value string + Label string +} + +var linkTypeOptions = []linkTypeOption{ + {"auto", "Auto (Ethernet, or Raw IP when layer 2 is empty and layer 3 is set)"}, + {"1", "Ethernet (1)"}, + {"101", "Raw IP (101)"}, + {"113", "Linux cooked SLL (113)"}, + {"276", "Linux cooked SLL2 (276)"}, + {"105", "IEEE 802.11 (105)"}, + {"custom", "Custom…"}, +} + +type hex2pcapForm struct { + L2, L3, L7 string + Fix bool + LinkType string + CustomLinkType string + Filename string +} + +var hex2pcapExample = hex2pcapForm{ + L2: "00 11 22 33 44 55 66 77 88 99 aa bb 08 00", + L3: "45 00 00 00 00 01 40 00 40 11 00 00 c0 a8 00 01 c0 a8 00 02\n30 39 04 d2 00 00 00 00", + L7: "68 65 6c 6c 6f 2c 20 70 63 61 70 21", + Fix: true, + LinkType: "auto", +} + +type hex2pcapResult struct { + Packet []byte + LinkType uint32 + LinkTypeName string + L2Len int + L3Len int + L7Len int + Fixed bool + IPOffset int + Fix inet.Result + Dump []dumpRow +} + +type hex2pcapPage struct { + Form hex2pcapForm + Options []linkTypeOption + Error string + Result *hex2pcapResult + Filename string +} + +func (s *Server) handleHex2PcapForm(w http.ResponseWriter, r *http.Request) { + form := hex2pcapForm{Fix: true, LinkType: "auto"} + if r.URL.Query().Has("example") { + form = hex2pcapExample + } + s.render(w, http.StatusOK, "hex2pcap.html", "/hex2pcap", hex2pcapPage{Form: form, Options: linkTypeOptions}) +} + +func (s *Server) handleHex2Pcap(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form: "+err.Error(), http.StatusBadRequest) + return + } + form := hex2pcapForm{ + L2: r.PostFormValue("l2"), + L3: r.PostFormValue("l3"), + L7: r.PostFormValue("l7"), + Fix: r.PostFormValue("fix") != "", + LinkType: r.PostFormValue("linktype"), + CustomLinkType: r.PostFormValue("customlinktype"), + Filename: r.PostFormValue("filename"), + } + page := hex2pcapPage{Form: form, Options: linkTypeOptions, Filename: pcapFilename(form.Filename)} + + res, err := buildPacket(form) + if err != nil { + page.Error = err.Error() + s.render(w, http.StatusBadRequest, "hex2pcap.html", "/hex2pcap", page) + return + } + + if r.PostFormValue("action") != "download" { + page.Result = res + s.render(w, http.StatusOK, "hex2pcap.html", "/hex2pcap", page) + return + } + + var buf bytes.Buffer + pw, err := pcap.NewWriter(&buf, res.LinkType) + if err == nil { + err = pw.WritePacket(time.Now(), res.Packet) + } + if err != nil { + page.Error = err.Error() + s.render(w, http.StatusBadRequest, "hex2pcap.html", "/hex2pcap", page) + return + } + w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", page.Filename)) + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) + w.Write(buf.Bytes()) +} + +// buildPacket decodes and concatenates the hex fields, and optionally fixes +// up the IP lengths and checksums. +func buildPacket(f hex2pcapForm) (*hex2pcapResult, error) { + var layers [3][]byte + for i, in := range []struct{ name, hex string }{ + {"Layer 2", f.L2}, {"Layer 3", f.L3}, {"Payload", f.L7}, + } { + b, err := hexutil.Decode(in.hex) + if err != nil { + return nil, fmt.Errorf("%s: %w", in.name, err) + } + layers[i] = b + } + l2, l3, l7 := layers[0], layers[1], layers[2] + + pkt := bytes.Join(layers[:], nil) + if len(pkt) == 0 { + return nil, errors.New("all fields are empty; paste some hex") + } + if len(pkt) > pcap.DefaultSnapLen { + return nil, fmt.Errorf("packet is %d bytes; maximum is %d", len(pkt), pcap.DefaultSnapLen) + } + + lt, err := resolveLinkType(f, len(l2) > 0, len(l3) > 0) + if err != nil { + return nil, err + } + + res := &hex2pcapResult{ + LinkType: lt, + LinkTypeName: pcap.LinkTypeName(lt), + L2Len: len(l2), + L3Len: len(l3), + L7Len: len(l7), + } + + orig := bytes.Clone(pkt) + if f.Fix { + // The layer 3 field marks where the headers end, which tells Fix the + // TCP header length and which SCTP chunk the payload belongs to. + ipOff, hdrLen := len(l2), len(l3) + if len(l3) == 0 { + // No explicit layer 3: find the IP header by parsing the link layer. + if ipOff, err = inet.FindIP(pkt, lt); err != nil { + return nil, fmt.Errorf("fix lengths/checksums: %w (or uncheck the fix option)", err) + } + } + if res.Fix, err = inet.Fix(pkt[ipOff:], hdrLen); err != nil { + return nil, fmt.Errorf("fix lengths/checksums: %w (or uncheck the fix option)", err) + } + res.Fixed = true + res.IPOffset = ipOff + } + + res.Packet = pkt + res.Dump = hexDump(pkt, orig, len(l2), len(l2)+len(l3)) + return res, nil +} + +func resolveLinkType(f hex2pcapForm, haveL2, haveL3 bool) (uint32, error) { + switch f.LinkType { + case "", "auto": + if !haveL2 && haveL3 { + return pcap.LinkTypeRaw, nil + } + return pcap.LinkTypeEthernet, nil + case "custom": + v, err := strconv.ParseUint(strings.TrimSpace(f.CustomLinkType), 0, 32) + if err != nil { + return 0, fmt.Errorf("invalid custom link type %q", f.CustomLinkType) + } + return uint32(v), nil + } + v, err := strconv.ParseUint(f.LinkType, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid link type %q", f.LinkType) + } + return uint32(v), nil +} + +// pcapFilename sanitizes a user supplied download name. +func pcapFilename(name string) string { + name = strings.TrimSuffix(strings.TrimSpace(name), ".pcap") + name = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { + return r + } + return '_' + }, name) + if strings.Trim(name, "._") == "" { + name = "packet" + } + return name + ".pcap" +} + +type dumpByte struct { + Hex string + Class string // l2, l3, l7 or empty for padding + Changed bool + Sep string // whitespace following the byte +} + +type dumpRow struct { + Offset string + Bytes []dumpByte + ASCII string +} + +// hexDump renders pkt as hexdump rows, tagging each byte with the layer it +// came from and whether the fixup changed it relative to orig. +func hexDump(pkt, orig []byte, l3Start, l7Start int) []dumpRow { + var rows []dumpRow + for off := 0; off < len(pkt); off += 16 { + row := dumpRow{Offset: fmt.Sprintf("%04x", off)} + var ascii strings.Builder + for i := off; i < off+16; i++ { + sep := " " + if i-off == 7 { + sep = " " + } + if i >= len(pkt) { + row.Bytes = append(row.Bytes, dumpByte{Hex: " ", Sep: sep}) + continue + } + class := "l7" + switch { + case i < l3Start: + class = "l2" + case i < l7Start: + class = "l3" + } + b := pkt[i] + row.Bytes = append(row.Bytes, dumpByte{Hex: fmt.Sprintf("%02x", b), Class: class, Changed: b != orig[i], Sep: sep}) + if b >= 0x20 && b < 0x7f { + ascii.WriteByte(b) + } else { + ascii.WriteByte('.') + } + } + row.ASCII = ascii.String() + rows = append(rows, row) + } + return rows +} diff --git a/internal/web/hex2pcap_test.go b/internal/web/hex2pcap_test.go new file mode 100644 index 0000000..d46e6fc --- /dev/null +++ b/internal/web/hex2pcap_test.go @@ -0,0 +1,157 @@ +package web + +import ( + "bytes" + "encoding/binary" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "pcap-web-tools/internal/pcap" +) + +func TestBuildPacketConcatenatesWithoutFix(t *testing.T) { + res, err := buildPacket(hex2pcapForm{L2: "aa bb", L3: "cc", L7: "dd:ee"}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(res.Packet, []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee}) { + t.Errorf("packet %x", res.Packet) + } + if res.LinkType != pcap.LinkTypeEthernet || res.Fixed { + t.Errorf("link type %d fixed %v", res.LinkType, res.Fixed) + } +} + +func TestBuildPacketLinkType(t *testing.T) { + tests := []struct { + form hex2pcapForm + want uint32 + }{ + {hex2pcapForm{L3: "45"}, pcap.LinkTypeRaw}, + {hex2pcapForm{L2: "00", L3: "45"}, pcap.LinkTypeEthernet}, + {hex2pcapForm{L7: "00"}, pcap.LinkTypeEthernet}, + {hex2pcapForm{L3: "45", LinkType: "113"}, pcap.LinkTypeLinuxSLL}, + {hex2pcapForm{L7: "00", LinkType: "custom", CustomLinkType: "228"}, 228}, + {hex2pcapForm{L7: "00", LinkType: "custom", CustomLinkType: "0x93"}, 147}, + } + for _, tt := range tests { + res, err := buildPacket(tt.form) + if err != nil { + t.Errorf("%+v: %v", tt.form, err) + continue + } + if res.LinkType != tt.want { + t.Errorf("%+v: link type %d, want %d", tt.form, res.LinkType, tt.want) + } + } +} + +func TestBuildPacketFixWholeFrameInPayload(t *testing.T) { + // Full Ethernet + IPv4 + ICMP frame pasted into the payload field only. + res, err := buildPacket(hex2pcapForm{ + L7: "00112233445566778899aabb0800 45000000000100004001 0000 c0a8000108080808 0800 0000 abcd0001", + Fix: true, + }) + if err != nil { + t.Fatal(err) + } + if res.IPOffset != 14 { + t.Errorf("IP offset %d, want 14", res.IPOffset) + } + if got := binary.BigEndian.Uint16(res.Packet[14+2:]); got != 28 { + t.Errorf("IPv4 total length %d, want 28", got) + } +} + +func TestBuildPacketFixUsesLayer3Boundary(t *testing.T) { + // IPv4 with a 4 byte option and TCP with 12 bytes of options in layer 3: + // the TCP data offset is set from where the layer 3 field ends. + res, err := buildPacket(hex2pcapForm{ + L3: "46000000123440004006 0000 0a0000010a000002 94040000 c350005000000001000000000018ffff00000000 0101080a0000000100000000", + L7: "474554202f", + Fix: true, + }) + if err != nil { + t.Fatal(err) + } + if got := res.Packet[24+12] >> 4; got != 8 { + t.Errorf("TCP data offset %d, want 8", got) + } +} + +func TestBuildPacketErrors(t *testing.T) { + for name, f := range map[string]hex2pcapForm{ + "empty": {}, + "bad hex": {L3: "4g"}, + "fix non-IP": {L7: "ffffffffffff 66778899aabb 0806 0001", Fix: true}, + "bad custom": {L7: "00", LinkType: "custom", CustomLinkType: "x"}, + "fix not IP hdr": {L3: "00", Fix: true}, + } { + if _, err := buildPacket(f); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestPcapFilename(t *testing.T) { + for in, want := range map[string]string{ + "": "packet.pcap", + " ": "packet.pcap", + "dns query": "dns_query.pcap", + "x.pcap": "x.pcap", + "../etc/passwd": ".._etc_passwd.pcap", + `a"b`: "a_b.pcap", + } { + if got := pcapFilename(in); got != want { + t.Errorf("pcapFilename(%q) = %q, want %q", in, got, want) + } + } +} + +func TestHandlers(t *testing.T) { + srv, err := New() + if err != nil { + t.Fatal(err) + } + + for _, path := range []string{"/", "/hex2pcap", "/hex2pcap?example", "/static/style.css"} { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + if rec.Code != http.StatusOK { + t.Errorf("GET %s: %d", path, rec.Code) + } + } + + post := func(v url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/hex2pcap", strings.NewReader(v.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + return rec + } + + rec := post(url.Values{"l3": {"4500"}, "l7": {"aabb"}, "action": {"download"}, "filename": {"test"}}) + if rec.Code != http.StatusOK { + t.Fatalf("download: %d %s", rec.Code, rec.Body) + } + if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="test.pcap"` { + t.Errorf("Content-Disposition %q", cd) + } + body := rec.Body.Bytes() + if len(body) != 24+16+4 || binary.LittleEndian.Uint32(body[20:]) != pcap.LinkTypeRaw { + t.Errorf("pcap body %x", body) + } + + rec = post(url.Values{"l3": {"4500"}, "action": {"preview"}}) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Hex dump") { + t.Errorf("preview: %d", rec.Code) + } + + rec = post(url.Values{"l3": {"zz"}, "action": {"download"}}) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid character") { + t.Errorf("bad hex: %d", rec.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..1abacba --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,83 @@ +// Package web serves the pcap tools web UI. +package web + +import ( + "bytes" + "embed" + "html/template" + "io/fs" + "log" + "net/http" +) + +//go:embed templates static +var assets embed.FS + +// Tool is one sub-application, listed on the index page and in the navigation. +type Tool struct { + Name string + Path string + Description string +} + +var tools = []Tool{ + {Name: "Hex → pcap", Path: "/hex2pcap", Description: "Build a pcap file from hex-encoded layer 2, layer 3 and payload bytes, optionally fixing IP lengths and checksums."}, +} + +// Server is the HTTP handler for the whole application. +type Server struct { + mux *http.ServeMux + pages map[string]*template.Template +} + +// New parses the templates and sets up the routes. +func New() (*Server, error) { + s := &Server{mux: http.NewServeMux(), pages: map[string]*template.Template{}} + + // Each page is parsed together with the shared layout so pages can define + // their own "title" and "content" blocks. + for _, page := range []string{"index.html", "hex2pcap.html"} { + t, err := template.New(page).ParseFS(assets, "templates/layout.html", "templates/"+page) + if err != nil { + return nil, err + } + s.pages[page] = t + } + + static, err := fs.Sub(assets, "static") + if err != nil { + return nil, err + } + s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static))) + s.mux.HandleFunc("GET /{$}", s.handleIndex) + s.mux.HandleFunc("GET /hex2pcap", s.handleHex2PcapForm) + s.mux.HandleFunc("POST /hex2pcap", s.handleHex2Pcap) + return s, nil +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + +// pageData is passed to every page template. +type pageData struct { + Tools []Tool + Active string // path of the current tool, for nav highlighting + Data any // page specific data +} + +func (s *Server) render(w http.ResponseWriter, status int, page, active string, data any) { + var buf bytes.Buffer + if err := s.pages[page].ExecuteTemplate(&buf, "layout", pageData{Tools: tools, Active: active, Data: data}); err != nil { + log.Printf("render %s: %v", page, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + s.render(w, http.StatusOK, "index.html", "/", nil) +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 0000000..e1bf5a4 --- /dev/null +++ b/internal/web/static/style.css @@ -0,0 +1,120 @@ +:root { + --bg: #f7f7f5; + --fg: #1d1d1f; + --muted: #6b6b70; + --card: #ffffff; + --border: #d9d9de; + --accent: #2463eb; + --accent-fg: #ffffff; + --error-bg: #fdecec; + --error-fg: #9b1c1c; + --l2: #b45309; + --l3: #1d4ed8; + --l7: #047857; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #161618; + --fg: #e8e8ea; + --muted: #9a9aa2; + --card: #1f1f22; + --border: #36363c; + --accent: #5b8cff; + --accent-fg: #0b0b0c; + --error-bg: #3a1717; + --error-fg: #ffb4b4; + --l2: #f5a65b; + --l3: #8fb0ff; + --l7: #5fd3a6; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +header { border-bottom: 1px solid var(--border); background: var(--card); } +nav { max-width: 960px; margin: 0 auto; padding: 0.75rem 1rem; display: flex; gap: 1.25rem; flex-wrap: wrap; } +nav a { color: var(--muted); text-decoration: none; } +nav a:hover, nav a.active { color: var(--fg); } +nav a.brand { color: var(--fg); font-weight: 600; } + +main { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } +h1 { margin: 0 0 0.5rem; font-size: 1.6rem; } +h2 { font-size: 1.25rem; margin: 0 0 0.75rem; } +h3 { font-size: 1rem; margin: 1.25rem 0 0.5rem; } +.lead { color: var(--muted); margin-top: 0; } +a { color: var(--accent); } +code { font-family: var(--mono); font-size: 0.9em; } +.hint { color: var(--muted); font-weight: normal; font-size: 0.85em; } + +.tools { list-style: none; padding: 0; display: grid; gap: 0.75rem; } +.tools li { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; } +.tools a { font-weight: 600; font-size: 1.05rem; } +.tools p { margin: 0.25rem 0 0; color: var(--muted); } + +form label { display: block; font-weight: 600; margin: 1rem 0 0.3rem; } +textarea, input, select { + width: 100%; + font: inherit; + color: var(--fg); + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.6rem; +} +textarea { font-family: var(--mono); font-size: 0.9rem; resize: vertical; } +textarea:focus, input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; } + +fieldset { border: 0; padding: 0; margin: 0; } +label.check { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; font-weight: 600; } +label.check input { width: auto; } +.row { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; } +@media (max-width: 720px) { .row { grid-template-columns: 1fr; gap: 0; } } + +.actions { margin-top: 1.25rem; display: flex; gap: 0.75rem; } +button { + font: inherit; + padding: 0.5rem 1.1rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--card); + color: var(--fg); + cursor: pointer; +} +button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } + +.error { background: var(--error-bg); color: var(--error-fg); padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; } +.note { color: var(--muted); margin: 0.35rem 0; } + +.result { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); } +.summary { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; margin: 0; } +.summary dt { color: var(--muted); } +.summary dd { margin: 0; } + +table { border-collapse: collapse; width: 100%; background: var(--card); border: 1px solid var(--border); } +th, td { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 600; font-size: 0.85rem; } +tr.changed td:last-child { font-weight: 600; } + +.dump { + font-family: var(--mono); + font-size: 0.85rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + overflow-x: auto; +} +.dump .off { color: var(--muted); } +.l2 { color: var(--l2); } +.l3 { color: var(--l3); } +.l7 { color: var(--l7); } +.chg { text-decoration: underline; text-underline-offset: 3px; font-weight: 700; } diff --git a/internal/web/templates/hex2pcap.html b/internal/web/templates/hex2pcap.html new file mode 100644 index 0000000..6838702 --- /dev/null +++ b/internal/web/templates/hex2pcap.html @@ -0,0 +1,94 @@ +{{define "title"}}Hex → pcap{{end}} + +{{define "content"}} +{{with .Data}} +

Hex → pcap

+

+ Paste hex for each layer. The fields are decoded and concatenated into one packet. + Any field may be empty, so you can also paste a whole packet into a single field. + Spaces, newlines, :, -, 0x and \x are ignored. + Load example. +

+ +{{if .Error}}{{end}} + +
+ + + + + + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ +{{with .Result}} +
+

Preview

+
+
Packet length
{{len .Packet}} bytes
+
Link type
{{.LinkTypeName}} ({{.LinkType}})
+
Layers
+
+ layer 2: {{.L2Len}} · + layer 3: {{.L3Len}} · + payload: {{.L7Len}} +
+
+ + {{if .Fixed}} +

Fixups IP header at offset {{.IPOffset}}

+ + + + {{range .Fix.Changes}} + + {{end}} + +
FieldOldNew
{{.Field}}{{.Old}}{{.New}}
+ {{range .Fix.Notes}}

{{.}}

{{end}} + {{end}} + +

Hex dump changed bytes are underlined

+
+{{- range .Dump -}}
+{{.Offset}}  {{range .Bytes}}{{.Hex}}{{.Sep}}{{end}} {{.ASCII}}
+{{end -}}
+
+
+{{end}} +{{end}} +{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html new file mode 100644 index 0000000..a413945 --- /dev/null +++ b/internal/web/templates/index.html @@ -0,0 +1,14 @@ +{{define "title"}}Home{{end}} + +{{define "content"}} +

pcap tools

+

Small utilities for working with pcap packet captures.

+ +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..71b75a1 --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,21 @@ +{{define "layout"}} + + + + +{{template "title" .}} · pcap tools + + + +
+ +
+
+{{template "content" .}} +
+ + +{{end}} diff --git a/main.go b/main.go new file mode 100644 index 0000000..26e67d4 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +// Command pcap-web-tools serves a web UI with tools for pcap packet captures. +package main + +import ( + "flag" + "log" + "net/http" + "time" + + "pcap-web-tools/internal/web" +) + +func main() { + addr := flag.String("addr", "localhost:8080", "listen address") + flag.Parse() + + srv, err := web.New() + if err != nil { + log.Fatal(err) + } + httpSrv := &http.Server{ + Addr: *addr, + Handler: srv, + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("listening on http://%s", *addr) + log.Fatal(httpSrv.ListenAndServe()) +}