Inline frontend instead of submodule

This commit is contained in:
dan
2025-12-06 17:34:40 +03:00
parent 2d265bb71d
commit c4af8465aa
36 changed files with 34880 additions and 4 deletions

Submodule frontend deleted from f90217cd6c

86
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,86 @@
.idea
cmake-build-debug/
cmake-build-release/
*.iws
out/
.idea_modules/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
*.bak
/.tgitconfig
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pids
*.pid
*.seed
*.pid.lock
lib-cov
coverage
.nyc_output
.grunt
bower_components
.lock-wscript
build/Release
node_modules/
jspm_packages/
typings/
.npm
.eslintcache
.node_repl_history
*.tgz
.yarn-integrity
.env
.next
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-v][a-z]
[._]sw[a-p]
Session.vim
.netrwhist
*~
tags
[._]*.un~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
.org-id-locations
*_archive
*_flymake.*
/eshell/history
/eshell/lastdir
/elpa/
*.rel
/auto/
.cask/
dist/
flycheck_*.el
/server/
.projectile
.dir-locals.el
.fuse_hidden*
.directory
.Trash-*
.nfs*
$*$

29
frontend/README.md Normal file
View File

@@ -0,0 +1,29 @@
# packmate-frontend
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Run your tests
```
npm run test
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

8
frontend/babel.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
presets: [
'@vue/app',
],
plugins: [
'@babel/plugin-proposal-optional-chaining',
],
};

31852
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
frontend/package.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "packmate-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.4.0",
"bootstrap": "^4.6.2",
"bootstrap-darkmode": "^0.9.1",
"bootstrap-vue": "^2.23.1",
"core-js": "^3.26.1",
"idb": "^5.0.8",
"sockjs-client": "^1.6.1",
"vue": "^2.6.14",
"vue-infinite-loading": "^2.4.5",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
"vuex-persistedstate": "^3.2.1",
"vuex-shared-mutations": "^1.0.2"
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.18.9",
"@fortawesome/fontawesome-free": "^5.15.4",
"@vue/cli-plugin-babel": "^4.5.19",
"@vue/cli-plugin-eslint": "^4.5.19",
"@vue/cli-service": "^4.3.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.8.0",
"eslint-plugin-vue": "^6.2.2",
"node-sass": "^8.0.0",
"sass-loader": "^10.4.1",
"vue-template-compiler": "^2.7.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"indent": [
"off"
],
"no-mixed-spaces-and-tabs": "off",
"comma-dangle": [
"warn",
{
"arrays": "always",
"objects": "always",
"imports": "always",
"exports": "always",
"functions": "never"
}
],
"no-console": "off"
},
"parserOptions": {
"parser": "babel-eslint"
}
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#afd9f0</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<title>0xb00b5 team Packmate</title>
<meta name="author" content="@danosito">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<!--suppress HtmlUnknownTarget -->
<link href="<%= BASE_URL %>favicon.ico" rel="icon">
</head>
<body>
<noscript>
<strong>Please enable JavaScript to continue!!</strong>
</noscript>
<div id="app"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1453.000000pt" height="1453.000000pt" viewBox="0 0 1453.000000 1453.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,1453.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M7065 14523 c-1372 -40 -2685 -458 -3825 -1216 -157 -105 -420 -294
-420 -303 0 -2 17 3 38 11 20 8 84 26 142 40 58 14 148 39 200 54 127 39 219
58 314 66 44 4 100 13 124 20 25 8 81 17 126 21 44 3 89 11 99 16 95 49 332
73 722 73 306 -1 463 -10 636 -40 167 -28 298 -56 404 -85 44 -12 103 -27 130
-34 60 -16 250 -77 320 -104 28 -11 86 -33 130 -50 175 -66 550 -246 619 -296
17 -13 67 -44 111 -70 44 -26 102 -61 129 -78 l49 -32 47 17 c88 31 191 13
245 -43 l28 -29 51 46 c69 62 286 203 366 238 36 16 70 32 75 36 10 7 190 82
290 121 108 42 410 138 433 138 8 0 28 6 45 14 74 32 376 88 657 120 368 43
516 44 1020 6 413 -30 709 -73 915 -133 100 -28 208 -97 265 -170 45 -55 45
-64 11 -142 -10 -22 -30 -69 -44 -105 -67 -171 -218 -472 -339 -680 -99 -168
-127 -225 -128 -254 0 -10 30 -52 68 -94 117 -130 132 -147 132 -154 0 -5 -30
-56 -66 -115 -37 -60 -80 -133 -97 -163 l-29 -54 13 -106 c17 -136 15 -322 -4
-430 -18 -94 -51 -233 -61 -248 -3 -5 -12 -57 -21 -115 -11 -82 -22 -122 -50
-176 -40 -81 -39 -76 -22 -93 12 -12 35 -47 179 -269 59 -90 124 -186 228
-337 24 -34 64 -86 89 -115 25 -29 69 -80 96 -112 67 -79 238 -252 298 -299
104 -84 198 -144 317 -205 126 -63 185 -100 177 -108 -5 -4 -168 10 -302 27
-174 21 -383 102 -532 206 -37 26 -81 57 -98 68 -51 36 -118 98 -163 150 -59
71 -58 69 -75 102 -14 25 -16 26 -17 8 0 -12 5 -46 11 -75 6 -30 17 -88 24
-129 7 -41 23 -127 35 -190 25 -135 46 -281 60 -435 6 -60 17 -171 25 -245 8
-74 17 -227 20 -340 3 -113 10 -234 15 -270 5 -36 12 -361 16 -723 6 -514 4
-679 -6 -755 -15 -117 -5 -231 36 -387 14 -55 30 -122 35 -150 5 -27 14 -59
19 -70 5 -11 21 -55 35 -97 57 -171 195 -440 297 -582 15 -21 30 -55 34 -75 3
-20 19 -49 34 -66 37 -39 59 -94 49 -120 -7 -18 -8 -18 -8 -2 -2 36 -77 134
-206 268 -121 125 -211 233 -277 334 -114 176 -160 253 -217 370 -76 158 -129
292 -157 400 -6 22 -10 -1 -15 -85 -11 -185 -39 -500 -49 -560 -6 -30 -14 -84
-19 -120 -20 -141 -42 -266 -51 -290 -5 -14 -12 -45 -16 -70 -3 -24 -10 -51
-14 -60 -4 -8 -15 -40 -24 -70 -8 -30 -24 -72 -35 -93 l-19 -38 59 -112 c32
-62 66 -126 74 -144 34 -73 147 -194 252 -270 29 -21 54 -43 56 -48 3 -7 -48
-9 -147 -7 -125 3 -164 7 -227 27 -75 23 -179 73 -210 101 -8 8 -20 14 -26 14
-24 0 -203 227 -203 258 0 6 -4 12 -8 12 -4 0 -14 16 -21 35 -15 44 -27 45
-34 3 -3 -18 -13 -67 -21 -108 -8 -41 -19 -97 -25 -125 -9 -47 -40 -144 -61
-190 -49 -111 -60 -139 -60 -151 0 -8 -4 -14 -10 -14 -5 0 -10 -5 -10 -11 0
-14 -72 -124 -134 -204 -79 -103 -216 -211 -346 -275 -36 -17 -73 -35 -82 -41
-10 -5 -24 -9 -31 -9 -7 0 -29 -11 -47 -25 -20 -15 -36 -21 -38 -15 -4 11 56
118 111 199 29 43 161 298 184 356 22 57 113 322 113 331 0 3 -28 -15 -62 -39
-93 -66 -223 -138 -308 -171 -41 -15 -84 -32 -95 -37 -18 -7 -178 -49 -189
-49 -7 0 -98 -158 -119 -205 -23 -53 -38 -164 -25 -188 5 -10 8 -20 6 -22 -14
-14 -74 70 -99 137 -41 111 -64 391 -39 477 5 19 7 37 5 40 -3 2 -59 -51 -126
-119 -117 -120 -180 -209 -249 -350 -12 -25 -26 -53 -31 -63 -5 -9 -12 -37
-15 -61 l-6 -43 88 -5 c107 -6 186 -41 249 -109 22 -24 80 -88 129 -142 139
-152 297 -276 471 -367 44 -23 99 -55 123 -71 24 -16 47 -29 53 -29 5 0 14
-11 20 -24 9 -18 26 -28 78 -41 36 -9 104 -28 151 -42 162 -48 179 -52 287
-56 153 -6 377 -46 513 -91 54 -17 75 -20 125 -13 108 15 388 22 510 14 66 -4
206 -11 310 -15 189 -8 190 -8 260 -42 125 -60 200 -115 256 -187 29 -38 56
-72 61 -78 10 -11 305 279 472 465 1000 1111 1631 2489 1815 3965 111 884 56
1797 -159 2654 -293 1167 -851 2216 -1656 3111 -124 138 -431 444 -564 561
-775 687 -1713 1214 -2692 1514 -605 185 -1213 288 -1858 315 -135 5 -276 9
-315 8 -38 -1 -110 -3 -160 -5z"/>
<path d="M2499 12743 c-148 -127 -480 -454 -623 -613 -1067 -1189 -1702 -2650
-1846 -4242 -43 -480 -29 -1086 35 -1568 198 -1471 822 -2821 1811 -3920 137
-152 430 -443 579 -574 106 -94 110 -96 119 -74 21 50 111 196 156 253 42 52
61 67 138 104 50 23 114 50 144 59 68 20 327 28 418 11 36 -6 94 -15 130 -20
36 -4 124 -18 195 -29 72 -12 158 -25 193 -30 54 -8 152 -34 204 -55 13 -5 21
0 29 18 6 14 21 28 33 32 18 5 569 26 764 29 46 1 72 5 72 12 0 7 15 23 33 36
54 41 222 138 239 138 8 0 22 7 29 16 7 9 40 37 74 62 58 45 485 466 555 548
19 23 67 78 105 124 39 45 80 86 93 91 31 12 84 11 116 -1 23 -8 26 -7 26 14
0 19 -27 65 -50 86 -9 8 -72 90 -98 128 -14 21 -42 70 -63 111 l-37 73 -7 -33
c-9 -46 -46 -158 -62 -189 -7 -14 -13 -29 -13 -35 0 -18 -44 -98 -93 -172 -81
-121 -197 -214 -197 -157 0 13 -67 149 -85 172 -92 115 -132 161 -139 161 -5
1 -37 27 -71 58 -35 32 -69 63 -76 68 -24 18 -86 55 -93 55 -7 0 46 -114 57
-118 4 -2 7 -8 7 -14 0 -5 17 -40 39 -76 105 -182 127 -232 101 -232 -5 0 -10
4 -10 8 0 5 -10 14 -22 19 -62 29 -319 251 -360 310 -16 24 -37 51 -47 61 -21
21 -66 79 -113 147 -28 42 -103 193 -145 295 -8 21 -11 14 -16 -48 -8 -79 -45
-261 -57 -277 -4 -5 -11 -26 -15 -45 -4 -19 -11 -39 -15 -45 -4 -5 -13 -28
-20 -50 -35 -108 -220 -355 -266 -355 -13 0 -26 -4 -29 -10 -3 -5 -34 -26 -68
-46 -70 -41 -77 -48 -77 -87 0 -16 -4 -26 -10 -22 -15 9 -12 73 3 82 40 24
110 216 126 343 6 41 15 106 21 143 10 58 9 71 -3 85 -18 20 -127 181 -127
187 0 2 -13 23 -29 47 -16 24 -42 66 -57 94 -16 28 -60 104 -99 168 -111 186
-308 565 -330 636 -4 14 -10 27 -14 30 -3 3 -14 21 -23 40 l-17 35 0 -60 c0
-107 60 -452 88 -505 5 -9 12 -31 16 -50 4 -19 11 -39 15 -45 4 -5 11 -26 15
-45 19 -90 158 -362 271 -530 48 -71 85 -132 83 -135 -6 -5 -145 86 -241 158
-128 97 -297 286 -298 335 0 6 -3 12 -8 12 -7 0 -38 56 -65 120 -9 19 -25 57
-36 83 -11 26 -23 63 -27 83 -3 19 -10 36 -14 39 -5 3 -11 24 -15 48 -4 23
-11 47 -16 53 -4 5 -13 44 -19 85 -6 40 -17 83 -23 94 -16 28 -22 822 -7 965
6 58 16 164 21 235 6 72 17 153 24 180 20 73 28 132 17 120 -5 -5 -23 -35 -39
-65 -50 -92 -58 -105 -70 -113 -7 -4 -13 -12 -13 -18 0 -13 -41 -72 -58 -82
-6 -4 -12 -14 -12 -20 0 -17 -188 -217 -204 -217 -7 0 -24 -10 -37 -21 -45
-41 -214 -145 -259 -161 -25 -9 -76 -19 -114 -22 -38 -4 -82 -14 -98 -22 -16
-8 -33 -12 -38 -9 -15 9 16 36 74 66 70 35 96 51 118 72 10 9 23 17 28 17 6 0
10 5 10 11 0 7 11 22 24 34 42 39 114 152 167 263 65 137 63 131 95 237 15 50
30 95 35 100 4 6 10 28 14 50 4 22 13 46 19 54 14 18 13 241 -2 250 -5 3 -13
38 -17 76 -3 39 -10 79 -15 90 -5 11 -14 67 -20 125 -6 58 -15 140 -20 183
-45 362 -43 1174 4 1477 8 52 18 124 22 160 3 35 10 71 15 80 4 8 12 44 18 80
12 83 66 302 80 330 5 9 12 31 16 50 4 19 13 46 21 60 7 14 31 70 54 125 22
55 45 108 50 117 19 34 9 36 -47 8 -117 -57 -191 -86 -263 -101 -25 -6 -54
-14 -65 -20 -58 -27 -335 -42 -460 -24 -80 11 -194 72 -302 163 -80 66 -71 73
47 42 56 -15 316 -8 333 9 6 6 24 11 40 11 16 0 43 6 60 14 18 8 61 27 97 42
36 16 83 39 105 51 22 13 51 28 65 35 14 7 27 15 30 18 3 3 16 11 30 18 48 24
70 39 126 83 31 24 90 71 132 104 43 33 82 64 89 70 6 5 28 21 47 35 42 29 42
45 3 113 -42 75 -57 115 -76 212 -34 173 -2 389 109 725 27 82 60 213 60 237
0 6 -20 35 -44 63 -48 56 -58 90 -44 156 11 48 32 11 -207 359 -126 183 -363
575 -408 675 -13 30 -36 80 -50 110 -60 129 -66 151 -52 198 7 23 12 42 10 42
-1 0 -54 -44 -116 -97z"/>
<path d="M6445 1929 c-5 -7 -25 -8 -56 -4 -29 4 -49 3 -49 -3 0 -5 -35 -12
-77 -16 -80 -6 -212 -31 -248 -46 -11 -4 -36 -14 -55 -21 -19 -7 -53 -24 -75
-39 -46 -30 -58 -37 -142 -86 -35 -19 -65 -40 -69 -45 -3 -5 13 -48 36 -97 62
-131 68 -143 90 -200 11 -29 20 -62 20 -73 0 -11 16 -47 35 -80 19 -33 38 -72
41 -87 4 -15 10 -31 14 -37 14 -18 32 -174 28 -240 -3 -54 -1 -65 12 -65 15 0
145 40 190 58 14 6 59 20 100 32 41 12 84 25 95 30 51 20 248 80 266 80 11 0
52 14 91 30 107 46 113 40 38 -35 -27 -28 -50 -54 -50 -58 0 -4 -46 -54 -102
-110 -104 -103 -238 -259 -238 -276 0 -5 -3 -11 -8 -13 -7 -3 -27 -30 -133
-177 -18 -24 -49 -76 -69 -115 -20 -39 -44 -84 -54 -99 -10 -15 -17 -29 -15
-32 13 -13 474 -67 765 -89 l70 -6 11 68 c15 90 43 205 54 216 5 6 9 17 9 26
0 23 103 221 159 305 25 38 61 99 79 135 18 35 40 74 50 85 29 35 132 252 132
279 0 9 4 16 9 16 5 0 14 21 20 46 10 40 74 126 84 113 10 -12 27 -70 32 -109
7 -46 38 -152 60 -205 7 -16 16 -41 20 -55 3 -14 12 -38 20 -55 7 -16 18 -41
23 -55 24 -55 75 -158 105 -211 35 -62 137 -221 147 -229 3 -3 20 -34 37 -70
18 -36 48 -88 67 -117 19 -29 37 -65 41 -81 3 -17 14 -37 24 -46 17 -15 29
-15 167 4 182 25 536 89 547 100 7 7 -67 111 -85 118 -4 2 -8 8 -8 14 0 11
-88 121 -101 126 -5 2 -9 8 -9 13 0 5 -19 33 -42 62 -24 29 -54 68 -68 86 -14
18 -34 45 -46 59 -32 41 -124 171 -124 176 0 3 -19 33 -43 68 -24 35 -53 82
-65 106 l-22 42 28 0 c15 -1 54 -7 87 -15 33 -8 83 -19 110 -25 28 -6 57 -15
66 -20 9 -5 33 -12 53 -15 20 -4 53 -16 73 -26 21 -11 48 -19 60 -19 13 0 23
-4 23 -9 0 -5 13 -11 29 -14 16 -4 56 -19 89 -35 l60 -28 6 60 c3 32 6 85 6
117 0 33 4 59 9 59 4 0 11 25 14 56 3 31 11 62 16 67 6 6 11 19 11 29 0 10 4
18 8 18 4 0 15 28 25 63 10 34 49 124 87 199 72 141 83 184 53 195 -10 4 -37
19 -60 35 -23 15 -45 28 -49 28 -8 0 -22 6 -169 75 -44 21 -100 40 -125 43
-25 2 -50 8 -55 13 -6 4 -51 13 -100 19 -50 7 -100 17 -113 23 -26 12 -340 -5
-407 -23 -100 -26 -153 -44 -194 -62 -38 -18 -55 -20 -146 -14 -56 3 -127 13
-157 21 -30 7 -74 12 -99 10 -99 -8 -216 -28 -246 -42 -29 -13 -37 -13 -75 0
-64 22 -148 45 -193 53 -22 3 -51 13 -64 21 -14 9 -59 18 -108 21 -46 3 -86 9
-89 13 -8 13 -192 11 -199 -2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Packmate",
"short_name": "Packmate",
"start_url": ".",
"description": "Packet monitoring tool for CTFs.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#afd9f0",
"background_color": "#ffffff",
"display": "standalone"
}

310
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,310 @@
<template>
<div id="app">
<div class="bg-lines"></div>
<Navbar/>
<div class="container-fluid app-frame">
<div class="row">
<transition name="fade" mode="out-in">
<router-view name="sidebar" ref="sidebar"/>
</transition>
<main role="main" class="col-sm-9 ml-sm-auto px-4 app-main">
<transition name="fade" mode="out-in">
<router-view name="content"/>
</transition>
</main>
</div>
</div>
<Settings/>
</div>
</template>
<!--suppress JSUnresolvedVariable -->
<script>
export const DB_OBJSTORE_COUNTERS_HISTORY = 'countersHistory';
import Navbar from './components/Navbar';
import Settings from './views/Settings';
import SockJS from 'sockjs-client';
import {openDB,} from 'idb';
export default {
data() {
return {
websocket: null,
db: null,
};
},
mounted() {
this.connectWs();
openDB('packmate', 1, {
upgrade(db, oldVersion, newVersion) {
console.info('[IDB] Creating new database! Old rev %d, new rev %d',
oldVersion, newVersion);
db.createObjectStore(DB_OBJSTORE_COUNTERS_HISTORY, {
autoIncrement: false,
keyPath: null,
});
},
})
.then(db => {
this.db = db;
})
.catch(e => {
console.error('[IDB] Failed to open DB!', e);
});
},
beforeDestroy() {
this.websocket?.close();
},
methods: {
connectWs() {
if (this.websocket !== null) return;
this.websocket = new SockJS(this.$http.defaults.baseURL + '/ws');
this.websocket.onopen = () => {
console.info('[WS] Connected');
};
this.websocket.onclose = (ev) => {
console.info('[WS] Disconnected', ev.code, ev.reason);
this.websocket = null;
if (ev.code === 1008) {
console.info('[WS] Security timeout, reconnecting...');
this.connectWs();
}
if (ev.code !== 1000) { // Normal closure
setTimeout(this.connectWs, 3000);
console.info('[WS] Reconnecting...');
}
};
this.websocket.onmessage = (ev) => {
const parsed = JSON.parse(ev.data);
switch (parsed.type) {
case 'NEW_STREAM': {
this.$refs.sidebar.addStreamFromWs(parsed.value);
break;
}
case 'SAVE_SERVICE': {
this.addServiceFromWs(parsed.value);
break;
}
case 'DELETE_SERVICE': {
this.deleteServiceFromWs(parsed.value);
break;
}
case 'SAVE_PATTERN': {
this.addPatternFromWs(parsed.value);
break;
}
case 'COUNTERS_UPDATE': {
const data = parsed.value;
this.$store.commit('setCurrentPacketsCount', data.totalPackets);
this.$store.commit('setCurrentStreamsCount', data.totalStreams);
this.$store.commit('setCurrentServicesPacketsCount', data.servicesPackets);
this.$store.commit('setCurrentServicesStreamsCount', data.servicesStreams);
console.debug('Adding new counters to DB', parsed.value);
const tx = this.db.transaction(DB_OBJSTORE_COUNTERS_HISTORY, 'readwrite');
tx.store.add({
newPacketsCount: data.totalPackets,
newStreamsCount: data.totalStreams,
servicesPackets: data.servicesPackets,
servicesStreams: data.servicesStreams,
}, Date.now()).then(() => {
console.debug('[IDB] Added entry');
}).catch(e => {
console.error('[IDB] Failed to add entry!', e);
});
break;
}
case 'ENABLE_PATTERN': {
this.togglePatternFromWs(parsed.value, true);
break;
}
case 'DISABLE_PATTERN': {
this.togglePatternFromWs(parsed.value, false);
break;
}
case 'PCAP_STARTED': {
this.$store.commit('startPcap');
this.$bvToast.toast(`Pcap file processing started`, {
title: 'Notification',
variant: 'info',
autoHideDelay: 5000,
});
break;
}
case 'PCAP_STOPPED': {
this.$bvToast.toast(`All streams processed`, {
title: 'Notification',
variant: 'success',
autoHideDelay: 5000,
});
break;
}
case 'FINISH_LOOKBACK': {
console.debug('Lookback completed');
this.$bvToast.toast(`Lookback completed`, {
title: 'Notification',
variant: 'success',
autoHideDelay: 5000,
});
this.$refs.sidebar.streams = [];
this.$refs.sidebar.$refs.infiniteLoader.stateChanger.reset();
break;
}
default: {
console.error('[WS] Event is not implemented!', parsed);
break;
}
}
};
this.websocket.onerror = (ev) => {
console.warn('[WS] Error', ev);
};
},
addPatternFromWs(pattern) {
const foundIndex = this.$store.state.patterns.findIndex(el => el.id === pattern.id);
if (foundIndex === -1) {
this.$store.commit('addPattern', pattern);
return;
}
let newPatterns = this.$store.state.patterns.slice();
newPatterns.splice(foundIndex, 1, pattern);
this.$store.commit('setPatterns', newPatterns);
},
togglePatternFromWs(id, enabled) {
this.$store.state.patterns.forEach(pattern => {
if (pattern.id === id) {
pattern.enabled = enabled;
}
});
},
addServiceFromWs(service) {
const foundIndex = this.$store.state.services.findIndex(el => el.port === service.port);
if (foundIndex === -1) {
this.$store.commit('addService', service);
return;
}
let newServices = this.$store.state.services.slice();
newServices.splice(foundIndex, 1, service);
this.$store.commit('setServices', newServices);
},
deleteServiceFromWs(port) {
this.$store.commit('setServices', this.$store.state.services.filter(o => o.port !== port));
},
},
components: {
Settings,
Navbar,
},
};
</script>
<style>
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFWJ0bbck.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFUZ0bbck.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFWZ0bbck.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFVp0bbck.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFWp0bbck.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFW50bbck.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFVZ0b.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
html * {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: .875rem;
}
body {
overflow-y: scroll;
}
/*noinspection CssUnusedSymbol*/
.fade-enter-active, .fade-leave-active {
transition: opacity .3s;
}
/*noinspection CssUnusedSymbol*/
.fade-enter, .fade-leave-to {
opacity: 0;
}
/* Patterns dropdown fix */
/*noinspection CssUnusedSymbol*/
.dropdown-menu.show {
position: fixed !important;
/*transform: translate3d(15px, 38px, 0) !important;*/
transform: none !important;
left: 15px !important;
top: 38px !important;
}
[role="main"] {
padding-top: 55px; /* Space for fixed navbar */
}
</style>

View File

@@ -0,0 +1,303 @@
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:wght@400;600&display=swap');
:root {
--bg: #040410;
--bg-2: #0a0f25;
--panel: rgba(10, 17, 40, 0.82);
--panel-strong: rgba(12, 22, 52, 0.92);
--accent: #5ef2ff;
--accent-2: #ff6ad5;
--text: #e7f5ff;
--muted: #9bb3d6;
--mono-font: 'JetBrains Mono', 'Ubuntu Mono', monospace;
--pixel-font: 'Press Start 2P', 'JetBrains Mono', monospace;
}
[data-theme="light"] {
--bg: #eaf2ff;
--bg-2: #f6f8ff;
--panel: rgba(245, 248, 255, 0.9);
--panel-strong: rgba(238, 244, 255, 0.95);
--text: #0d1b2f;
--muted: #4b5873;
}
* {
box-sizing: border-box;
}
body {
background:
radial-gradient(circle at 18% 24%, rgba(255, 106, 213, 0.16), transparent 25%),
radial-gradient(circle at 82% 12%, rgba(94, 242, 255, 0.2), transparent 25%),
linear-gradient(135deg, #02030b 0%, #0b1535 50%, #050811 100%);
color: var(--text);
font-family: var(--pixel-font);
letter-spacing: 0.6px;
min-height: 100vh;
}
body[data-theme="light"] {
background: linear-gradient(135deg, #f4f7ff 0%, #dfeaff 40%, #f8fbff 100%);
}
#app {
position: relative;
}
.bg-lines {
position: fixed;
inset: 0;
background-image: linear-gradient(rgba(94, 242, 255, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 106, 213, 0.08) 1px, transparent 1px);
background-size: 120px 120px;
pointer-events: none;
z-index: 0;
}
.container-fluid.app-frame {
padding-top: 84px;
position: relative;
z-index: 1;
}
.neon-navbar {
background: var(--panel-strong) !important;
border-bottom: 1px solid rgba(94, 242, 255, 0.35);
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.45), 0 0 25px rgba(255, 106, 213, 0.25);
padding: 12px 18px;
}
.navbar-brand {
font-family: var(--pixel-font);
letter-spacing: 1px;
color: var(--accent) !important;
display: flex;
align-items: center;
gap: 10px;
}
.navbar-brand .brand-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 10px rgba(94, 242, 255, 0.6);
}
.navbar-sub {
font-size: 10px;
color: var(--muted);
margin-left: 6px;
}
.navbar-metrics {
display: flex;
gap: 10px;
align-items: center;
margin-left: 12px;
flex-wrap: wrap;
}
.metric-chip {
background: rgba(94, 242, 255, 0.08);
border: 1px solid rgba(94, 242, 255, 0.25);
color: var(--text);
padding: 8px 10px;
border-radius: 10px;
display: grid;
grid-template-columns: auto auto;
column-gap: 8px;
align-items: center;
font-size: 11px;
box-shadow: 0 0 14px rgba(94, 242, 255, 0.18);
}
.metric-chip .label {
color: var(--muted);
}
.metric-chip .value {
color: var(--accent);
}
.navbar-nav .nav-link,
.neon-tab {
color: var(--text) !important;
border-radius: 12px;
padding: 8px 10px;
margin: 4px 4px;
transition: all .2s ease;
background: transparent;
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-item.active > .nav-link,
.neon-tab:hover {
background: rgba(255, 106, 213, 0.18);
box-shadow: 0 0 14px rgba(255, 106, 213, 0.2);
color: #fff !important;
}
.navbar-cogs > i {
color: var(--muted);
}
.navbar-cogs > i:hover {
color: var(--accent);
}
.app-main {
background: var(--panel);
border: 1px solid rgba(94, 242, 255, 0.28);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35), 0 0 35px rgba(94, 242, 255, 0.16);
border-radius: 18px;
padding: 28px;
margin-bottom: 24px;
min-height: calc(100vh - 120px);
}
.sidebar {
background: var(--panel);
border-right: 1px solid rgba(94, 242, 255, 0.25);
box-shadow: inset -10px 0 24px rgba(0, 0, 0, 0.25);
min-height: calc(100vh - 84px);
padding-top: 14px;
}
.sidebar .btn {
font-family: var(--pixel-font);
letter-spacing: 0.6px;
border-radius: 10px;
border-color: rgba(94, 242, 255, 0.35);
color: var(--text);
}
.sidebar .btn-outline-primary,
.sidebar .btn-outline-info,
.sidebar .btn-outline-success,
.sidebar .btn-outline-warning {
background: rgba(255, 255, 255, 0.02);
}
.sidebar .btn:hover {
box-shadow: 0 0 12px rgba(255, 106, 213, 0.22);
}
.sidebar-sticky {
padding: 8px 6px 14px;
max-height: calc(100vh - 180px);
overflow-y: auto;
}
.stream-item {
margin: 8px 0;
border-radius: 14px;
border: 1px solid rgba(94, 242, 255, 0.2);
background: linear-gradient(125deg, rgba(94, 242, 255, 0.06), rgba(255, 106, 213, 0.08));
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
}
.stream-item .nav-link {
color: var(--text) !important;
padding: 12px;
}
.stream-item .nav-link:hover {
background: rgba(94, 242, 255, 0.08);
}
.highlight {
border-color: rgba(255, 106, 213, 0.45);
box-shadow: 0 0 12px rgba(255, 106, 213, 0.3);
}
.legend-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
margin-bottom: 18px;
font-size: 12px;
}
.legend-pill {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(94, 242, 255, 0.35);
background: rgba(94, 242, 255, 0.08);
box-shadow: 0 0 12px rgba(94, 242, 255, 0.15);
color: var(--text);
}
.packet-outgoing, .packet-incoming {
border-radius: 16px;
padding: 14px;
margin-bottom: 14px;
border: 1px solid rgba(94, 242, 255, 0.22);
}
.packet-incoming {
background: radial-gradient(circle at 18% 20%, rgba(94, 242, 255, 0.12), rgba(10, 17, 40, 0.9));
}
.packet-outgoing {
background: radial-gradient(circle at 18% 20%, rgba(255, 106, 213, 0.12), rgba(10, 17, 40, 0.9));
}
.packet-incoming div,
.packet-outgoing div {
font-size: 11px;
color: var(--muted);
font-family: var(--pixel-font);
}
.packet-incoming button,
.packet-outgoing button {
color: var(--accent);
}
.packet-incoming p,
.packet-outgoing p {
font-family: var(--mono-font);
font-size: 13px;
background: rgba(0, 0, 0, 0.22);
padding: 12px;
border-radius: 10px;
color: var(--text);
box-shadow: inset 0 0 12px rgba(0,0,0,0.35);
}
.btn, .form-control, .custom-select, .page-link {
font-family: var(--pixel-font);
letter-spacing: 0.5px;
}
.modal-content {
background: var(--panel);
color: var(--text);
border: 1px solid rgba(94, 242, 255, 0.3);
box-shadow: 0 0 24px rgba(0, 0, 0, 0.45);
}
.dropdown-menu {
background: var(--panel);
color: var(--text);
}
.dropdown-item {
color: var(--text);
}
.dropdown-item:hover {
background: rgba(94, 242, 255, 0.15);
}
.toast {
background: var(--panel-strong);
color: var(--text);
border: 1px solid rgba(255, 106, 213, 0.22);
}
.theme-toggle {
margin-left: auto;
}

View File

@@ -0,0 +1,194 @@
<template>
<nav class="navbar navbar-dark navbar-expand fixed-top bg-dark flex-md-nowrap p-0 shadow neon-navbar">
<div class="d-flex align-items-center pl-3">
<span class="navbar-brand mb-0 ml-2">
<span class="brand-dot"></span>
0xb00b5 team Packmate
</span>
<span class="navbar-sub">@danosito</span>
</div>
<div class="navbar-metrics">
<span class="metric-chip">
<span class="label">SPM</span>
<span class="value">{{ this.$store.state.currentStreamsCount }}</span>
</span>
<span class="metric-chip">
<span class="label">PPS</span>
<span class="value">{{ packetsPerStream }}</span>
</span>
</div>
<PatternsDropdown ref="patternsDropdown"/>
<span v-if="this.$route.query.pattern" class="navbar-text navbar-nowrap">
{{ selectedPatternText }}
</span>
<div class="navbar-collapse collapse">
<ul class="navbar-nav px-1 mr-auto">
<li class="nav-item text-nowrap">
<router-link class="nav-link" :to="{name:'stream', params: {}, query: $route.query}" exact>All</router-link>
</li>
<template v-for="service in this.$store.state.services">
<router-link :key="service.port" tag="li" class="nav-item text-nowrap edit-button"
:to="{name:'stream', params: {servicePort: service.port}, query: $route.query}">
<a class="nav-link">
{{service.name}} #{{service.port}}
({{ getSpmForService(service.port) }}
<u title="Streams per minute">SPM</u>)
</a>
<a class="nav-link pl-0" style="cursor: pointer" @click.stop.prevent="editService(service)">
<i class="fas fa-pencil-alt"/>
</a>
</router-link>
</template>
<li class="nav-item text-nowrap" style="padding-left: 1em;">
<div class="my-2 mr-3 navbar-cogs" style="cursor: pointer;"
@click.stop.prevent="showAddService">
<i class="fas fa-plus-circle"/>
</div>
</li>
</ul>
<div class="my-2 my-lg-0 mr-3 navbar-cogs" style="cursor: pointer;"
@click.stop.prevent="showSettings">
<i class="fas fa-cogs"/>
</div>
</div>
<ServiceModal :creating="serviceModalIsCreating" :initial-service="serviceModalEditingService"
@service-update-needed="updateServices"/>
</nav>
</template>
<script>
import PatternsDropdown from './PatternsDropdown';
import ServiceModal from '../views/ServiceModal';
export default {
name: 'Navbar',
computed: {
packetsPerStream: function() {
let streams = this.$store.state.currentStreamsCount;
let packets = this.$store.state.currentPacketsCount;
if (streams === 0) {
return 0;
} else {
let pps = packets / streams;
return Math.round((pps + Number.EPSILON) * 10) / 10;
}
},
selectedPatternText: function () {
let selected = this.$route.query.pattern;
if (typeof selected === 'string') {
selected = parseInt(selected);
}
let pattern = this.$store.state.patterns.find(o => o.id === selected);
if (pattern) {
return `[Selected: ${pattern.name}]`;
} else {
return '[Invalid pattern]';
}
},
},
data() {
return {
serviceModalIsCreating: true,
serviceModalEditingService: {},
};
},
mounted() {
this.updateServices();
},
methods: {
getSpmForService(port) {
return this.$store.state.currentServicesStreamsCount[port] ?? 0;
},
editService(service) {
this.serviceModalIsCreating = false;
this.serviceModalEditingService = {};
this.$nextTick(() => {
this.serviceModalEditingService = service;
this.$bvModal.show('serviceModal');
});
},
showSettings() {
this.$bvModal.show('settingsModal');
console.debug('Showing settings...');
},
showAddService() {
this.serviceModalIsCreating = true;
this.serviceModalEditingService = {};
this.$bvModal.show('serviceModal');
console.debug('Showing addService...');
},
updateServices() {
this.$http.get('service/')
.then(r => this.$store.commit('setServices', r.data))
.catch(e => {
this.$bvToast.toast(`Failed to load services: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to load services:', e);
});
},
},
components: {
ServiceModal,
PatternsDropdown,
},
};
</script>
<style scoped>
.nav-link {
transition: all .3s;
color: var(--text) !important;
}
.edit-button {
display: inherit;
}
.navbar-cogs > i {
transition: all .3s;
color: var(--muted);
}
.navbar-cogs > i:hover {
color: var(--accent);
}
.navbar-nowrap {
white-space: nowrap;
}
nav {
overflow-x: auto;
}
u {
text-underline-position: under;
text-decoration-style: dotted;
}
::-webkit-scrollbar {
height: 0.2em
}
::-webkit-scrollbar-button {
width: 0;
}
::-webkit-scrollbar-track-piece {
background: #F1F1F1
}
::-webkit-scrollbar-thumb {
background: #C1C1C1
}
</style>

