diff --git a/pkg/storage/iface.go b/pkg/storage/iface.go index 212b1a4..3bb3461 100644 --- a/pkg/storage/iface.go +++ b/pkg/storage/iface.go @@ -1,6 +1,9 @@ package storage -import "io" +import ( + "io" + "io/fs" +) type Iface interface { GetPath() string @@ -10,6 +13,7 @@ type Iface interface { Remove(name string) error Rename(oldName, newName string) error AppendFile(name string, data []byte) error + EnsurePermissions(name string, mode fs.FileMode) error ReadWriter Seeker } @@ -21,6 +25,7 @@ type ReadWriter interface { ConfigPath(filename string) string OpenFile(name string) (io.ReadCloser, error) OpenFileForWriting(name string) (io.WriteCloser, error) + OpenFileForAppending(name string) (io.WriteCloser, error) } type Seeker interface { diff --git a/pkg/storage/local/path.go b/pkg/storage/local/path.go index f1e8d8b..de52e4f 100644 --- a/pkg/storage/local/path.go +++ b/pkg/storage/local/path.go @@ -3,6 +3,7 @@ package localstorage import ( "errors" "fmt" + "io/fs" "os" "os/user" "path" @@ -85,3 +86,7 @@ func (l *LocalStorage) Remove(name string) error { func (l *LocalStorage) Rename(oldName, newName string) error { return os.Rename(path.Join(l.path, oldName), path.Join(l.path, newName)) } + +func (l *LocalStorage) EnsurePermissions(name string, mode fs.FileMode) error { + return os.Chmod(path.Join(l.path, name), mode) +} diff --git a/pkg/storage/local/write.go b/pkg/storage/local/write.go index 91a88b1..5eb3d19 100644 --- a/pkg/storage/local/write.go +++ b/pkg/storage/local/write.go @@ -12,7 +12,7 @@ func (l *LocalStorage) WriteFile(name string, data []byte) error { } func (l *LocalStorage) AppendFile(name string, data []byte) error { - f, err := os.OpenFile(path.Join(l.path, name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + f, err := os.OpenFile(path.Join(l.path, name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0660) if err != nil { return err } @@ -31,3 +31,11 @@ func (l *LocalStorage) OpenFileForWriting(name string) (io.WriteCloser, error) { } return file, nil } + +func (l *LocalStorage) OpenFileForAppending(name string) (io.WriteCloser, error) { + file, err := os.OpenFile(path.Join(l.path, name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0660) + if err != nil { + return nil, err + } + return file, nil +} diff --git a/pkg/storage/memory/storage.go b/pkg/storage/memory/storage.go index 1bde56c..3114181 100644 --- a/pkg/storage/memory/storage.go +++ b/pkg/storage/memory/storage.go @@ -5,6 +5,7 @@ import ( "bytes" "fmt" "io" + "io/fs" "os" "path" "strings" @@ -144,3 +145,18 @@ func (m *MockMemoryStorage) OpenFileForWriting(name string) (io.WriteCloser, err m.Data[name] = (*MockReadWriterData)(&[]byte{}) return m.Data[name], nil } +func (m *MockMemoryStorage) OpenFileForAppending(name string) (io.WriteCloser, error) { + if m.Data == nil { + m.Data = make(map[string]*MockReadWriterData) + } + val, ok := m.Data[name] + if !ok { + m.Data[name] = (*MockReadWriterData)(&[]byte{}) + return m.Data[name], nil + } + m.Data[name] = (*MockReadWriterData)(val) + return m.Data[name], nil +} +func (m *MockMemoryStorage) EnsurePermissions(name string, mode fs.FileMode) error { + return nil +} diff --git a/pkg/wireguard/cache.go b/pkg/wireguard/cache.go index 2214540..f96d886 100644 --- a/pkg/wireguard/cache.go +++ b/pkg/wireguard/cache.go @@ -22,9 +22,13 @@ func UpdateClientCache(peerConfig PeerConfig, clientCache *ClientCache) error { } if !found { + clientID, _, err := getClientIDAndConfigID(peerConfig.ID) + if err != nil { + return fmt.Errorf("can't parse peer config ID (%s): %s", peerConfig.ID, err) + } clientCache.Addresses = append(clientCache.Addresses, ClientCacheAddresses{ Address: *peerConfigAddressParsed, - ClientID: peerConfig.ID, + ClientID: clientID, }) } diff --git a/pkg/wireguard/packetlogger.go b/pkg/wireguard/packetlogger.go index c69f98c..5415a8e 100644 --- a/pkg/wireguard/packetlogger.go +++ b/pkg/wireguard/packetlogger.go @@ -59,6 +59,11 @@ func RunPacketLogger(storage storage.Iface, clientCache *ClientCache, vpnConfig logging.ErrorLog(fmt.Errorf("could not ensure ownership of stats path: %s. Stats disabled", err)) return } + err = storage.EnsurePermissions(path.Join(VPN_STATS_DIR, VPN_PACKETLOGGER_DIR), 0770|os.ModeSetgid) + if err != nil { + logging.ErrorLog(fmt.Errorf("could not ensure permissions of stats path: %s. Stats disabled", err)) + return + } useSyscalls := false if runtime.GOOS == "darwin" { @@ -71,18 +76,25 @@ func RunPacketLogger(storage storage.Iface, clientCache *ClientCache, vpnConfig } defer handle.Close() i := 0 + openFiles := make(PacketLoggerOpenFiles) for { - err := readPacket(storage, handle, clientCache) + err := readPacket(storage, handle, clientCache, openFiles) if err != nil { logging.DebugLog(fmt.Errorf("readPacket error: %s", err)) } if !vpnConfig.EnablePacketLogs { logging.InfoLog("disabling packetlogs") + for _, openFile := range openFiles { + openFile.Close() + } return } if i%1000 == 0 { if err := checkDiskSpace(); err != nil { logging.ErrorLog(fmt.Errorf("disk space error: %s", err)) + for _, openFile := range openFiles { + openFile.Close() + } return } i = 0 @@ -90,14 +102,14 @@ func RunPacketLogger(storage storage.Iface, clientCache *ClientCache, vpnConfig i++ } } -func readPacket(storage storage.Iface, handle *pcap.Handle, clientCache *ClientCache) error { +func readPacket(storage storage.Iface, handle *pcap.Handle, clientCache *ClientCache, openFiles PacketLoggerOpenFiles) error { data, _, err := handle.ReadPacketData() if err != nil { return fmt.Errorf("read packet error: %s", err) } - return parsePacket(storage, data, clientCache) + return parsePacket(storage, data, clientCache, openFiles, time.Now()) } -func parsePacket(storage storage.Iface, data []byte, clientCache *ClientCache) error { +func parsePacket(storage storage.Iface, data []byte, clientCache *ClientCache, openFiles PacketLoggerOpenFiles, now time.Time) error { packet := gopacket.NewPacket(data, layers.IPProtocolIPv4, gopacket.DecodeOptions{Lazy: true, DecodeStreamsAsDatagrams: true}) var ( ip4 *layers.IPv4 @@ -129,19 +141,56 @@ func parsePacket(storage storage.Iface, data []byte, clientCache *ClientCache) e if clientID == "" { // doesn't match a client ID return nil } - now := time.Now() - filename := path.Join(VPN_STATS_DIR, VPN_PACKETLOGGER_DIR, clientID+"-"+now.Format("2006-01-02")+".log") + + // handle open files + logWriter, isFileOpen := openFiles[clientID+"-"+now.Format("2006-01-02")] + if !isFileOpen { + var err error + filename := path.Join(VPN_STATS_DIR, VPN_PACKETLOGGER_DIR, clientID+"-"+now.Format("2006-01-02")+".log") + // check if we need to close an older writer + for openFileKey, logWriterToClose := range openFiles { + filenameSplit := strings.Split(openFileKey, "-") + if len(filenameSplit) > 3 { + dateParsed, err := time.Parse("2006-01-02", strings.Join(filenameSplit[len(filenameSplit)-3:], "-")) + if err != nil { + logging.ErrorLog(fmt.Errorf("packetlogger: closing unknown open file %s (cannot parse date)", filename)) + logWriterToClose.Close() + delete(openFiles, openFileKey) + } else { + if !dateutils.DateEqual(dateParsed, now) { + logWriterToClose.Close() + delete(openFiles, openFileKey) + } + } + } else { + logging.ErrorLog(fmt.Errorf("packetlogger: closing file without a date %s", filename)) + logWriterToClose.Close() + delete(openFiles, openFileKey) + } + } + // open new file for appending + logWriter, err = storage.OpenFileForAppending(filename) + if err != nil { + return fmt.Errorf("could not open file for appending (%s): %s", clientID+"-"+now.Format("2006-01-02"), err) + } + err = storage.EnsurePermissions(filename, 0640) + if err != nil { + return fmt.Errorf("could not set permissions (%s): %s", clientID+"-"+now.Format("2006-01-02"), err) + } + openFiles[clientID+"-"+now.Format("2006-01-02")] = logWriter + } + if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil { tcpPacket, _ := tcpLayer.(*layers.TCP) if tcpPacket.SYN { - storage.AppendFile(filename, []byte(strings.Join([]string{ - time.Now().Format(TIMESTAMP_FORMAT), + logWriter.Write([]byte(strings.Join([]string{ + now.Format(TIMESTAMP_FORMAT), "tcp", srcIP.String(), dstIP.String(), strconv.FormatUint(uint64(tcpPacket.SrcPort), 10), strconv.FormatUint(uint64(tcpPacket.DstPort), 10)}, - ",")+"\n", + ",") + "\n", )) } switch tcpPacket.DstPort { @@ -153,15 +202,15 @@ func parsePacket(storage storage.Iface, data []byte, clientCache *ClientCache) e if err != nil { fmt.Printf("debug: can't parse http packet: %s", err) } else { - storage.AppendFile(filename, []byte(strings.Join([]string{ - time.Now().Format(TIMESTAMP_FORMAT), + logWriter.Write([]byte(strings.Join([]string{ + now.Format(TIMESTAMP_FORMAT), "http", srcIP.String(), dstIP.String(), strconv.FormatUint(uint64(tcpPacket.SrcPort), 10), strconv.FormatUint(uint64(tcpPacket.DstPort), 10), "http://" + req.Host + req.URL.RequestURI()}, - ",")+"\n", + ",") + "\n", )) } } @@ -170,15 +219,15 @@ func parsePacket(storage storage.Iface, data []byte, clientCache *ClientCache) e if tls, ok := packet.Layer(layers.LayerTypeTLS).(*layers.TLS); ok { for _, handshake := range tls.Handshake { if sni := parseTLSExtensionSNI([]byte(handshake.ClientHello.Extensions)); sni != nil { - storage.AppendFile(filename, []byte(strings.Join([]string{ - time.Now().Format(TIMESTAMP_FORMAT), + logWriter.Write([]byte(strings.Join([]string{ + now.Format(TIMESTAMP_FORMAT), "https", srcIP.String(), dstIP.String(), strconv.FormatUint(uint64(tcpPacket.SrcPort), 10), strconv.FormatUint(uint64(tcpPacket.DstPort), 10), string(sni)}, - ",")+"\n", + ",") + "\n", )) } } @@ -205,15 +254,15 @@ func parsePacket(storage storage.Iface, data []byte, clientCache *ClientCache) e } } - storage.AppendFile(filename, []byte(strings.Join([]string{ - time.Now().Format(TIMESTAMP_FORMAT), + logWriter.Write([]byte(strings.Join([]string{ + now.Format(TIMESTAMP_FORMAT), "udp", srcIP.String(), dstIP.String(), strconv.FormatUint(uint64(udp.SrcPort), 10), strconv.FormatUint(uint64(udp.DstPort), 10), strings.Join(questions, "#")}, - ",")+"\n")) + ",") + "\n")) } } } diff --git a/pkg/wireguard/packetlogger_test.go b/pkg/wireguard/packetlogger_test.go index 0b0e2ef..dbd3cab 100644 --- a/pkg/wireguard/packetlogger_test.go +++ b/pkg/wireguard/packetlogger_test.go @@ -57,13 +57,14 @@ func TestParsePacket(t *testing.T) { "450201050000400040066c260abdb8020a00010cf24a01bb510f1260c4b504a180180800ea1300000101080a327dffc0edef002a1703030035131e32cc93174219580748842686d43e1cbb73501f643eaa49b3b7ba50a9f0a97e19ec926f8b5b141b363067d9a31061b146010d8f17030300511611c04909f5346b580fe1a95c68b2a62389ca6ed7e2f31ddb38cb191cf0997e16b5efaa9248a213e621869d071af7339ddafaee642953538a03d89cb3896ecf6756f5fb80f1866671282da72dce691169170303003c3bd012039a27a373dd1b4e7509e0e9aaefc4cfae6adcae6f670501e2577e20c98233761878d9f64355a89aa389f56480517bada888a2625ef211cb5e", } now := time.Now() + openFiles := make(PacketLoggerOpenFiles) for _, s := range input { data, err := hex.DecodeString(s) if err != nil { t.Fatalf("hex decode error: %s", err) } - err = parsePacket(storage, data, clientCache) + err = parsePacket(storage, data, clientCache, openFiles, now) if err != nil { t.Fatalf("parse error: %s", err) } @@ -105,13 +106,14 @@ func TestParsePacketSNI(t *testing.T) { `450000d100004000400682160abdb80240e9b468ec5001bb4f71ed891a93673d8018080468f400000101080a1329f7772c5410131603010098010000940301f1d62f57f05cc00fc8fb984e7fc381a26adc301ec143b9bab6d36f3f1b15c97200002ec014c00a0039ff850088008100350084c013c00900330045002f0041c011c00700050004c012c0080016000a00ff0100003d00000013001100000e7777772e676f6f676c652e636f6d000b00020100000a000a0008001d0017001800190010000e000c02683208687474702f312e31`, } now := time.Now() + openFiles := make(PacketLoggerOpenFiles) for _, s := range input { data, err := hex.DecodeString(s) if err != nil { t.Fatalf("hex decode error: %s", err) } - err = parsePacket(storage, data, clientCache) + err = parsePacket(storage, data, clientCache, openFiles, now) if err != nil { t.Fatalf("parse error: %s", err) } @@ -126,6 +128,96 @@ func TestParsePacketSNI(t *testing.T) { } } +func TestParsePacketOpenFiles(t *testing.T) { + storage := &memorystorage.MockMemoryStorage{} + clientCache := &ClientCache{ + Addresses: []ClientCacheAddresses{ + { + Address: net.IPNet{ + IP: net.ParseIP("10.189.184.2"), + Mask: net.IPMask(net.ParseIP("255.255.255.255").To4()), + }, + ClientID: "1-2-3-4", + }, + { + Address: net.IPNet{ + IP: net.ParseIP("10.189.184.3"), + Mask: net.IPMask(net.ParseIP("255.255.255.255").To4()), + }, + ClientID: "1-2-3-5", + }, + }, + } + input := []string{ + // DNS reqs + "45000037e04900004011cdab0abdb8030a000002e60d00350023d6861e1501000001000000000000056170706c6503636f6d0000010001", + "4500004092d1000040111b1b0abdb8030a000002c73b0035002c4223b28e01000001000000000000037777770a676f6f676c656170697303636f6d0000410001", + "450000e300004000fe11af480a0000030abdb8020035dbb500cffccbad65818000010000000100000975732d656173742d310470726f6402707209616e616c797469637307636f6e736f6c65036177730361327a03636f6d00001c00010975732d656173742d310470726f6402707209616e616c797469637307636f6e736f6c65036177730361327a03636f6d00000600010000014b004b076e732d3136333709617773646e732d313202636f02756b0011617773646e732d686f73746d617374657206616d617a6f6e03636f6d000000000100001c20000003840012750000015180", + "450000a100004000fe11af8a0a0000030abdb8020035e136008db8bd155f81830001000000010000026462075f646e732d7364045f756470086174746c6f63616c036e657400000c0001c01c00060001000003c0004b046f726375026f72026272026e7007656c732d676d7303617474c0250d726d2d686f73746d617374657203656d730361747403636f6d0000000001000151800000271000093a8000015180", + // https reqs + "450000400000400040066ced0abdb8020a00010cf24a01bb510f111000000000b0c2ffffe119000002040564010303060101080a327dff040000000004020000", + "450000340000400040066cf90abdb8020a00010cf24a01bb510f1111c4b4fb4b801008046b8700000101080a327dff34edeeff9e", + "4502017d0000400040066bae0abdb8020a00010cf24a01bb510f1111c4b4fb4b801808041b1500000101080a327dff36edeeff9e1603010144010001400303e3b233de9dcd3f71f4c6e3d0d45ec25144e2fcdf8c676e52ff5cfc021123786020056eefe25e5b4e9abec2953b5fa9bc1f68dd09d7ad4ddce858476b4aaaa029b80062130313021301cca9cca8ccaac030c02cc028c024c014c00a009f006b0039ff8500c400880081009d003d003500c00084c02fc02bc027c023c013c009009e0067003300be0045009c003c002f00ba0041c011c00700050004c012c0080016000a00ff01000095002b0009080304030303020301003300260024001d0020dc2b5e4f0741b2ff9982fe2bfa6641e22fe80e5b50811780b82aafae96570c2400000018001600001376706e2d7365727665722e696e3469742e696f000b00020100000a000a0008001d001700180019000d00180016080606010603080505010503080404010403020102030010000e000c02683208687474702f312e31", + "450000340000400040066cf90abdb8020a00010cf24a01bb510f125ac4b500a3801007ee649700000101080a327dff66edeeffd1", + "450000340000400040066cf90abdb8020a00010cf24a01bb510f125ac4b504a1801007f0609600000101080a327dff67edeeffd1", + "4502003a0000400040066cf10abdb8020a00010cf24a01bb510f125ac4b504a180180800487100000101080a327dff6aedeeffd1140303000101", + "450201050000400040066c260abdb8020a00010cf24a01bb510f1260c4b504a180180800ea1300000101080a327dffc0edef002a1703030035131e32cc93174219580748842686d43e1cbb73501f643eaa49b3b7ba50a9f0a97e19ec926f8b5b141b363067d9a31061b146010d8f17030300511611c04909f5346b580fe1a95c68b2a62389ca6ed7e2f31ddb38cb191cf0997e16b5efaa9248a213e621869d071af7339ddafaee642953538a03d89cb3896ecf6756f5fb80f1866671282da72dce691169170303003c3bd012039a27a373dd1b4e7509e0e9aaefc4cfae6adcae6f670501e2577e20c98233761878d9f64355a89aa389f56480517bada888a2625ef211cb5e", + } + now := time.Now() + nowMinusOneDay := now.AddDate(0, 0, -1) + openFiles := make(PacketLoggerOpenFiles) + for _, s := range input { + + data, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("hex decode error: %s", err) + } + err = parsePacket(storage, data, clientCache, openFiles, nowMinusOneDay) + if err != nil { + t.Fatalf("parse error: %s", err) + } + } + + out1, err := storage.ReadFile(path.Join(VPN_STATS_DIR, VPN_PACKETLOGGER_DIR, "1-2-3-4-"+nowMinusOneDay.Format("2006-01-02")+".log")) + if err != nil { + t.Fatalf("read file error: %s", err) + } + out2, err := storage.ReadFile(path.Join(VPN_STATS_DIR, VPN_PACKETLOGGER_DIR, "1-2-3-5-"+nowMinusOneDay.Format("2006-01-02")+".log")) + if err != nil { + t.Fatalf("read file error: %s", err) + } + if !strings.Contains(string(out2), `,udp,10.189.184.3,10.0.0.2,58893,53,apple.com`) { + t.Fatalf("unexpected output. Expected udp record") + } + if !strings.Contains(string(out1), `,https,10.189.184.2,10.0.1.12,62026,443,vpn-server.in4it.io`) { + t.Fatalf("unexpected output. Expected https record") + } + if len(openFiles) != 2 { + t.Fatalf("unexpected open files count: %d", len(openFiles)) + } + for _, s := range input { + data, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("hex decode error: %s", err) + } + err = parsePacket(storage, data, clientCache, openFiles, now) + if err != nil { + t.Fatalf("parse error: %s", err) + } + } + + dir, err := storage.ReadDir(path.Join(VPN_STATS_DIR, VPN_PACKETLOGGER_DIR)) + if err != nil { + t.Fatalf("read dir error: %s", err) + } + if len(dir) != 4 { + t.Fatalf("expected 4 files written") + } + if len(openFiles) != 2 { + t.Fatalf("unexpected open files count: %d", len(openFiles)) + } +} + func TestParseTLSExtensionSNI(t *testing.T) { input := []string{ "00000013001100000e7777772e676f6f676c652e636f6d000b00020100000a000a0008001d0017001800190010000e000c02683208687474702f312e31", diff --git a/pkg/wireguard/types.go b/pkg/wireguard/types.go index 45c8083..a174da0 100644 --- a/pkg/wireguard/types.go +++ b/pkg/wireguard/types.go @@ -1,6 +1,7 @@ package wireguard import ( + "io" "net" "net/netip" "time" @@ -85,3 +86,7 @@ type ClientCacheAddresses struct { Address net.IPNet ClientID string } + +// packetlogger open files + +type PacketLoggerOpenFiles map[string]io.WriteCloser diff --git a/pkg/wireguard/wireguardclientconfig.go b/pkg/wireguard/wireguardclientconfig.go index e934852..e65d77c 100644 --- a/pkg/wireguard/wireguardclientconfig.go +++ b/pkg/wireguard/wireguardclientconfig.go @@ -501,24 +501,27 @@ func ReactivateAllClientConfigs(storage storage.Iface, userID string) error { } func HasClientUserID(filename string, userID string) bool { - name := strings.TrimSuffix(filename, ".json") - nameSplit := strings.Split(name, "-") - filenameUserID := strings.Join(nameSplit[:len(nameSplit)-1], "-") - return filenameUserID == userID + clientID, _, _ := getClientIDAndConfigID(strings.TrimSuffix(filename, ".json")) + return clientID == userID } func getConfigNumberFromConnectionFile(filename string) (int, error) { - name := strings.TrimSuffix(filename, ".json") + _, configNumber, err := getClientIDAndConfigID(strings.TrimSuffix(filename, ".json")) + return configNumber, err +} +func getClientIDAndConfigID(name string) (string, int, error) { nameSplit := strings.Split(name, "-") if len(nameSplit) < 2 { - return -1, fmt.Errorf("invalid connection name") + return "", -1, fmt.Errorf("invalid connection name") } i, err := strconv.Atoi(nameSplit[len(nameSplit)-1]) if err != nil { - return -1, fmt.Errorf("could not convert string to int: %s", err) + return "", -1, fmt.Errorf("could not convert string to int: %s", err) } - return i, nil + clientID := strings.Join(nameSplit[:len(nameSplit)-1], "-") + return clientID, i, nil } + func networkIntersects(network1, network2 *net.IPNet) bool { return network2.Contains(network1.IP) || network1.Contains(network2.IP) } diff --git a/pkg/wireguard/wireguardclientconfig_test.go b/pkg/wireguard/wireguardclientconfig_test.go index d6d38c2..3a33628 100644 --- a/pkg/wireguard/wireguardclientconfig_test.go +++ b/pkg/wireguard/wireguardclientconfig_test.go @@ -829,3 +829,58 @@ func TestUpdateClientConfigNewClientAddressPrefix(t *testing.T) { } } + +func TestGetClientIDAndConfigID(t *testing.T) { + testCases := []string{ + "b7f3355d-1009-452c-8b81-ec4642ab7754-1", + "1-2-3-4-5-6-7", + "1-2-3", + "1-2", + "1", + "", + "garbage", + } + expectedUserID := []string{ + "b7f3355d-1009-452c-8b81-ec4642ab7754", + "1-2-3-4-5-6", + "1-2", + "1", + "", + "", + "", + } + expectedConfigID := []int{ + 1, + 7, + 3, + 2, + -1, + -1, + -1, + } + errExpected := []bool{ + false, + false, + false, + false, + true, + true, + true, + } + for k, testCase := range testCases { + userID, configID, err := getClientIDAndConfigID(testCase) + if err != nil && !errExpected[k] { + t.Fatalf("got error: %s", err) + } + if err == nil && errExpected[k] { + t.Fatalf("expected error, but got nil") + } + if userID != expectedUserID[k] { + t.Fatalf("userid mismatch. Expected: %s, got: %s", expectedUserID[k], userID) + } + if configID != expectedConfigID[k] { + t.Fatalf("config ID mismatch. Expected: %d, got: %d", expectedConfigID[k], configID) + } + } + +}