Add hex2pcap

This commit is contained in:
2026-10-05 20:07:31 +02:00
parent 477e6c0ee7
commit 5b5cf211d0
19 changed files with 1694 additions and 0 deletions
+41
View File
@@ -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'
}
+44
View File
@@ -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)
}
}
}
+381
View File
@@ -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)
}
+244
View File
@@ -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)
}
}
}
+58
View File
@@ -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
}
+81
View File
@@ -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
}
+36
View File
@@ -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")
}
}
+268
View File
@@ -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
}
+157
View File
@@ -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)
}
}
+83
View File
@@ -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)
}
+120
View File
@@ -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; }
+94
View File
@@ -0,0 +1,94 @@
{{define "title"}}Hex → pcap{{end}}
{{define "content"}}
{{with .Data}}
<h1>Hex → pcap</h1>
<p class="lead">
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, <code>:</code>, <code>-</code>, <code>0x</code> and <code>\x</code> are ignored.
<a href="/hex2pcap?example">Load example</a>.
</p>
{{if .Error}}<div class="error" role="alert">{{.Error}}</div>{{end}}
<form method="post" action="/hex2pcap">
<label for="l2">Layer 2 <span class="hint">e.g. Ethernet header</span></label>
<textarea id="l2" name="l2" rows="2" spellcheck="false">{{.Form.L2}}</textarea>
<label for="l3">Layer 3 <span class="hint">IPv4/IPv6 header with options, optionally followed by the TCP/UDP/ICMP header, or the SCTP header and chunk header(s)</span></label>
<textarea id="l3" name="l3" rows="4" spellcheck="false">{{.Form.L3}}</textarea>
<label for="l7">Payload <span class="hint">the rest of the packet, not interpreted</span></label>
<textarea id="l7" name="l7" rows="6" spellcheck="false">{{.Form.L7}}</textarea>
<fieldset>
<label class="check">
<input type="checkbox" name="fix"{{if .Form.Fix}} checked{{end}}>
Recalculate IP lengths and checksums
<span class="hint">IPv4/IPv6 length, IPv4 header checksum, TCP/UDP/ICMP/ICMPv6/SCTP checksum, UDP length.
The end of the layer 3 field also sets the TCP data offset and the length of the SCTP chunk the payload belongs to
(paste chunk data without padding).</span>
</label>
<div class="row">
<div>
<label for="linktype">Link type</label>
<select id="linktype" name="linktype">
{{range .Options}}<option value="{{.Value}}"{{if eq .Value $.Data.Form.LinkType}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
<div>
<label for="customlinktype">Custom link type <span class="hint">when “Custom…” is selected</span></label>
<input id="customlinktype" name="customlinktype" inputmode="numeric" value="{{.Form.CustomLinkType}}" placeholder="e.g. 228">
</div>
<div>
<label for="filename">File name</label>
<input id="filename" name="filename" value="{{.Form.Filename}}" placeholder="packet.pcap">
</div>
</div>
</fieldset>
<div class="actions">
<button type="submit" name="action" value="preview">Preview</button>
<button type="submit" name="action" value="download" class="primary">Download pcap</button>
</div>
</form>
{{with .Result}}
<section class="result">
<h2>Preview</h2>
<dl class="summary">
<dt>Packet length</dt><dd>{{len .Packet}} bytes</dd>
<dt>Link type</dt><dd>{{.LinkTypeName}} ({{.LinkType}})</dd>
<dt>Layers</dt>
<dd>
<span class="l2">layer 2: {{.L2Len}}</span> ·
<span class="l3">layer 3: {{.L3Len}}</span> ·
<span class="l7">payload: {{.L7Len}}</span>
</dd>
</dl>
{{if .Fixed}}
<h3>Fixups <span class="hint">IP header at offset {{.IPOffset}}</span></h3>
<table>
<thead><tr><th>Field</th><th>Old</th><th>New</th></tr></thead>
<tbody>
{{range .Fix.Changes}}
<tr{{if ne .Old .New}} class="changed"{{end}}><td>{{.Field}}</td><td><code>{{.Old}}</code></td><td><code>{{.New}}</code></td></tr>
{{end}}
</tbody>
</table>
{{range .Fix.Notes}}<p class="note">{{.}}</p>{{end}}
{{end}}
<h3>Hex dump <span class="hint">changed bytes are underlined</span></h3>
<pre class="dump">
{{- range .Dump -}}
<span class="off">{{.Offset}}</span> {{range .Bytes}}<span class="{{.Class}}{{if .Changed}} chg{{end}}">{{.Hex}}</span>{{.Sep}}{{end}} {{.ASCII}}
{{end -}}
</pre>
</section>
{{end}}
{{end}}
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "title"}}Home{{end}}
{{define "content"}}
<h1>pcap tools</h1>
<p class="lead">Small utilities for working with pcap packet captures.</p>
<ul class="tools">
{{range .Tools}}
<li>
<a href="{{.Path}}">{{.Name}}</a>
<p>{{.Description}}</p>
</li>
{{end}}
</ul>
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{template "title" .}} · pcap tools</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<nav>
<a href="/" class="brand">pcap tools</a>
{{range .Tools}}<a href="{{.Path}}"{{if eq .Path $.Active}} class="active"{{end}}>{{.Name}}</a>{{end}}
</nav>
</header>
<main>
{{template "content" .}}
</main>
</body>
</html>
{{end}}