View File

@@ -0,0 +1,218 @@
<template>
<div :class="{'packet-incoming': packet.incoming, 'packet-outgoing': !packet.incoming}">
<div>#{{ packet.id }} at {{ dateToText(packet.timestamp) }}
<template v-if="offset !== null"> (+{{ offset }} ms)</template>
{{ printPacketFlags(packet) }}
<button @click.prevent="copyRaw" class="btn btn-link">Copy HEX</button>
<button @click.prevent="copyText" class="btn btn-link">Copy text</button>
<button @click.prevent="copyPythonBytes" class="btn btn-link">Copy as Python bytes</button>
</div>
<p v-if="!this.$store.state.hexdumpMode"
class="pt-2 pb-2 mb-3"
v-html="stringdata"/>
<p v-else
class="pt-2 pb-2 mb-3"
v-html="hexdata"/>
</div>
</template>
<!--suppress JSUnresolvedVariable, JSDeprecatedSymbols -->
<script>
export default {
name: 'Packet',
props: {
packet: {
id: Number(),
matches: Array(),
timestamp: Number(),
incoming: Boolean(),
ungzipped: Boolean(),
webSocketParsed: Boolean(),
tlsDecrypted: Boolean(),
content: String(),
},
offset: Number(),
},
computed: {
hexdata() {
const dataString = this.atou(this.packet.content);
const dump = this.hexdump(dataString, this.$store.state.hexdumpBlockSize, this.$store.state.hexdumpLineNumberBase);
return this.escapeHtml(dump)
.split('\n')
.join('<br>'); // Replace all \n to <br>
},
stringdata() {
const dataString = this.atou(this.packet.content);
const dump = this.highlightPatterns(dataString);
return this.escapeHtml(dump)
.split('\n')
.join('<br>');
},
},
methods: {
atou(b64) {
const text = atob(b64);
const length = text.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = text.charCodeAt(i);
}
const decoder = new TextDecoder();
return decoder.decode(bytes);
},
printPacketFlags(packet) {
let flags = [];
if (packet.ungzipped) {
flags.push('GZIP');
}
if (packet.webSocketParsed) {
flags.push('WS');
}
if (packet.tlsDecrypted) {
flags.push('TLS');
}
return flags.join(' ');
},
hexdump(buffer, blockSize, lineNumberBase) {
blockSize = parseInt(blockSize, 10) || 16;
lineNumberBase = parseInt(lineNumberBase, 10) || 10;
let lines = [];
const hex = '0123456789ABCDEF';
for (let b = 0; b < buffer.length; b += blockSize) {
const block = buffer.slice(b, Math.min(b + blockSize, buffer.length));
const addr = ('0000000000' + b.toString(lineNumberBase)).slice(-10);
let codes = block.split('').map(ch => {
const code = ch.charCodeAt(0);
return ' ' + hex[(0xF0 & code) >> 4] + hex[0x0F & code];
}).join('');
codes += ' ..'.repeat(blockSize - block.length);
// eslint-disable-next-line no-control-regex
let chars = block.replace(/[\x00-\x1F]/g, '.');
chars += ' '.repeat(blockSize - block.length);
lines.push(addr + ':' + codes + ' |' + chars + '|');
}
return lines.join('\n');
},
dateToText(unixTimestamp) {
return new Date(unixTimestamp).toLocaleDateString('ru-RU', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
},
escapeHtml(in_) {
return in_.replace(/(<span style="background-color: #(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})">|<\/span>)|[&<>"'/]/g, ($0, $1) => {
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
'/': '&#x2F;',
};
return $1 ? $1 : entityMap[$0];
});
},
highlightPatterns(raw) {
const patterns = this.$store.state.patterns.reduce((obj, item) => {
obj[item.id] = item;
return obj;
}, {}); // Array to object
let offset = 0;
this.packet.matches
.filter(match => !patterns[match.patternId].deleted)
.sort((a, b) => a.startPosition - b.startPosition)
.forEach(match => {
const pattern = patterns[match.patternId];
if (!pattern) {
console.info(`Pattern #${match.patternId} does not exist`);
return;
}
const firstTag = `<span style="background-color: ${pattern.color}">`;
const secondTag = '</span>';
const positionStart = match.startPosition + offset;
raw = raw.substring(0, positionStart) + firstTag + raw.substring(positionStart);
offset += firstTag.length;
const positionEnd = match.endPosition + offset + 1;
raw = raw.substring(0, positionEnd) + secondTag + raw.substring(positionEnd);
offset += secondTag.length;
});
return raw;
},
copyPythonBytes() {
const data = 'b\'' + atob(this.packet.content)
.split('')
.map((aChar) => {
return '\\x' + ('0' + aChar.charCodeAt(0).toString(16)).slice(-2);
})
.join('') + '\'';
this.copyContent(data);
},
copyRaw() {
this.copyContent(Buffer.from(this.packet.content, 'base64').toString('hex'));
},
copyText() {
this.copyContent(atob(this.packet.content));
},
copyContent(data) {
const tempEl = document.createElement('textarea');
tempEl.value = data; // Chrome
tempEl.textContent = data; // Firefox
document.body.appendChild(tempEl);
tempEl.select();
const result = document.execCommand('copy');
document.body.removeChild(tempEl);
console.debug('Copy result is', result);
},
},
};
</script>
<style scoped>
.packet-outgoing {
background: linear-gradient(135deg, rgba(255, 106, 213, 0.12), rgba(14, 18, 40, 0.85));
border: 1px solid rgba(255, 106, 213, 0.35);
box-shadow: 0 0 14px rgba(255, 106, 213, 0.2);
}
.packet-incoming {
background: linear-gradient(135deg, rgba(94, 242, 255, 0.16), rgba(14, 18, 40, 0.85));
border: 1px solid rgba(94, 242, 255, 0.35);
box-shadow: 0 0 14px rgba(94, 242, 255, 0.18);
}
div {
font-size: 11px;
color: var(--muted);
font-family: var(--pixel-font);
}
p {
font-family: var(--mono-font);
font-size: 13px;
margin-bottom: 10px;
padding: 10px 12px;
word-break: break-word;
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
color: var(--text);
}
button {
padding: 0;
top: -0.1em;
position: relative;
margin-left: 5px;
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<b-dropdown no-flip text="Patterns" block variant="dark" class="col-sm-1 mr-0 p-0">
<li role="presentation" style="padding-left: 0.5em; padding-right: 0.5em;">
<button role="menuitem" type="button" class="btn btn-sm btn-primary btn-block"
@click.stop.prevent="showAddPattern">
<i class="fas fa-plus"/>
</button>
</li>
<b-dropdown-divider/>
<b-dropdown-item-button @click.stop.prevent="resetPatternSelection">
<strong>All streams</strong>
</b-dropdown-item-button>
<b-dropdown-item-button v-for="pattern in existingPatterns"
:key="pattern.id" @click.stop.prevent="openPattern(pattern)"
:class="{ 'ignore-pattern' : pattern.actionType === 'IGNORE' }">
<strong v-if="pattern.enabled" :style="getPatternColor">{{ pattern.name }}</strong>
<s v-else :style="`color: ${pattern.color};`">{{ pattern.name }}</s>:
<code>{{ getSearchTypeValue(pattern.searchType, pattern.value) }}</code>;
<template v-if="pattern.actionType === 'FIND'">search </template>
<template v-else>ignore </template>
<template v-if="pattern.directionType === 'BOTH'">anywhere </template>
<template v-else-if="pattern.directionType === 'INPUT'">in request </template>
<template v-else>in response </template>
<template v-if="pattern.serviceId === null">of any service</template>
<template v-else>of service {{ getServiceName(pattern.serviceId) }} #{{ pattern.serviceId }}</template>
<div class="float-right" style="margin-left: 2em;">
<button type="button" class="btn btn-outline-info btn-sm mr-1"
@click.stop.prevent="showEditPattern(pattern)"
title="Edit pattern">
<i class="fas fa-edit"></i>
</button>
<button v-if="pattern.actionType === 'FIND'" type="button" class="btn btn-outline-warning btn-sm mr-1"
@click.stop.prevent="showLookBack(pattern)"
title="Apply pattern to older streams">
<i class="fas fa-backward"></i>
</button>
<button v-if="pattern.enabled" type="button" class="btn btn-outline-danger btn-sm mr-1"
@click.stop.prevent="togglePattern(pattern)"
title="Stop matching streams with this pattern">
<i class="fas fa-pause"></i>
</button>
<button v-else type="button" class="btn btn-outline-success btn-sm mr-1"
@click.stop.prevent="togglePattern(pattern)"
title="Start matching streams with this pattern again">
<i class="fas fa-play"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
@click.stop.prevent="deletePattern(pattern)"
title="Permanently delete this pattern">
<i class="fas fa-trash"></i>
</button>
</div>
</b-dropdown-item-button>
<PatternModal :creating="patternModalIsCreating" :initial-pattern="patternModalEditingPattern" />
<LookBack :pattern-id="patternIdForLookback" />
</b-dropdown>
</template>
<!--suppress JSUnresolvedReference -->
<script>
import PatternModal from '@/views/PatternModal.vue';
import LookBack from '@/views/LookBack.vue';
export default {
name: 'PatternsDropdown',
components: {LookBack, PatternModal, },
data() {
return {
patternModalIsCreating: true,
patternModalEditingPattern: {},
patternIdForLookback: 0,
};
},
mounted() {
this.updatePatterns();
},
computed: {
existingPatterns: function () {
return this.$store.state.patterns.filter(p => !p.deleted)
},
},
methods: {
getSearchTypeValue(searchType, value) {
if (searchType === 'REGEX') return `/${value}/`;
else if (searchType === 'SUBSTRING') return `'${value}'`;
else return `0x${value}`;
},
getPatternColor(pattern) {
if (pattern.actionType === 'FIND') {
return `color: ${pattern.color};`;
} else {
return `color: inherit;`;
}
},
getServiceName(port) {
return this.$store.state.services.find(o => o.port === port)?.name ?? '<Deleted service>'
},
showAddPattern() {
this.patternModalIsCreating = true;
this.patternModalEditingPattern = {};
this.$bvModal.show('patternModal');
console.debug('Showing patternModal (create)');
},
showEditPattern(pattern) {
this.patternModalIsCreating = false;
this.patternModalEditingPattern = {};
this.$nextTick(() => {
this.patternModalEditingPattern = pattern;
this.$bvModal.show('patternModal');
console.debug('Showing patternModal (edit)');
});
},
showLookBack(pattern) {
this.patternIdForLookback = pattern.id;
this.$bvModal.show('lookBackModal');
},
updatePatterns() {
this.$http.get('pattern/')
.then(r => this.$store.commit('setPatterns', r.data))
.catch(e => {
this.$bvToast.toast(`Failed to load patterns: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to load patterns:', e);
});
},
openPattern(pattern) {
if (pattern.actionType === 'IGNORE') {
return;
}
console.debug('Opening pattern w/ id', pattern.id);
this.$router.push({
name: 'stream',
params: this.$route.params,
query: {pattern: pattern.id,},
}, () => {});
},
resetPatternSelection() {
console.debug('Resetting pattern selection');
this.$router.push({
name: 'stream',
params: this.$route.params,
}, () => {});
},
togglePattern(pattern) {
const enabled = !pattern.enabled;
console.debug('Toggling pattern', pattern);
this.$http.post(`pattern/${pattern.id}/enable`, null, {
params: {
enabled,
},
})
.then(response => {
const data = response.data;
console.debug('Done toggling pattern', data);
this.$emit('patternAddComplete');
}).catch(e => {
this.$bvToast.toast(`Failed to toggle pattern: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to toggle pattern', e);
});
},
deletePattern(pattern) {
console.debug('Deleting pattern', pattern);
this.$http.delete(`pattern/${pattern.id}`, null)
.then(() => {
console.debug('Done deleting pattern');
this.$emit('patternAddComplete');
}).catch(e => {
this.$bvToast.toast(`Failed to delete pattern: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to delete pattern', e);
});
},
},
};
</script>
<style scoped>
code {
font-family: "Ubuntu Mono", "Lucida Console", monospace;
font-size: 100%;
}
</style>
<style>
.ignore-pattern button {
cursor: default !important;
}
</style>

View File

@@ -0,0 +1,272 @@
<template>
<nav class="col-sm-3 d-none d-sm-block sidebar">
<div class="m-2 d-flex flex-wrap align-items-center" style="gap: 6px;">
<button type="button" class="btn btn-sm"
:title="this.$store.state.pause ? 'Continue' : 'Pause new streams'"
:class="this.$store.state.pause ? 'btn-danger' : 'btn-outline-success'"
@click.stop.prevent="togglePause">
<i :class="this.$store.state.pause ? 'fas fa-play' : 'fas fa-pause'"/>
</button>
<button type="button" class="btn btn-sm ml-1"
:title="this.$store.state.displayFavoritesOnly ? 'Show all streams' : 'Show only favorite streams'"
:class="this.$store.state.displayFavoritesOnly ? 'btn-danger' : 'btn-outline-danger'"
@click.stop.prevent="toggleFavorites">
<i class="fas fa-star"/>
</button>
<button type="button" class="btn btn-outline-primary btn-sm ml-1"
:title="this.$store.state.hexdumpMode ? 'Switch to text view' : 'Switch to hexdump view'"
@click.stop.prevent="toggleHexdump">
<i :class="this.$store.state.hexdumpMode ? 'far fa-file-code' : 'fas fa-align-left'"/>
</button>
<button type="button" class="btn btn-sm btn-outline-warning ml-1" v-if="!this.$store.state.pcapStarted"
title="Start pcap file processing"
@click.stop.prevent="startPcap">
<i class="fas fa-arrow-circle-down"/>
</button>
<button type="button" class="btn btn-sm btn-outline-info" style="float: right;"
title="Scroll to top"
@click.stop.prevent="scrollUp">
<i class="fas fa-angle-double-up"/>
</button>
</div>
<div class="sidebar-sticky">
<ul class="nav flex-column">
<SidebarStream v-for="stream in streams"
:key="stream.id"
:stream="stream"/>
<infinite-loading @infinite="infiniteLoadingHandler" ref="infiniteLoader"/>
</ul>
</div>
</nav>
</template>
<script>
import SidebarStream from './SidebarStream';
export default {
name: 'Sidebar',
props: ['servicePort', 'streamId',],
data() {
return {
streams: [],
navigationKeysCallback: (e) => {
if (!e.ctrlKey) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
const index = this.streams.findIndex(e => e.id === this.$route.params.streamId);
const newStream = this.streams[index - 1];
if (!newStream) return;
const newId = newStream.id;
this.$router.push({
name: 'stream',
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
query: this.$route.query,
});
document.getElementById(`stream-${newId}`).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const index = this.streams.findIndex(e => e.id === this.$route.params.streamId);
const newStream = this.streams[index + 1];
if (!newStream) return;
const newId = newStream.id;
this.$router.push({
name: 'stream',
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
query: this.$route.query,
});
document.getElementById(`stream-${newId}`).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
} else if (e.key === 'Home') {
const newStream = this.streams[0];
if (!newStream) return;
const newId = newStream.id;
this.$router.push({
name: 'stream',
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
query: this.$route.query,
});
document.getElementById(`stream-${newId}`).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
} else if (e.key === 'End') {
const newStream = this.streams[this.streams.length - 1];
if (!newStream) return;
const newId = newStream.id;
this.$router.push({
name: 'stream',
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
query: this.$route.query,
});
document.getElementById(`stream-${newId}`).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
},
};
},
watch: {
'$route.params.servicePort': function () {
console.debug('Port reselected');
this.streams = [];
this.$refs.infiniteLoader.stateChanger.reset();
},
'$route.query.pattern': function () {
console.debug('Pattern selected');
this.streams = [];
this.$refs.infiniteLoader.stateChanger.reset();
},
},
methods: {
infiniteLoadingHandler($state) {
const ourStreams = this.streams;
const pageSize = this.$store.state.pageSize;
let startsFrom;
if (ourStreams?.length && ourStreams[ourStreams.length - 1]) {
startsFrom = ourStreams[ourStreams.length - 1].id;
} else {
startsFrom = null;
}
this.$http.post(`/stream/${this.$route?.params?.servicePort || 'all'}`, {
startingFrom: startsFrom,
pageSize: pageSize,
pattern: this.$route.query.pattern ? {id: this.$route.query.pattern,} : null,
favorites: this.$store.state.displayFavoritesOnly,
}).then(r => {
const data = r.data;
if (data?.length === 0) {
console.log('Finished loading streams (empty page)');
return $state.complete();
}
if (data[0]?.id === this.streams[0]?.id) {
console.log('Finished loading streams (overlap detected)');
return $state.complete();
}
this.streams.push(...data);
if (data.length < pageSize) {
// this was the last page
console.log('Finished loading streams (last page was not full)');
$state.complete();
} else {
console.log('Loaded another page of streams');
$state.loaded()
}
}).catch(e => {
this.$bvToast.toast(`Failed to load portion of streams: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to load portion of streams:', e);
return $state.error();
});
},
toggleFavorites() {
this.$store.commit('toggleDisplayFavoritesOnly');
this.streams = [];
this.$refs.infiniteLoader.stateChanger.reset();
},
togglePause() {
this.$store.commit('togglePause');
},
toggleHexdump() {
this.$store.commit('toggleHexdumpMode');
},
startPcap() {
this.$http.post('pcap/start')
.then(() => {
this.$store.commit('startPcap');
}).catch(e => {
this.$bvToast.toast(`Failed to start pcap: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to start pcap', e);
});
},
addStreamFromWs(stream) {
const currentPort = parseInt(this.$route?.params?.servicePort, 10);
if (currentPort && currentPort !== stream.service) return;
if (this.$store.state.displayFavoritesOnly || this.$store.state.pause) return;
if (this.$route.query.pattern && !stream.foundPatterns.some(e => e.id === this.$route.query.pattern)) {
return;
}
this.streams.unshift(stream);
},
scrollUp() {
const newStream = this.streams[0];
if (!newStream) return;
const newId = newStream.id;
this.$router.push({
name: 'stream',
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
query: this.$route.query,
});
document.getElementById(`stream-${newId}`).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
},
},
mounted() {
document.addEventListener('keydown', this.navigationKeysCallback);
this.$http.get('pcap/started')
.then(r => this.$store.state.pcapStarted = r.data)
.catch(e => {
console.error('Failed to get pcap status, defaulting to true:', e);
this.$store.state.pcapStarted = true;
});
},
beforeDestroy() {
document.removeEventListener('keydown', this.navigationKeysCallback);
},
components: {
SidebarStream,
},
};
</script>
<style scoped>
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100; /* Behind the navbar */
padding: 40px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 85px);
/*padding-top: .5rem;*/
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
@supports (position: sticky) {
.sidebar-sticky {
position: sticky;
}
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<li :id="`stream-${stream.id}`" class="nav-item stream-item" :class="{highlight: favorite0}">
<router-link class="nav-link"
:to="{name: 'stream', params: {servicePort: this.stream.service, streamId: this.stream.id}, query: this.$route.query}">
<a @click.stop.prevent="toggleFavorite">
<i class="fa-star" style="color: #DC3545" :class="favorite0 ? 'fas' : 'far'"/>
</a>
{{ stream.id }} {{ stream.protocol }}
<template v-if="stream.ttl">TTL {{ stream.ttl }}</template>
<template v-if="shouldShowServiceName"><br/>{{ getServiceName(stream.service) }} #{{stream.service}}</template>
<br/>
{{ dateToText(stream.startTimestamp) }}
<template v-if="!dateMatches(stream.startTimestamp, stream.endTimestamp)">
- {{ dateToText(stream.endTimestamp, dayMatches(stream.startTimestamp, stream.endTimestamp)) }}
</template>
<template v-if="stream.userAgentHash"><br/>UA: {{ stream.userAgentHash }}</template>
<br/>
{{ stream.sizeBytes }} bytes in {{ stream.packetsCount }} packets
<br/>
<span v-for="pattern in notDeletedFoundPatterns"
:key="pattern.id"
:style="`color: ${pattern.color};`">
{{ pattern.name }}
</span>
</router-link>
</li>
</template>
<script>
export default {
name: 'SidebarStream',
props: {
stream: {
id: Number(),
protocol: String(),
service: Number(),
startTimestamp: Number(),
endTimestamp: Number(),
foundPatternsIds: Array(),
favorite: Boolean(),
ttl: Number(),
userAgentHash: String(),
sizeBytes: Number(),
packetsCount: Number(),
},
},
computed: {
shouldShowServiceName: function () {
return this.$route?.params?.servicePort === undefined;
},
foundPatterns: function () {
return this.$store.state.patterns.filter(p => this.stream.foundPatternsIds.includes(p.id));
},
notDeletedFoundPatterns: function () {
return this.foundPatterns.filter(p => !p.deleted);
},
},
data: function () {
return {
favorite0: this.stream.favorite,
getServiceName: function (port) {
return this.$store.state.services.find(o => o.port === port)?.name ?? '<Deleted service>'
},
};
},
methods: {
dateToText(unixTimestamp, short = false) {
const date = new Date(unixTimestamp);
const options = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
};
if (!short && !this.isToday(date)) {
options.month = '2-digit';
options.day = '2-digit';
return date.toLocaleDateString('ru-RU', options);
}
return date.toLocaleTimeString('ru-RU', options);
},
isToday(someDate) {
const today = new Date();
return someDate.getDate() === today.getDate() &&
someDate.getMonth() === today.getMonth() &&
someDate.getFullYear() === today.getFullYear();
},
dayMatches(rFirst, rSecond) {
const first = new Date(rFirst);
const second = new Date(rSecond);
return first.getDate() === second.getDate() &&
first.getMonth() === second.getMonth() &&
first.getFullYear() === second.getFullYear();
},
dateMatches(rFirst, rSecond) {
const first = new Date(rFirst);
const second = new Date(rSecond);
return first.getDate() === second.getDate() &&
first.getMonth() === second.getMonth() &&
first.getFullYear() === second.getFullYear() &&
first.getHours() === second.getHours() &&
first.getMinutes() === second.getMinutes() &&
first.getSeconds() === second.getSeconds();
},
toggleFavorite() {
this.$http.post(`stream/${this.stream.id}/${this.favorite0 ? 'unfavorite' : 'favorite'}`)
.then(() => this.favorite0 = !this.favorite0)
.catch(e => {
this.$bvToast.toast(`Failed to fav service: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to fav service', e);
});
},
},
};
</script>
<style scoped>
.nav-link {
font-weight: 600;
color: var(--text);
font-family: var(--pixel-font);
}
/*noinspection CssUnusedSymbol*/
.nav-link.active {
color: var(--accent);
text-shadow: 0 0 10px rgba(94, 242, 255, 0.35);
}
.highlight {
background: rgba(255, 106, 213, 0.18);
box-shadow: 0 0 12px rgba(255, 106, 213, 0.28);
}
.stream-item {
transition: all .3s;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div>
<input
@change="toggleTheme"
id="checkbox"
type="checkbox"
class="switch-checkbox"
/>
<label for="checkbox" class="switch-label">
<span class="checkbox-emoji">🌙</span>
<span class="checkbox-emoji"></span>
<div
class="switch-toggle"
:class="{ 'switch-toggle-checked': chosenTheme === 'dark' }"
></div>
</label>
</div>
</template>
<script>
export default {
mounted() {
this.initTheme();
},
computed: {
chosenTheme: {
get() {
return this.$store.state.theme || this.detectTheme();
},
set(theme) {
this.$store.commit('setTheme', theme);
this.displayTheme(theme);
},
},
},
methods: {
toggleTheme() {
const activeTheme = this.chosenTheme;
if (activeTheme === "light") {
this.chosenTheme = 'dark';
} else {
this.chosenTheme = 'light';
}
console.debug('Toggling theme from ', activeTheme, ' to ', this.chosenTheme);
},
initTheme() {
this.displayTheme(this.chosenTheme);
},
detectTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
displayTheme(theme) {
document.body.setAttribute('data-theme', theme);
},
},
};
</script>
<style scoped>
.switch-checkbox {
display: none;
}
.switch-label {
align-items: center;
background: var(--button-text-primary-color);
border: calc(var(--button-element-size) * 0.025) solid var(--button-accent-color);
border-radius: var(--button-element-size);
cursor: pointer;
display: flex;
font-size: calc(var(--button-element-size) * 0.3);
height: calc(var(--button-element-size) * 0.35);
position: relative;
padding: calc(var(--button-element-size) * 0.1);
transition: background 0.5s ease;
justify-content: space-between;
width: var(--button-element-size);
z-index: 1;
box-sizing: content-box;
}
.switch-toggle {
position: absolute;
background-color: var(--button-background-color-primary);
border-radius: 50%;
top: calc(var(--button-element-size) * 0.07);
left: calc(var(--button-element-size) * 0.07);
height: calc(var(--button-element-size) * 0.4);
width: calc(var(--button-element-size) * 0.4);
transform: translateX(0);
transition: transform 0.3s ease, background-color 0.5s ease;
}
.switch-toggle-checked {
transform: translateX(calc(var(--button-element-size) * 0.6)) !important;
}
.checkbox-emoji {
color: transparent;
text-shadow: 0 0 0 var(--button-background-color-primary);
}
>>> {
--button-element-size: 3rem;
--button-background-color-primary: var(--panel);
--button-background-color-secondary: var(--bg-2);
--button-accent-color: var(--accent-2);
--button-text-primary-color: var(--text);
}
[data-theme=light] * {
--button-background-color-primary: #f0f4ff;
--button-background-color-secondary: #e4ebff;
--button-accent-color: var(--accent);
--button-text-primary-color: #0d1b2f;
}
</style>

48
frontend/src/main.js Normal file
View File

@@ -0,0 +1,48 @@
import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue';
import Axios from 'axios';
import InfiniteLoading from 'vue-infinite-loading';
import App from './App.vue';
import router from './router';
import store from './store';
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap-vue/dist/bootstrap-vue.css';
import '@fortawesome/fontawesome-free/css/all.css';
import "bootstrap-darkmode/scss/darktheme.scss";
import './assets/neon.css';
Vue.config.productionTip = false;
Vue.use(BootstrapVue);
Vue.use(InfiniteLoading, {
slots: {
noResults: 'No results...',
noMore: 'No more data',
error: 'An error has occurred!',
errorBtnText: 'Retry',
},
props: {
spinner: 'waveDots',
},
});
const axiosInstance = Axios.create({
baseURL: '/api', // TO!DO: edit for release!
// baseURL: 'http://192.168.79.131:65000/api',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
timeout: 5000,
withCredentials: true,
maxContentLength: 20000,
});
// noinspection JSUnusedGlobalSymbols
Vue.prototype.$http = axiosInstance;
// noinspection JSUnusedGlobalSymbols
new Vue({
router,
store,
render: h => h(App),
}).$mount('#app');

View File

@@ -0,0 +1,118 @@
// https://github.com/saikojosh/Object-Assign-Deep/blob/master/objectAssignDeep.js
/*
* A unified way of returning a string that describes the type of the given variable.
*/
function getTypeOf(input) {
if (input === null) {
return 'null';
} else if (typeof input === 'undefined') {
return 'undefined';
} else if (typeof input === 'object') {
return (Array.isArray(input) ? 'array' : 'object');
}
return typeof input;
}
/*
* Branching logic which calls the correct function to clone the given value base on its type.
*/
function cloneValue(value) {
// The value is an object so let's clone it.
if (getTypeOf(value) === 'object') {
return quickCloneObject(value);
}
// The value is an array so let's clone it.
else if (getTypeOf(value) === 'array') {
return quickCloneArray(value);
}
// Any other value can just be copied.
return value;
}
/*
* Enumerates the given array and returns a new array, with each of its values cloned (i.e. references broken).
*/
function quickCloneArray(input) {
return input.map(cloneValue);
}
/*
* Enumerates the properties of the given object (ignoring the prototype chain) and returns a new object, with each of
* its values cloned (i.e. references broken).
*/
function quickCloneObject(input) {
const output = {};
for (const key in input) {
if (!Object.prototype.hasOwnProperty.call(input, key)) {
continue;
}
output[key] = cloneValue(input[key]);
}
return output;
}
/*
* Does the actual deep merging.
*/
function executeDeepMerge(target, _objects = [], _options = {}) {
const options = {
arrayBehaviour: _options.arrayBehaviour || 'replace', // Can be "merge" or "replace".
};
// Ensure we have actual objects for each.
const objects = _objects.map(object => object || {});
const output = target || {};
// Enumerate the objects and their keys.
for (let oindex = 0; oindex < objects.length; oindex++) {
const object = objects[oindex];
const keys = Object.keys(object);
for (let kindex = 0; kindex < keys.length; kindex++) {
const key = keys[kindex];
const value = object[key];
const type = getTypeOf(value);
const existingValueType = getTypeOf(output[key]);
if (type === 'object') {
if (existingValueType !== 'undefined') {
const existingValue = (existingValueType === 'object' ? output[key] : {});
output[key] = executeDeepMerge({}, [existingValue, quickCloneObject(value),], options);
} else {
output[key] = quickCloneObject(value);
}
} else if (type === 'array') {
if (existingValueType === 'array') {
const newValue = quickCloneArray(value);
output[key] = (options.arrayBehaviour === 'merge' ? output[key].concat(newValue) : newValue);
} else {
output[key] = quickCloneArray(value);
}
} else {
output[key] = value;
}
}
}
return output;
}
export function objectAssignDeep(target, ...objects) {
return executeDeepMerge(target, objects);
}

21
frontend/src/router.js Normal file
View File

@@ -0,0 +1,21 @@
import Vue from 'vue';
import Router from 'vue-router';
import ContentStream from './views/ContentStream';
import Sidebar from './components/Sidebar';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/:servicePort?/:streamId?',
name: 'stream',
props: true,
components: {
sidebar: Sidebar,
content: ContentStream,
},
},
],
linkActiveClass: 'active',
});

89
frontend/src/store.js Normal file
View File

@@ -0,0 +1,89 @@
import Vue from 'vue';
import Vuex from 'vuex';
import createMutationsSharer from 'vuex-shared-mutations';
import createPersistedState from 'vuex-persistedstate';
Vue.use(Vuex);
// noinspection JSUnusedLocalSymbols,JSUnusedGlobalSymbols
export default new Vuex.Store({
state: {
theme: null,
hexdumpBlockSize: 16,
hexdumpLineNumberBase: 10,
pageSize: 25,
displayFavoritesOnly: false,
pause: false,
pcapStarted: true,
hexdumpMode: false,
serviceModalName: '',
serviceModalId: 0,
patterns: [],
services: [],
currentPacketsCount: 0,
currentStreamsCount: 0,
currentServicesPacketsCount: {},
currentServicesStreamsCount: {},
},
mutations: {
setTheme: (s, p) => s.theme = p,
setHexdumpBlockSize: (s, p) => s.hexdumpBlockSize = p,
setHexdumpLineNumberBase: (s, p) => s.hexdumpLineNumberBase = p,
setPageSize: (s, p) => s.pageSize = p,
// eslint-disable-next-line no-unused-vars
toggleDisplayFavoritesOnly: (s, p) => s.displayFavoritesOnly = !s.displayFavoritesOnly,
// eslint-disable-next-line no-unused-vars
togglePause: (s, p) => s.pause = !s.pause,
// eslint-disable-next-line no-unused-vars
startPcap: (s, p) => s.pcapStarted = true,
// eslint-disable-next-line no-unused-vars
toggleHexdumpMode: (s, p) => s.hexdumpMode = !s.hexdumpMode,
setServiceModalName: (s, p) => s.serviceModalName = p,
setServiceModalId: (s, p) => s.serviceModalId = p,
setPatterns: (s, p) => s.patterns = p,
addPattern: (s, p) => s.patterns.push(p),
setServices: (s, p) => s.services = p,
addService: (s, p) => s.services.push(p),
setCurrentPacketsCount: (s, p) => s.currentPacketsCount = p,
setCurrentStreamsCount: (s, p) => s.currentStreamsCount = p,
setCurrentServicesPacketsCount: (s, p) => s.currentServicesPacketsCount = p,
setCurrentServicesStreamsCount: (s, p) => s.currentServicesStreamsCount = p,
},
actions: {},
plugins: [
createPersistedState(),
createMutationsSharer({
// eslint-disable-next-line no-unused-vars
predicate: (mutation, state) => {
console.debug('Got mutation:', mutation);
const mName = mutation?.type;
const process = mName !== 'toggleDisplayFavoritesOnly'
&& mName !== 'setTheme'
&& mName !== 'setServiceModalName'
&& mName !== 'setServiceModalId'
&& mName !== 'togglePause'
&& mName !== 'startPcap'
&& mName !== 'toggleHexdumpMode'
&& mName !== 'setPatterns'
&& mName !== 'addPattern'
&& mName !== 'setServices'
&& mName !== 'addService'
&& mName !== 'setCurrentPacketsCount'
&& mName !== 'setCurrentStreamsCount'
&& mName !== 'setCurrentServicesPacketsCount'
&& mName !== 'setCurrentServicesStreamsCount';
console.debug('Processing?', process);
return process;
},
}),
],
});

View File

@@ -0,0 +1,129 @@
<template>
<div>
<div class="legend-bar">
<div class="d-flex align-items-center" style="gap: 10px;">
<span class="legend-pill request-pill">Request</span>
<span class="legend-pill response-pill">Response</span>
</div>
<ThemeButton class="d-inline-flex theme-toggle"/>
</div>
<Packet v-for="packetWithOffset in packetsWithOffsets"
:key="packetWithOffset.packet.id"
:packet="packetWithOffset.packet"
:offset="packetWithOffset.offset" />
<infinite-loading @infinite="infiniteLoadingHandler" ref="infiniteLoader">
<span slot="no-results"></span>
</infinite-loading>
</div>
</template>
<script>
import Packet from '../components/Packet';
import ThemeButton from '@/components/ThemeButton.vue';
export default {
name: 'ContentStream',
props: ['servicePort', 'streamId',],
data() {
return {
packets: [],
};
},
computed: {
packetsWithOffsets: function() {
return this.packets.map((el, i) => {
let offset = null;
if (i !== 0) {
offset = el.timestamp - this.packets[i - 1].timestamp;
}
return {packet: el, offset: offset,}
})
},
},
watch: {
'$route.params.streamId': function () {
this.packets = [];
this.$refs.infiniteLoader.stateChanger.reset();
},
},
methods: {
infiniteLoadingHandler($state) {
if (!this.$route.params.streamId) return $state.complete();
const packets = this.packets;
const pageSize = this.$store.state.pageSize;
let startsFrom;
if (packets && packets.length && packets[packets.length - 1]) {
startsFrom = packets[packets.length - 1].id;
} else {
startsFrom = null;
}
this.$http.post(`packet/${this.$route.params.streamId}`, {
startingFrom: startsFrom,
pageSize: pageSize,
}).then(response => {
const data = response.data;
if (data.length === 0) {
console.log('Finished loading packets (empty page)');
return $state.complete();
}
if (data[0] && this.packets[0] && data[0].id === this.packets[0].id) {
console.log('Finished loading packets (overlap detected)');
return $state.complete();
}
this.packets.push(...data);
if (data.length < pageSize) {
// this was the last page
console.log('Finished loading packets (last page was not full)');
$state.complete();
} else {
console.log('Loaded another page of packets');
$state.loaded();
}
}).catch(e => {
this.$bvToast.toast(`Failed to load portion of packets: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to load portion of packets:', e);
return $state.error();
});
},
},
components: {
ThemeButton,
Packet,
},
};
</script>
<style>
:root {
--response-packet-color: rgba(255, 106, 213, 0.16);
--request-packet-color: rgba(94, 242, 255, 0.2);
}
:root [data-theme=dark] {
color-scheme: dark;
--response-packet-color: rgba(255, 106, 213, 0.16);
--request-packet-color: rgba(94, 242, 255, 0.2);
}
.request-pill {
border-color: rgba(94, 242, 255, 0.45);
color: #c9f8ff;
background: rgba(94, 242, 255, 0.1);
}
.response-pill {
border-color: rgba(255, 106, 213, 0.45);
color: #ffd6f6;
background: rgba(255, 106, 213, 0.1);
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<b-modal @ok="lookBack" id="lookBackModal"
title="Search into the past"
cancel-title="Cancel"
centered scrollable>
<form ref="lookBackForm">
<b-form-group
label-cols-sm="4"
label-cols-lg="3"
label="Minutes"
description="How far into the past do we want to look"
label-for="lookback-minutes">
<b-form-input @keydown.native.enter="lookBack" id="lookback-minutes" required v-model="minutes"/>
</b-form-group>
</form>
</b-modal>
</template>
<script>
export default {
name: 'LookBack',
props: {
patternId: Number,
},
data() {
return {
minutes: 5,
};
},
methods: {
checkValidity() {
return this.$refs.lookBackForm.reportValidity();
},
lookBack(ev) {
ev.preventDefault();
if (!this.checkValidity()) {
console.debug('Form is invalid');
return;
}
console.debug(`Looking back with pattern ${this.patternId}`);
this.$http.post(`pattern/${this.patternId}/lookback`, this.minutes)
.then(() => {
console.debug('Lookback started');
this.$bvModal.hide('lookBackModal');
}).catch(e => {
this.$bvToast.toast(`Failed to start lookback: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to start lookback', e);
});
},
},
};
</script>

View File

@@ -0,0 +1,205 @@
<template>
<b-modal @ok="addPattern" @cancel="reset" id="patternModal"
:title="creating ? 'Creating pattern' : 'Editing pattern'"
cancel-title="Cancel"
centered scrollable>
<form ref="addPatternForm">
<b-form-group
label-cols-sm="4"
label-cols-lg="3"
label="Name"
description="It is displayed in the list and highlighted in the packet contents"
label-for="pattern-name">
<b-form-input @keydown.native.enter="addPattern" id="pattern-name" required v-model="newPattern.name"/>
</b-form-group>
<b-form-group
v-if="creating"
label-cols-sm="4"
label-cols-lg="3"
label="Pattern"
description="Substring, RegEx or bytes"
label-for="pattern-value">
<b-form-input @keydown.native.enter="addPattern" @keydown="validateKey"
id="pattern-value" required v-model="newPattern.value"
:placeholder="getPlaceholder()"/>
</b-form-group>
<b-form-group
v-if="creating"
label-cols-sm="4"
label-cols-lg="3"
label="Search action"
description="What to do with matching streams"
label-for="pattern-actionType">
<b-form-select id="pattern-actionType" required v-model="newPattern.actionType">
<option value="FIND" selected>Highlight found pattern</option>
<option value="IGNORE">Ignore matching streams</option>
</b-form-select>
</b-form-group>
<b-form-group v-if="newPattern.actionType === 'FIND'"
label-cols-sm="4"
label-cols-lg="3"
label="Color"
description="The highlight color of the pattern in the packets and streams"
label-for="pattern-color">
<b-form-input id="pattern-color" required type="color" v-model="newPattern.color"/>
</b-form-group>
<b-form-group
v-if="creating"
label-cols-sm="4"
label-cols-lg="3"
label="Search method"
description="The way to search for patterns in packets"
label-for="pattern-searchType">
<b-form-select id="pattern-searchType" required v-model="newPattern.searchType">
<option value="REGEX" selected>Regular expression</option>
<option value="SUBSTRING">Substring</option>
<option value="SUBBYTES">Bytes</option>
</b-form-select>
</b-form-group>
<b-form-group
v-if="creating"
label-cols-sm="4"
label-cols-lg="3"
label="Search type"
description="In which packets to search for a pattern"
label-for="pattern-type">
<b-form-select id="pattern-type" required v-model="newPattern.directionType">
<option value="INPUT">Request</option>
<option value="OUTPUT">Response</option>
<option value="BOTH" selected>Anywhere</option>
</b-form-select>
</b-form-group>
<b-form-group
v-if="creating"
label-cols-sm="4"
label-cols-lg="3"
label="Service"
description="Apply this pattern only to the specific service"
label-for="pattern-service">
<b-form-select id="pattern-service" :options="serviceOptions"
v-model="newPattern.serviceId"></b-form-select>
</b-form-group>
</form>
</b-modal>
</template>
<script>
const hexRegex = /[0-9A-Fa-f ]/;
const defaultPattern = {
name: '',
value: '',
color: '#FF7474',
searchType: 'SUBSTRING',
directionType: 'BOTH',
actionType: 'FIND',
serviceId: null,
};
export default {
name: 'PatternModal',
props: {
creating: Boolean,
initialPattern: {
name: String,
value: String,
color: String,
searchType: String,
directionType: String,
actionType: String,
serviceId: Number,
},
},
data() {
return {
newPattern: {},
};
},
watch: {
initialPattern() {
console.debug('initialService changed, reassigning...', this.initialPattern);
this.newPattern = {...defaultPattern, ...this.initialPattern,};
},
},
computed: {
serviceOptions: function () {
let services = this.$store.state.services;
let options = services.map(service => {
return {
value: service.port,
text: `${service.name} #${service.port}`,
}
});
return [
{ value: null, text: 'Any service', },
...options,
];
},
},
methods: {
getPlaceholder() {
if (this.newPattern.searchType === 'REGEX') return '[A-Z0-9]{31}=';
else if (this.newPattern.searchType === 'SUBSTRING') return 'HTTP/2';
else return 'DEAD BEEF 1337';
},
validateKey(e) {
console.log('', e);
if (this.newPattern.searchType !== 'SUBBYTES') return;
if (!hexRegex.test(e?.key)) {
e.preventDefault();
}
},
checkValidity() {
return this.$refs.addPatternForm.reportValidity();
},
reset() {
this.newPattern = {...defaultPattern,};
},
addPattern(ev) {
ev.preventDefault();
if (!this.checkValidity()) {
console.debug('Form is invalid');
return;
}
console.debug('Adding/editing pattern...', this.newPattern);
if (this.newPattern.searchType === 'SUBBYTES') {
this.newPattern.value = this.newPattern.value.replace(/\s+/g, '').toLowerCase();
}
let url;
if (this.creating) {
url = 'pattern/'
} else {
url = 'pattern/' + this.newPattern.id
}
this.$http.post(url, this.newPattern)
.then(response => {
const data = response.data;
console.debug('Done adding/editing pattern', data);
this.$emit('patternAddComplete');
this.reset();
this.$bvModal.hide('patternModal');
}).catch(e => {
this.$bvToast.toast(`Failed to add pattern: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to add pattern', e);
});
},
},
};
</script>

View File

@@ -0,0 +1,136 @@
<template>
<b-modal id="serviceModal" size="lg"
:title="creating ? 'Creating service' : 'Editing service'"
centered scrollable @ok.prevent="submit">
<b-form ref="serviceForm">
<b-form-group label-cols-sm="4"
label="Name"
label-for="service-name">
<b-form-input id="service-name" required v-model="service.name"
@keyup.enter.stop.prevent="submit"/>
</b-form-group>
<b-form-group v-if="creating"
label-cols-sm="4"
label="Port"
label-for="service-port">
<b-form-input id="service-port" required
type="number" min="1" max="65535" v-model.number="service.port"
@keyup.enter.stop.prevent="submit"/>
</b-form-group>
<b-form-group label-cols-sm="4"
label="Is an HTTP service"
label-for="service-is-http">
<b-form-checkbox id="service-is-http" required
v-model="service.http" />
</b-form-group>
<b-form-group label-cols-sm="4"
label="Apply urldecode"
label-for="service-urldecode">
<b-form-checkbox id="service-urldecode" required
v-model="service.urldecodeHttpRequests"/>
</b-form-group>
<b-form-group label-cols-sm="4"
label="Merge adjacent packets"
label-for="service-mergeAdjacent">
<b-form-checkbox id="service-mergeAdjacent" required
v-model="service.mergeAdjacentPackets"/>
</b-form-group>
<b-form-group label-cols-sm="4"
label="Inflate WebSockets"
label-for="service-inflateWS">
<b-form-checkbox id="service-inflateWS" required
v-model="service.parseWebSockets"/>
</b-form-group>
<b-form-group label-cols-sm="4"
label="Decrypt TLS (TLS_RSA_WITH_AES only)"
label-for="service-decryptTls">
<b-form-checkbox id="service-decryptTls" required
v-model="service.decryptTls"/>
</b-form-group>
<b-button v-if="!creating" variant="danger" @click="deleteService">Delete</b-button>
</b-form>
</b-modal>
</template>
<script>
import {objectAssignDeep,} from '@/objectAssignDeep';
export default {
name: 'ServiceModal',
props: {
creating: Boolean,
initialService: {
name: String,
port: Number,
http: Boolean,
urldecodeHttpRequests: Boolean,
mergeAdjacentPackets: Boolean,
parseWebSockets: Boolean,
decryptTls: Boolean,
},
},
data() {
return {
service: {},
};
},
watch: {
initialService() {
console.debug('initialService changed, reassigning...', this.initialService);
this.service = objectAssignDeep({}, this.initialService);
},
},
methods: {
submit() {
if (!this.$refs.serviceForm.reportValidity()) {
return;
}
console.debug('Submitting service...', this.service, this.creating);
let url;
if (this.creating) {
url = 'service/';
} else {
url = 'service/' + this.initialService.port
}
this.$http.post(url, this.service)
.then(response => {
console.info('Done editing/creating service', response.data);
this.$emit('service-update-needed');
this.$bvModal.hide('serviceModal');
})
.catch(e => {
this.$bvToast.toast(`Failed to ${this.creating ? 'create' : 'edit'} service: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to edit/create service', e);
});
},
deleteService() {
this.$http.delete(`service/${this.service.port}`)
.then(() => {
console.info('Done deleting service', this.service);
this.$emit('service-update-needed');
this.$bvModal.hide('serviceModal');
}).catch(e => {
this.$bvToast.toast(`Failed to delete service: ${e}`, {
title: 'Error',
variant: 'danger',
});
console.error('Failed to delete service', e);
});
},
},
};
</script>

View File

@@ -0,0 +1,63 @@
<template>
<b-modal id="settingsModal" title="Settings" centered scrollable ok-only>
<b-form-group
label-cols-sm="4"
label-cols-lg="3"
description="The number of bytes to display in the binary representation of the packet"
label="HEX block width"
label-for="settings-hexdumpBlockSize">
<b-form-input type="number" id="settings-hexdumpBlockSize" v-model.number="hexdumpBlockSize"/>
</b-form-group>
<b-form-group
label-cols-sm="4"
label-cols-lg="3"
description="The number system used in line numbers of a binary representation of a packet"
label="Line numbering"
label-for="settings-hexdumpLineNumberBase">
<b-form-select id="settings-hexdumpLineNumberBase" v-model="hexdumpLineNumberBase">
<option :value="10" selected>Decimal</option>
<option :value="16">Hexadecimal</option>
</b-form-select>
</b-form-group>
<b-form-group
label-cols-sm="4"
label-cols-lg="3"
description="The number of streams to download at a time"
label="Page size"
label-for="settings-pageSize">
<b-form-input type="number" id="settings-pageSize" v-model.number="pageSize"/>
</b-form-group>
</b-modal>
</template>
<script>
export default {
name: 'Settings',
computed: {
hexdumpBlockSize: {
get() {
return this.$store.state.hexdumpBlockSize;
},
set(v) {
this.$store.commit('setHexdumpBlockSize', v);
},
},
hexdumpLineNumberBase: {
get() {
return this.$store.state.hexdumpLineNumberBase;
},
set(v) {
this.$store.commit('setHexdumpLineNumberBase', v);
},
},
pageSize: {
get() {
return this.$store.state.pageSize;
},
set(v) {
this.$store.commit('setPageSize', v);
},
},
},
};
</script>

3
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
lintOnSave: false,
};