Compare commits
	
		
			13 Commits
		
	
	
		
			4a3c022958
			...
			39a682fd41
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 39a682fd41 | |||
| a0e661eca3 | |||
| db294c171d | |||
| 2c75603046 | |||
| a7b1ba584f | |||
| 89237c2b84 | |||
| 5ade1c0593 | |||
| ec6c0cc45b | |||
| 9f9e0524f5 | |||
| 13f126ccbc | |||
| 80ac8148e7 | |||
| d7f0ba7486 | |||
| 5682777bfe | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,3 +1,5 @@ | ||||
| **/node_modules | ||||
| **/dist | ||||
| honeypot/Geoacumen-Country.mmdb | ||||
| honeypot/Geoacumen-Country.mmdb | ||||
| result | ||||
| frontend/.parcel-cache | ||||
|   | ||||
| @@ -153,7 +153,7 @@ func ActionServe(c *cli.Context) error { | ||||
| 
 | ||||
| 	// Start ssh server | ||||
| 	go func() { | ||||
| 		loggers.rootLogger.Info("Starting SSH server.") | ||||
| 		loggers.rootLogger.Infow("Starting SSH server.", "addr", cfg.Honeypot.ListenAddr) | ||||
| 		if err := hs.ListenAndServe(); err != nil && err != sshlib.ErrServerClosed { | ||||
| 			loggers.rootLogger.Warnw("SSH server returned error.", "error", err) | ||||
| 		} | ||||
| @@ -162,7 +162,7 @@ func ActionServe(c *cli.Context) error { | ||||
| 
 | ||||
| 	// Start web server | ||||
| 	go func() { | ||||
| 		loggers.rootLogger.Info("Starting web server.") | ||||
| 		loggers.rootLogger.Infow("Starting web server.", "addr", cfg.Frontend.ListenAddr) | ||||
| 		if err := web.StartServe(); err != nil && err != http.ErrServerClosed { | ||||
| 			loggers.rootLogger.Warnw("Web server returned error.", "error", err) | ||||
| 		} | ||||
							
								
								
									
										27
									
								
								flake.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								flake.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| { | ||||
|   "nodes": { | ||||
|     "nixpkgs": { | ||||
|       "locked": { | ||||
|         "lastModified": 1741173522, | ||||
|         "narHash": "sha256-k7VSqvv0r1r53nUI/IfPHCppkUAddeXn843YlAC5DR0=", | ||||
|         "owner": "NixOS", | ||||
|         "repo": "nixpkgs", | ||||
|         "rev": "d69ab0d71b22fa1ce3dbeff666e6deb4917db049", | ||||
|         "type": "github" | ||||
|       }, | ||||
|       "original": { | ||||
|         "owner": "NixOS", | ||||
|         "ref": "nixos-unstable", | ||||
|         "repo": "nixpkgs", | ||||
|         "type": "github" | ||||
|       } | ||||
|     }, | ||||
|     "root": { | ||||
|       "inputs": { | ||||
|         "nixpkgs": "nixpkgs" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "root": "root", | ||||
|   "version": 7 | ||||
| } | ||||
							
								
								
									
										123
									
								
								flake.nix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								flake.nix
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| { | ||||
|   description = "SSH honeypot with frontend"; | ||||
|  | ||||
|   inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; | ||||
|  | ||||
|   outputs = | ||||
|     { self, nixpkgs }: | ||||
|     let | ||||
|       allSystems = [ | ||||
|         "x86_64-linux" | ||||
|         "aarch64-linux" | ||||
|         "x86_64-darwin" | ||||
|         "aarch64-darwin" | ||||
|       ]; | ||||
|       forAllSystems = | ||||
|         f: | ||||
|         nixpkgs.lib.genAttrs allSystems ( | ||||
|           system: | ||||
|           f { | ||||
|             pkgs = import nixpkgs { inherit system; }; | ||||
|           } | ||||
|         ); | ||||
|     in | ||||
|     { | ||||
|       overlays.default = final: prev: { | ||||
|         apiary = self.packages.${prev.system}.default; | ||||
|       }; | ||||
|  | ||||
|       packages = forAllSystems ( | ||||
|         { pkgs }: | ||||
|         { | ||||
|           default = self.packages.${pkgs.system}.apiary; | ||||
|           apiary = | ||||
|             let | ||||
|               src = pkgs.lib.sourceFilesBySuffices ./. [ | ||||
|                 "go.mod" | ||||
|                 "go.sum" | ||||
|                 ".go" | ||||
|               ]; | ||||
|               version = pkgs.lib.strings.removePrefix "v" ( | ||||
|                 builtins.elemAt (pkgs.lib.strings.split "\"" ( | ||||
|                   pkgs.lib.lists.findFirst (x: pkgs.lib.strings.hasInfix "Version" x) null ( | ||||
|                     pkgs.lib.strings.splitString "\n" (builtins.readFile ./version.go) | ||||
|                   ) | ||||
|                 )) 2 | ||||
|               ); | ||||
|               rev = self.rev or ""; | ||||
|               geoDb = pkgs.fetchFromGitHub { | ||||
|                 owner = "geoacumen"; | ||||
|                 repo = "geoacumen-country"; | ||||
|                 rev = "5f770af620465f40427c3f421446a7b7845e2699"; | ||||
|                 sha256 = "sha256-t3ELkhAbJC40z6r6wJeQI4Kutfw26fRg6n7a6FmhvkA="; | ||||
|               }; | ||||
|  | ||||
|               frontend = pkgs.buildNpmPackage { | ||||
|                 inherit version; | ||||
|                 name = "apiary-frontend"; | ||||
|  | ||||
|                 src = ./frontend; | ||||
|  | ||||
|                 npmDepsHash = "sha256-7gjPEiQAMOpteLwHT4AIY9QLpDozpXb7xy0bDiOpDf4="; | ||||
|  | ||||
|                 installPhase = '' | ||||
|                   mkdir -p $out | ||||
|                   cp -r dist/* $out | ||||
|                 ''; | ||||
|               }; | ||||
|             in | ||||
|             pkgs.buildGoModule { | ||||
|               inherit version; | ||||
|               pname = "apiary"; | ||||
|               src = src; | ||||
|               prePatch = '' | ||||
|                 cp ${geoDb}/Geoacumen-Country.mmdb honeypot/ssh | ||||
|                 mkdir -p web/frontend/dist | ||||
|                 cp -r ${frontend}/* web/frontend/dist | ||||
|               ''; | ||||
|               vendorHash = "sha256-griWN9fQ0X2ClPDOzVXV80MpdcEFZfe/WaYm7L7fAc8="; | ||||
|               ldflags = [ "-X git.t-juice.club/torjus/apiary.Build=${rev}" ]; | ||||
|               tags = [ | ||||
|                 "embed" | ||||
|               ]; | ||||
|             }; | ||||
|  | ||||
|           tarball = | ||||
|             let | ||||
|               version = self.packages.${pkgs.system}.apiary.version; | ||||
|             in | ||||
|             pkgs.stdenv.mkDerivation { | ||||
|               name = "apiary-tarballs-${version}"; | ||||
|               phases = [ "installPhase" ]; | ||||
|               installPhase = '' | ||||
|                 mkdir -p $out | ||||
|                 mkdir apiary | ||||
|                 cp ${self.packages.${pkgs.system}.apiary}/bin/apiary apiary/apiary-${pkgs.system}-${version} | ||||
|                 tar cvzf $out/apiary-${pkgs.system}-${version}.tar.gz apiary | ||||
|                 pushd apiary | ||||
|                 sha256sum apiary-${pkgs.system}-${version} > apiary-${pkgs.system}-${version}.sha256sum | ||||
|                 popd | ||||
|                 cp apiary/apiary-${pkgs.system}-${version}.sha256sum $out | ||||
|               ''; | ||||
|             }; | ||||
|         } | ||||
|       ); | ||||
|       devShells = forAllSystems ( | ||||
|         { pkgs }: | ||||
|         { | ||||
|           default = pkgs.mkShell { | ||||
|             packages = with pkgs; [ | ||||
|               go | ||||
|               golangci-lint | ||||
|             ]; | ||||
|           }; | ||||
|           frontend = pkgs.mkShell { | ||||
|             packages = with pkgs; [ | ||||
|               nodejs | ||||
|               yarn | ||||
|             ]; | ||||
|           }; | ||||
|         } | ||||
|       ); | ||||
|     }; | ||||
| } | ||||
							
								
								
									
										7
									
								
								frontend/jest.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/jest.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /** @type {import('ts-jest').JestConfigWithTsJest} **/ | ||||
| module.exports = { | ||||
|   testEnvironment: "jsdom", | ||||
|   transform: { | ||||
|     "^.+\.tsx?$": ["ts-jest",{}], | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										9405
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										9405
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										37
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| { | ||||
|   "name": "apiary-frontend", | ||||
|   "scripts": { | ||||
|     "start": "parcel src/index.html", | ||||
|     "build": "parcel build src/index.html", | ||||
|     "test": "jest" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@ngneat/falso": "^7.3.0", | ||||
|     "@parcel/transformer-css": "^2.13.3", | ||||
|     "@testing-library/dom": "^10.4.0", | ||||
|     "@testing-library/jest-dom": "^6.6.3", | ||||
|     "@testing-library/react": "^16.2.0", | ||||
|     "@testing-library/user-event": "^14.6.1", | ||||
|     "@types/humanize-duration": "^3.27.4", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/node": "^22.13.9", | ||||
|     "@types/react": "^19.0.10", | ||||
|     "@types/react-dom": "^19.0.4", | ||||
|     "jest": "^29.7.0", | ||||
|     "jest-environment-jsdom": "^29.7.0", | ||||
|     "parcel": "^2.13.3", | ||||
|     "process": "^0.11.10", | ||||
|     "svgo": "^3.3.2", | ||||
|     "ts-jest": "^29.2.6", | ||||
|     "ts-loader": "^9.5.2", | ||||
|     "typescript": "^5.8.2" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "chart.js": "^4.4.8", | ||||
|     "humanize-duration": "^3.32.1", | ||||
|     "react": "^19.0.0", | ||||
|     "react-chartjs-2": "^5.3.0", | ||||
|     "react-dom": "^19.0.0", | ||||
|     "react-router": "^7.2.0" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										77
									
								
								frontend/src/assets/apiary.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								frontend/src/assets/apiary.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| <?xml version="1.0" encoding="iso-8859-1"?> | ||||
| <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||||
| <svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  | ||||
| 	 viewBox="0 0 511.573 511.573" xml:space="preserve"> | ||||
| <g transform="translate(1 1)"> | ||||
| 	<polygon style="fill:#FFE100;" points="254.787,7.107 84.12,143.64 425.453,143.64 	"/> | ||||
| 	<polygon style="fill:#FFFFFF;" points="84.12,143.64 254.787,7.107 41.453,143.64 	"/> | ||||
| 	<polygon style="fill:#FFA800;" points="254.787,7.107 425.453,143.64 468.12,143.64 	"/> | ||||
| 	<g> | ||||
| 		<polygon style="fill:#FFE100;" points="41.453,502.04 92.653,502.04 92.653,450.84 41.453,450.84 		"/> | ||||
| 		<polygon style="fill:#FFE100;" points="416.92,502.04 468.12,502.04 468.12,450.84 416.92,450.84 		"/> | ||||
| 		<polygon style="fill:#FFE100;" points="41.453,450.84 468.12,450.84 468.12,143.64 41.453,143.64 		"/> | ||||
| 	</g> | ||||
| 	<polygon style="fill:#FFA800;" points="442.52,126.573 442.52,502.04 468.12,502.04 468.12,146.2 	"/> | ||||
| 	<polygon style="fill:#FFFFFF;" points="41.453,143.64 41.453,502.04 67.053,502.04 67.053,126.573 	"/> | ||||
| 	<path style="fill:#63D3FD;" d="M314.52,450.84H195.053V341.613c0-15.36,12.8-27.307,27.307-27.307h64 | ||||
| 		c15.36,0,27.307,12.8,27.307,27.307V450.84H314.52z"/> | ||||
| 	<path style="fill:#3DB9F9;" d="M287.213,314.307h-25.6c15.36,0,27.307,12.8,27.307,27.307V450.84h25.6V341.613 | ||||
| 		C314.52,327.107,302.573,314.307,287.213,314.307"/> | ||||
| 	<path d="M468.12,459.373H41.453c-5.12,0-8.533-3.413-8.533-8.533v-307.2c0-2.56,1.707-5.973,4.267-6.827L250.52,0.28 | ||||
| 		c2.56-1.707,5.973-1.707,9.387,0L473.24,136.813c2.56,1.707,4.267,4.267,4.267,6.827v307.2 | ||||
| 		C476.653,455.96,473.24,459.373,468.12,459.373z M49.987,442.307h409.6v-294.4l-204.8-130.56l-204.8,130.56V442.307z"/> | ||||
| 	<path d="M92.653,510.573h-51.2c-5.12,0-8.533-3.413-8.533-8.533v-51.2c0-5.12,3.413-8.533,8.533-8.533h51.2 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v51.2C101.187,507.16,97.773,510.573,92.653,510.573z M49.987,493.507H84.12v-34.133H49.987 | ||||
| 		V493.507z"/> | ||||
| 	<path d="M468.12,510.573h-51.2c-5.12,0-8.533-3.413-8.533-8.533v-51.2c0-5.12,3.413-8.533,8.533-8.533h51.2 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v51.2C476.653,507.16,473.24,510.573,468.12,510.573z M425.453,493.507h34.133v-34.133h-34.133 | ||||
| 		V493.507z"/> | ||||
| 	<path d="M7.32,177.773c-2.56,0-5.12-0.853-6.827-3.413c-2.56-3.413-1.707-9.387,1.707-11.947l34.133-25.6 | ||||
| 		c3.413-2.56,9.387-1.707,11.947,1.707c2.56,3.413,1.707,9.387-1.707,11.947l-34.133,25.6C10.733,176.92,9.027,177.773,7.32,177.773 | ||||
| 		z"/> | ||||
| 	<path d="M502.253,177.773c-1.707,0-3.413-0.853-5.12-1.707L463,150.467c-3.413-2.56-4.267-8.533-1.707-11.947 | ||||
| 		c2.56-3.413,8.533-4.267,11.947-1.707l34.133,25.6c3.413,2.56,4.267,8.533,1.707,11.947 | ||||
| 		C507.373,176.92,504.813,177.773,502.253,177.773z"/> | ||||
| 	<path d="M468.12,254.573H41.453c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533H468.12 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,251.16,473.24,254.573,468.12,254.573z M49.987,237.507h409.6v-85.333h-409.6 | ||||
| 		V237.507z"/> | ||||
| 	<path d="M314.52,459.373H195.053c-5.12,0-8.533-3.413-8.533-8.533V341.613c0-19.627,16.213-35.84,35.84-35.84h64 | ||||
| 		c19.627,0,35.84,16.213,35.84,35.84V450.84C323.053,455.96,319.64,459.373,314.52,459.373z M203.587,442.307h102.4V341.613 | ||||
| 		c0-10.24-8.533-18.773-18.773-18.773h-64c-11.093,0-19.627,8.533-19.627,18.773V442.307z"/> | ||||
| 	<path d="M195.053,459.373h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533h153.6 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v102.4C203.587,455.96,200.173,459.373,195.053,459.373z M49.987,442.307H186.52v-85.333H49.987 | ||||
| 		V442.307z"/> | ||||
| 	<path d="M468.12,459.373h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533h153.6 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,455.96,473.24,459.373,468.12,459.373z M323.053,442.307h136.533v-85.333H323.053 | ||||
| 		V442.307z"/> | ||||
| 	<path d="M468.12,356.973h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-6.827c0-10.24-8.533-18.773-18.773-18.773h-64 | ||||
| 		c-11.093,0-19.627,8.533-19.627,18.773v6.827c0,5.12-3.413,8.533-8.533,8.533h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4 | ||||
| 		c0-5.12,3.413-8.533,8.533-8.533H468.12c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,353.56,473.24,356.973,468.12,356.973z | ||||
| 		 M323.053,339.907h136.533v-85.333h-409.6v85.333H186.52c0.853-18.773,17.067-34.133,35.84-34.133h64 | ||||
| 		C305.987,305.773,322.2,321.133,323.053,339.907z"/> | ||||
| 	<path d="M468.12,288.707h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533S473.24,288.707,468.12,288.707z"/> | ||||
| 	<path d="M468.12,322.84h-34.133c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,319.427,473.24,322.84,468.12,322.84z"/> | ||||
| 	<path d="M101.187,288.707H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533S106.307,288.707,101.187,288.707z"/> | ||||
| 	<path d="M75.587,322.84H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C84.12,319.427,80.707,322.84,75.587,322.84z"/> | ||||
| 	<path d="M468.12,186.307h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,182.893,473.24,186.307,468.12,186.307z"/> | ||||
| 	<path d="M468.12,220.44h-34.133c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,217.027,473.24,220.44,468.12,220.44z"/> | ||||
| 	<path d="M101.187,186.307H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C109.72,182.893,106.307,186.307,101.187,186.307z"/> | ||||
| 	<path d="M75.587,220.44H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C84.12,217.027,80.707,220.44,75.587,220.44z"/> | ||||
| 	<path d="M468.12,391.107h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,387.693,473.24,391.107,468.12,391.107z"/> | ||||
| 	<path d="M468.12,425.24h-34.133c-5.12,0-8.533-3.413-8.533-8.533s3.413-8.533,8.533-8.533h34.133c5.12,0,8.533,3.413,8.533,8.533 | ||||
| 		S473.24,425.24,468.12,425.24z"/> | ||||
| 	<path d="M101.187,391.107H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C109.72,387.693,106.307,391.107,101.187,391.107z"/> | ||||
| 	<path d="M75.587,425.24H41.453c-5.12,0-8.533-3.413-8.533-8.533s3.413-8.533,8.533-8.533h34.133c5.12,0,8.533,3.413,8.533,8.533 | ||||
| 		S80.707,425.24,75.587,425.24z"/> | ||||
| </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 6.4 KiB | 
							
								
								
									
										2
									
								
								frontend/src/css/fonts.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								frontend/src/css/fonts.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| @import url("https://fonts.googleapis.com/css2?family=Prompt:wght@300;600&display=swap"); | ||||
| @import url('https://fonts.googleapis.com/css2?family=Secular+One&display=swap'); | ||||
							
								
								
									
										210
									
								
								frontend/src/css/style.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								frontend/src/css/style.module.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | ||||
| :root { | ||||
|     --main-color-bg: white; | ||||
|     --main-color: black; | ||||
|     /* Menu */ | ||||
|     --menu-color-bg: #212529; | ||||
|     --menu-color-text: hsla(0,0%,100%,.5); | ||||
|     --menu-color-text-hover: white; | ||||
|     --menu-color-title-text: white; | ||||
|  | ||||
|     /* Table */ | ||||
|     --table-color-header-bg: var(--menu-color-bg); | ||||
|     --table-color-header-text: #dddddd; | ||||
|     --table-row-odd: #dddddd; | ||||
|     --table-row-even: #f3f3f3; | ||||
| } | ||||
|  | ||||
| .dark_mode { | ||||
|     --main-color-bg: #15141a; | ||||
|     --main-color: white; | ||||
|     /* Menu */ | ||||
|     --menu-color-bg: #35393d; | ||||
|     --menu-color-text: hsla(0,0%,100%,.5); | ||||
|     --menu-color-text-hover: white; | ||||
|     --menu-color-title-text: white; | ||||
|  | ||||
|     /* Table */ | ||||
|     --table-color-header-bg: var(--menu-color-bg); | ||||
|     --table-color-header-text: var(--menu-color-text); | ||||
|     --table-row-odd: #302f36; | ||||
|     --table-row-even: #232229; | ||||
| } | ||||
|  | ||||
| #root { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
| } | ||||
| body { | ||||
|     background-color: var(--main-color-bg); | ||||
|     color: var(--main-color); | ||||
|     margin: 0; | ||||
|     margin-top: 100px; | ||||
| } | ||||
|  | ||||
| *, | ||||
| *::before, | ||||
| *::after { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| a { | ||||
|     text-decoration: none; | ||||
| } | ||||
|  | ||||
| canvas { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     padding-left: 0; | ||||
|     padding-right: 0; | ||||
|     margin-left: auto; | ||||
|     margin-right: auto; | ||||
| } | ||||
|  | ||||
| form { | ||||
|     width: 100%; | ||||
| } | ||||
| input[type=text] { | ||||
|     width: 100%; | ||||
|     height: 30px; | ||||
|     box-sizing: border-box; | ||||
|     border: 2px solid var(--menu-color-bg); | ||||
|     border-radius: 5px; | ||||
|     padding-left: 5px; | ||||
| } | ||||
|  | ||||
| .navbar { | ||||
|     background-color: var(--menu-color-bg); | ||||
|     display: flex; | ||||
|     font-size: 1.2rem; | ||||
|     font-family: "Prompt", sans-serif; | ||||
|     height: 40px; | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .navbar ul { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .root .navbar { | ||||
|     justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .navbar ul li { | ||||
|     list-style-type: none; | ||||
|     color: var(--menu-color-text); | ||||
|     padding-left: 10px; | ||||
|     padding-right: 10px; | ||||
|     line-height: 40px; | ||||
| } | ||||
|  | ||||
| .navbar h2 { | ||||
|     line-height: 40px; | ||||
|     align-items: center; | ||||
|     padding-right: 10px; | ||||
| } | ||||
|  | ||||
| .navbar a { | ||||
|     height: 1; | ||||
| } | ||||
|  | ||||
| .navbar a:hover { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
|  | ||||
| .submenu nav { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     line-height: 1; | ||||
| } | ||||
|  | ||||
| .totals { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(2, 1fr); | ||||
|     grid-template-rows: 1fr; | ||||
|     grid-column-gap: 0px; | ||||
|     grid-row-gap: 0px; | ||||
|     margin-top: 5px; | ||||
|     margin-bottom: 5px; | ||||
|     font-family: "Secular One", sans-serif; | ||||
|     font-size: 20px; | ||||
|     font-weight: 200; | ||||
| } | ||||
| .totals_key {  | ||||
|     grid-area: 1 / 1 / 2 / 2;  | ||||
|     text-align: right; | ||||
| } | ||||
| .totals_value {  | ||||
|     grid-area: 1 / 2 / 2 / 3; | ||||
|     padding-left: 10px; | ||||
|     text-align: left; | ||||
| } | ||||
| #menu_title { | ||||
|     color: var(--menu-color-text-hover); | ||||
|     font-family: "Secular One", sans-serif; | ||||
|     font-size: 30px; | ||||
|     font-weight: 300; | ||||
| } | ||||
| #menu_logo { | ||||
|     padding: 5px; | ||||
|     content: url("../assets/apiary.svg"); | ||||
|     width: 30px; | ||||
|     height: 30px; | ||||
| } | ||||
| .menu_link { | ||||
|     color: var(--menu-color-text); | ||||
| } | ||||
| .menu_link:hover { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
| .menu_link_active { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
| .stats_pie { | ||||
|     max-height: 70vh; | ||||
|     text-align: left; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .live_table { | ||||
|     border-collapse: collapse; | ||||
|     margin: 25px 0; | ||||
|     font-size: 0.9em; | ||||
|     font-family: sans-serif; | ||||
|     min-width: 400px; | ||||
|     width: 100%; | ||||
|     box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); | ||||
| } | ||||
| .live_table thead tr { | ||||
|     background-color: var(--table-color-header-bg); | ||||
|     color: var(--table-color-header-text); | ||||
|     text-align: left; | ||||
| } | ||||
| .live_table th, | ||||
| .live_table td { | ||||
|     padding: 12px 15px; | ||||
| } | ||||
| .live_table tbody tr { | ||||
|     border-bottom: 1px solid var(--table-row-odd); | ||||
| } | ||||
| .live_table tbody tr:nth-of-type(even) { | ||||
|     background-color: var(--table-row-even); | ||||
| } | ||||
| .live_table tbody tr:last-of-type { | ||||
|     border-bottom: 2px solid var(--table-color-bg); | ||||
| } | ||||
| .live_table tbody tr.active-row { | ||||
|     font-weight: bold; | ||||
|     color: var(--table-color-bg); | ||||
| } | ||||
|  | ||||
| .content { | ||||
|     margin-left: 10vw; | ||||
|     width: 50vw; | ||||
|     text-align: center; | ||||
| } | ||||
							
								
								
									
										12
									
								
								frontend/src/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/src/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <title></title> | ||||
|     <link rel="stylesheet" href="./css/fonts.css" /> | ||||
|     <script type="module" src="./js/app.tsx"></script> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										154
									
								
								frontend/src/js/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								frontend/src/js/api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| import { randUserName, randRecentDate, randIp, randPassword, randUuid, randNumber } from '@ngneat/falso'; | ||||
| import { r } from 'react-router/dist/development/fog-of-war-Cm1iXIp7'; | ||||
|  | ||||
| export interface LoginAttempt { | ||||
|     readonly date: string; | ||||
|     readonly remoteIP: string; | ||||
|     readonly username: string; | ||||
|     readonly password: string; | ||||
|     readonly sshClientVersion: string; | ||||
|     readonly connectionUUID: string; | ||||
|     readonly country: string; | ||||
| } | ||||
|  | ||||
| export interface TotalStats { | ||||
|     readonly password: number; | ||||
|     readonly username: number; | ||||
|     readonly ip: number; | ||||
|     readonly country: number; | ||||
|     readonly attempts: number; | ||||
| } | ||||
|  | ||||
| export interface StatsResult { | ||||
|     name: string; | ||||
|     count: number; | ||||
| } | ||||
|  | ||||
| export type StatsType = 'password' | 'username' | 'ip' | 'country' | 'attempts'; | ||||
|  | ||||
| export interface ApiaryAPI { | ||||
|     live(fn: (a: LoginAttempt) => void): void; | ||||
|     stats(statsType: StatsType, limit: number): Promise<StatsResult[]>; | ||||
|     query(queryType: string, query: string): Promise<LoginAttempt[]>; | ||||
|     totals(): Promise<TotalStats>; | ||||
| } | ||||
|  | ||||
| function fakeLoginAttempt(): LoginAttempt { | ||||
|     return { | ||||
|         date: randRecentDate({ days: 2 }).toISOString(), | ||||
|         remoteIP: randIp().toString(), | ||||
|         username: randUserName().toString(), | ||||
|         password: randPassword().toString(), | ||||
|         sshClientVersion: '1.0', | ||||
|         connectionUUID: randUuid().toString(), | ||||
|         country: 'NO' | ||||
|     } | ||||
| } | ||||
|  | ||||
| export class DummyApiaryAPIClient implements ApiaryAPI { | ||||
|     live(fn: (a: LoginAttempt) => void): () => void { | ||||
|         const interval = setInterval(() => { | ||||
|             let a = fakeLoginAttempt(); | ||||
|             fn(a); | ||||
|         }, 1000); | ||||
|  | ||||
|         return () => { clearInterval(interval) } | ||||
|     } | ||||
|     async stats(_type: StatsType, limit: number): Promise<StatsResult[]> { | ||||
|         const stats = Array.from({ length: limit }, () => { | ||||
|             switch (_type) { | ||||
|                 case 'password': | ||||
|                     return { name: randPassword().toString(), count: randNumber().valueOf() } | ||||
|                 case 'username': | ||||
|                     return { name: randUserName().toString(), count: randNumber().valueOf() } | ||||
|                 case 'ip': | ||||
|                     return { name: randIp().toString(), count: randNumber().valueOf() } | ||||
|                 case 'country': | ||||
|                     return { name: 'NO', count: randNumber().valueOf() } | ||||
|             } | ||||
|             return { name: randUserName().toString(), count: randNumber().valueOf() } | ||||
|         }); | ||||
|  | ||||
|         const sorted = stats.sort((a, b) => b.count - a.count) | ||||
|         return Promise.resolve(sorted); | ||||
|     } | ||||
|     async query(_type: string, _query: string): Promise<LoginAttempt[]> { | ||||
|         const attempts = Array.from({ length: 10 }, () => { | ||||
|             return fakeLoginAttempt(); | ||||
|         }) | ||||
|         return Promise.resolve(attempts); | ||||
|     } | ||||
|  | ||||
|     async totals(): Promise<TotalStats> { | ||||
|         return Promise.resolve({ password: 1, username: 1, ip: 1, country: 1, attempts: 1 }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| export class ApiaryAPIClient implements ApiaryAPI { | ||||
|     live(fn: (a: LoginAttempt) => void): () => void { | ||||
|         const es = new EventSource('/api/stream'); | ||||
|         const updateFn = (ev: MessageEvent<string>) => { | ||||
|             const attempt: LoginAttempt = JSON.parse(ev.data); | ||||
|             fn(attempt); | ||||
|         }; | ||||
|         es.addEventListener('message', updateFn) | ||||
|  | ||||
|         return () => { | ||||
|             es.removeEventListener('message', updateFn); | ||||
|             es.close(); | ||||
|         } | ||||
|     } | ||||
|     async stats(statsType: StatsType, limit: number): Promise<StatsResult[]> { | ||||
|         const resp = await fetch(`/api/stats?type=${statsType}&limit=${limit}`) | ||||
|         if (!resp.ok) { | ||||
|             throw new Error('Failed to fetch query') | ||||
|         } | ||||
|         const data: StatsResult[] | null = await resp.json() | ||||
|         if (!data) { | ||||
|             return [] | ||||
|         } | ||||
|         return data.sort((a, b) => b.count - a.count) | ||||
|     } | ||||
|     async query(queryType: string, query: string): Promise<LoginAttempt[]> { | ||||
|         const resp = await fetch(`/api/query?type=${queryType}&query=${query}`) | ||||
|         if (!resp.ok) { | ||||
|             throw new Error('Failed to fetch query') | ||||
|         } | ||||
|         const data: LoginAttempt[] = await resp.json() | ||||
|  | ||||
|         return data | ||||
|     } | ||||
|  | ||||
|     async totals(): Promise<TotalStats> { | ||||
|         let password: number = -1 | ||||
|         let username: number = -1 | ||||
|         let ip: number = -1 | ||||
|         let attempts: number = -1 | ||||
|         let country: number = -1 | ||||
|  | ||||
|         const resp = await fetch('/api/stats?type=total') | ||||
|         const data: Array<StatsResult> = await resp.json() | ||||
|  | ||||
|         for (const stat of data) { | ||||
|             switch (stat.name) { | ||||
|                 case 'UniquePasswords': | ||||
|                     password = stat.count | ||||
|                     break | ||||
|                 case 'UniqueUsernames': | ||||
|                     username = stat.count | ||||
|                     break | ||||
|                 case 'UniqueIPs': | ||||
|                     ip = stat.count | ||||
|                     break | ||||
|                 case 'TotalLoginAttempts': | ||||
|                     attempts = stat.count | ||||
|                     break | ||||
|                 case 'UniqueCountries': | ||||
|                     country = stat.count | ||||
|                     break | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return { password, username, ip, attempts, country } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										467
									
								
								frontend/src/js/app.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										467
									
								
								frontend/src/js/app.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,467 @@ | ||||
| import React from "react"; | ||||
| import { createRoot } from "react-dom/client"; | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult, StatsType } from "./api"; | ||||
| import { BrowserRouter, NavLink, Routes, Route } from "react-router"; | ||||
| import { Chart as ChartJS, Tooltip, ArcElement, Legend, ChartOptions } from "chart.js"; | ||||
| import { Pie } from "react-chartjs-2"; | ||||
| import humanizeDuration from "humanize-duration"; | ||||
| import * as classes from "../css/style.module.css"; | ||||
|  | ||||
| ChartJS.register(Tooltip, ArcElement, Legend); | ||||
| console.log(classes); | ||||
|  | ||||
| interface AppProps { | ||||
|     api: ApiaryAPI | ||||
| } | ||||
|  | ||||
| let chartColors = [ | ||||
|     "#1abc9c", | ||||
|     "#2ecc71", | ||||
|     "#3498db", | ||||
|     "#9b59b6", | ||||
|     "#34495e", | ||||
|     "#16a085", | ||||
|     "#27ae60", | ||||
|     "#2980b9", | ||||
|     "#8e44ad", | ||||
|     "#2c3e50", | ||||
|     "#f1c40f", | ||||
|     "#e67e22", | ||||
|     "#e74c3c", | ||||
|     "#ecf0f1", | ||||
|     "#95a5a6", | ||||
|     "#f39c12", | ||||
|     "#d35400", | ||||
|     "#c0392b", | ||||
|     "#bdc3c7", | ||||
|     "#7f8c8d", | ||||
| ] | ||||
|  | ||||
| export function App({ api }: AppProps) { | ||||
|     const [mode, setMode] = useState("light"); | ||||
|     const headerProps: HeaderMenuProps = { | ||||
|         title: "apiary.home.2rjus.net", | ||||
|         items: [ | ||||
|             { | ||||
|                 name: "Totals", | ||||
|                 path: "/", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Passwords", | ||||
|                 path: "/stats/password", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Usernames", | ||||
|                 path: "/stats/username", | ||||
|             }, | ||||
|             { | ||||
|                 name: "IPs", | ||||
|                 path: "/stats/ip", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Live", | ||||
|                 path: "/live", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Query", | ||||
|                 path: "/query", | ||||
|             } | ||||
|         ] | ||||
|     } | ||||
|     const onSelectMode = (mode: string) => { | ||||
|         setMode(mode); | ||||
|  | ||||
|         if (mode === "dark") { | ||||
|             document.body.classList.add(classes.dark_mode); | ||||
|         } else { | ||||
|             document.body.classList.remove(classes.dark_mode); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     useEffect(() => { | ||||
|         window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light")); | ||||
|         onSelectMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') | ||||
|         return () => { | ||||
|             window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light")); | ||||
|         } | ||||
|     }, []) | ||||
|     return ( | ||||
|         <> | ||||
|             <BrowserRouter> | ||||
|                 <div id="app"> | ||||
|                     <HeaderMenu title={headerProps.title} items={headerProps.items} /> | ||||
|                     <div className={classes.content}> | ||||
|                         <Routes> | ||||
|                             <Route path="/" element={<Home api={api} />} /> | ||||
|                             <Route path="/stats/password" element={<Stats api={api} type="password" />} /> | ||||
|                             <Route path="/stats/username" element={<Stats api={api} type="username" />} /> | ||||
|                             <Route path="/stats/ip" element={<Stats api={api} type="ip" />} /> | ||||
|                             <Route path="/live" element={<Live api={api} />} /> | ||||
|                             <Route path="/query" element={<Query api={api} />} /> | ||||
|                         </Routes> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </BrowserRouter > | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export interface StatsProps { | ||||
|     api: ApiaryAPI | ||||
|     type: StatsType | ||||
| } | ||||
|  | ||||
| export function Stats({ api, type }: StatsProps) { | ||||
|     const [stats, setStats] = useState<StatsResult[] | null>(null) | ||||
|     const [currentType, setCurrentType] = useState(type) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         async function getStats() { | ||||
|             try { | ||||
|                 let newStats = await api.stats(type, 10); | ||||
|                 if (JSON.stringify(newStats) !== JSON.stringify(stats)) { | ||||
|                     setStats(newStats) | ||||
|                 } | ||||
|             } catch (e) { | ||||
|                 console.log("Error getting stats", e) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (currentType !== type) { | ||||
|             setCurrentType(type) | ||||
|             getStats() | ||||
|         } | ||||
|  | ||||
|         if (stats === null) { | ||||
|             getStats() | ||||
|         } | ||||
|  | ||||
|         const interval = setInterval(() => { | ||||
|             getStats() | ||||
|         }, 60000) | ||||
|  | ||||
|         return () => { | ||||
|             clearInterval(interval); | ||||
|         } | ||||
|     }, [stats, type]) | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             {(stats != null && stats.length > 0) ? <StatsPie data={stats} /> : <p>Loading...</p>} | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export interface StatsPieProps { | ||||
|     data: StatsResult[] | ||||
| } | ||||
|  | ||||
| export function StatsPie({ data }: StatsPieProps) { | ||||
|     const labels = data.map((d) => d.name); | ||||
|     const values = data.map((d) => d.count); | ||||
|     const piedata = { | ||||
|         labels, | ||||
|         datasets: [{ | ||||
|             label: "# of attempts", | ||||
|             data: values, | ||||
|             backgroundColor: chartColors, | ||||
|             borderWidth: 1 | ||||
|         }] | ||||
|     }; | ||||
|     console.log(piedata) | ||||
|     const getTextColor = () => { | ||||
|         return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'white' : 'black' | ||||
|     } | ||||
|     const options: ChartOptions<"pie"> = { | ||||
|         plugins: { | ||||
|             legend: { | ||||
|                 display: true, | ||||
|                 align: "start", | ||||
|                 labels: { | ||||
|                     color: getTextColor(), | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     return ( | ||||
|         <div className={classes.stats_pie}> | ||||
|             <Pie data={piedata} options={options} /> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export function Home({ api }: AppProps) { | ||||
|     const [totals, setTotals] = useState<TotalStats | null>(null) | ||||
|     useEffect(() => { | ||||
|         async function getTotals() { | ||||
|             let totals = await api.totals(); | ||||
|             setTotals(totals); | ||||
|         } | ||||
|  | ||||
|         if (!totals) { | ||||
|             getTotals(); | ||||
|         } | ||||
|  | ||||
|         const interval = setInterval(() => { | ||||
|             getTotals(); | ||||
|         }, 5000) | ||||
|  | ||||
|         return () => { | ||||
|             clearInterval(interval); | ||||
|         } | ||||
|     }) | ||||
|     return ( | ||||
|         <> | ||||
|             {totals ? <Totals totals={totals} /> : <p>Loading...</p>} | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export interface TotalsProps { | ||||
|     totals: TotalStats | ||||
| } | ||||
|  | ||||
| export function Totals({ totals }: TotalsProps) { | ||||
|     return ( | ||||
|         <div className={classes.totals}> | ||||
|             <div className={classes.totals_key}> | ||||
|                 <h2>Unique passwords</h2> | ||||
|                 <h2>Unique username</h2> | ||||
|                 <h2>Unique IPs</h2> | ||||
|                 <h2>Total attempts</h2> | ||||
|             </div> | ||||
|             <div className={classes.totals_value}> | ||||
|                 <h2>{totals.password}</h2> | ||||
|                 <h2>{totals.username}</h2> | ||||
|                 <h2>{totals.ip}</h2> | ||||
|                 <h2>{totals.attempts}</h2> | ||||
|             </div> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export function Live({ api }: AppProps) { | ||||
|     let list: LoginAttempt[] = []; | ||||
|     let [liveList, setLiveList] = useState(list); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const cleanup = api.live((a) => { | ||||
|             setLiveList((list) => { | ||||
|                 return [a, ...list]; | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         return cleanup | ||||
|  | ||||
|     }, [liveList, api]); | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <LiveList list={liveList} /> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export interface LiveListProps { | ||||
|     list: LoginAttempt[] | ||||
| }; | ||||
|  | ||||
| export interface DateTDProps { | ||||
|     date: Date | ||||
|     now: Date | ||||
| } | ||||
|  | ||||
| export function DateTD({ date, now }: DateTDProps) { | ||||
|     const [displayDate, setDisplayDate] = useState(date.toLocaleString()); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (now.getTime() - date.getTime() < 14400000) { | ||||
|             const newDate = humanizeDuration(now.getTime() - date.getTime(), { largest: 1, round: true }) + " ago"; | ||||
|             if (newDate !== displayDate) { | ||||
|                 setDisplayDate(newDate); | ||||
|             } | ||||
|         } | ||||
|     }, [displayDate, now]) | ||||
|  | ||||
|     return ( | ||||
|         <td className={classes.live_table_date}>{displayDate}</td> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export function LiveList({ list }: LiveListProps) { | ||||
|     const [now, setNow] = useState(new Date()) | ||||
|  | ||||
|     let items = list.map((a) => { | ||||
|         const attemptDate = new Date(a.date); | ||||
|         const key = `${a.username}-${a.password}-${a.remoteIP}-${a.date}`; | ||||
|         return ( | ||||
|             <tr key={a.date}> | ||||
|                 <DateTD date={attemptDate} now={now} /> | ||||
|                 <td className={classes.live_table_username}>{a.username}</td> | ||||
|                 <td className={classes.live_table_password}>{a.password}</td> | ||||
|                 <td className={classes.live_table_ip}>{a.remoteIP}</td> | ||||
|                 <td className={classes.live_table_country}>{a.country}</td> | ||||
|             </tr> | ||||
|         ) | ||||
|     }) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const interval = setInterval(() => { | ||||
|             setNow(new Date()) | ||||
|         }, 1000) | ||||
|  | ||||
|         return () => { | ||||
|             clearInterval(interval) | ||||
|         } | ||||
|     }, [now]) | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <table className={classes.live_table}> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <th className={classes.live_table_date}>Date</th> | ||||
|                         <th className={classes.live_table_username}>Username</th> | ||||
|                         <th className={classes.live_table_password}>Password</th> | ||||
|                         <th className={classes.live_table_ip}>IP</th> | ||||
|                         <th className={classes.live_table_country}>Country</th> | ||||
|                     </tr> | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {items} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         </> | ||||
|     ) | ||||
| }; | ||||
|  | ||||
|  | ||||
| export function Query({ api }: AppProps) { | ||||
|     const [liveList, setLiveList] = useState<LoginAttempt[]>([]); | ||||
|     const [queryErr, setQueryErr] = useState<Error | null>(null); | ||||
|  | ||||
|     async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { | ||||
|         event.preventDefault(); | ||||
|         const value = event.currentTarget.query.value; | ||||
|         if (value === "") { | ||||
|             setQueryErr(new Error("Query cannot be empty")); | ||||
|             return | ||||
|         } | ||||
|         try { | ||||
|             const results = await api.query("", value) | ||||
|             setQueryErr(null); | ||||
|             setLiveList(results); | ||||
|         } catch (e) { | ||||
|             if (e instanceof Error) { | ||||
|                 setQueryErr(e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     return ( | ||||
|         <> | ||||
|             <form onSubmit={handleSubmit}> | ||||
|                 <input placeholder="Search..." name="query" type="text" /> | ||||
|             </form> | ||||
|             {queryErr ? <ErrorBox message={queryErr.message} /> : null} | ||||
|             <LiveList list={liveList} /> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| interface ErrorBoxProps { | ||||
|     message: string | null | ||||
| }; | ||||
|  | ||||
| export function ErrorBox({ message }: ErrorBoxProps) { | ||||
|     return ( | ||||
|         <div className={classes.error_box}> | ||||
|             <p>Error: {message}</p> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export function Header() { | ||||
|     return ( | ||||
|         <div className={classes.navbar}> | ||||
|             <h2 id="menu-title">apiary.home.2rjus.net</h2> | ||||
|             <nav className={classes.nav_flex}> | ||||
|                 <ul> | ||||
|                     <li><NavLink to="/" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Home</NavLink></li> | ||||
|                     <li><NavLink to="/stats/password" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Stats</NavLink></li> | ||||
|                     <li><NavLink to="/live" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Live</NavLink></li> | ||||
|                     <li><NavLink to="/query" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Query</NavLink></li> | ||||
|                 </ul> | ||||
|             </nav> | ||||
|         </div > | ||||
|     ); | ||||
| } | ||||
|  | ||||
| interface HeaderItem { | ||||
|     name: string | ||||
|     path: string | ||||
| } | ||||
|  | ||||
| interface HeaderMenuProps { | ||||
|     title: string | ||||
|     items: Array<HeaderItem> | ||||
| } | ||||
|  | ||||
| export function HeaderMenu({ title, items }: HeaderMenuProps) { | ||||
|     const menuItems = items.map((item) => { | ||||
|         return ( | ||||
|             <li key={item.path}> | ||||
|                 <NavLink to={item.path} className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>{item.name}</NavLink> | ||||
|             </li> | ||||
|         ) | ||||
|     }) | ||||
|  | ||||
|     return ( | ||||
|         <div className={classes.navbar}> | ||||
|             <img id={classes.menu_logo}></img> | ||||
|             <h2 id={classes.menu_title}>{title}</h2> | ||||
|             <nav className={classes.nav_flex}> | ||||
|                 <ul> | ||||
|                     {menuItems} | ||||
|                 </ul> | ||||
|             </nav> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export interface SubMenuProps { | ||||
|     items: Array<{ name: string, active: () => boolean, onClick: () => void }> | ||||
| } | ||||
|  | ||||
| export function SubMenu({ items }: SubMenuProps) { | ||||
|     return ( | ||||
|         <nav className={classes.submenu}> | ||||
|             <ul> | ||||
|                 {items.map((item) => { | ||||
|                     return <li> | ||||
|                         <a | ||||
|                             href="#" | ||||
|                             className={item.active() ? classes.sub_menu_active : classes.sub_menu_link} | ||||
|                             onClick={item.onClick}>{item.name}</a> | ||||
|                     </li> | ||||
|                 })} | ||||
|             </ul> | ||||
|         </nav> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| const rootElement = document.getElementById('root'); | ||||
| if (rootElement) { | ||||
|     const root = createRoot(rootElement); | ||||
|     let api: ApiaryAPI; | ||||
|     if (process.env.NODE_ENV === "production") { | ||||
|         api = new ApiaryAPIClient(); | ||||
|     } else { | ||||
|         api = new DummyApiaryAPIClient(); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     root.render( | ||||
|         <App api={api} /> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										17
									
								
								frontend/src/tests/api.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/tests/api.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { DummyApiaryAPIClient, TotalStats } from "../js/api"; | ||||
|  | ||||
| describe("DummyApiaryAPIClient", () => { | ||||
|     const api = new DummyApiaryAPIClient() | ||||
|     test("totals returns expeced value", async () => { | ||||
|         let totals = await api.totals() | ||||
|         const expected: TotalStats = { | ||||
|             password: 1, | ||||
|             username: 1, | ||||
|             ip: 1, | ||||
|             attempts: 1, | ||||
|             country: 1, | ||||
|         } | ||||
|  | ||||
|         expect(totals).toEqual(expected) | ||||
|     }) | ||||
| }); | ||||
							
								
								
									
										16
									
								
								frontend/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "esModuleInterop": true, | ||||
|     "jsx": "react", | ||||
|     "module": "esnext", | ||||
|     "moduleResolution": "node", | ||||
|     "lib": ["dom", "esnext"], | ||||
|     "strict": true, | ||||
|     "sourceMap": true, | ||||
|     "target": "esnext", | ||||
|     "types": ["node", "jest"] | ||||
|   }, | ||||
|   "exclude": ["node_modules"], | ||||
|   "include": ["src/**/*.ts", "src/**/*.tsx"] | ||||
| } | ||||
|  | ||||
| @@ -6,7 +6,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	Version = "v0.1.32" | ||||
| 	Version = "v0.2.1" | ||||
| 	Build   string | ||||
| ) | ||||
|  | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| > 1% | ||||
| last 2 versions | ||||
| not dead | ||||
| @@ -1,7 +0,0 @@ | ||||
| [*.{js,jsx,ts,tsx,vue}] | ||||
| indent_style = space | ||||
| indent_size = 2 | ||||
| end_of_line = lf | ||||
| trim_trailing_whitespace = true | ||||
| insert_final_newline = true | ||||
| max_line_length = 100 | ||||
| @@ -1,20 +0,0 @@ | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   env: { | ||||
|     node: true, | ||||
|   }, | ||||
|   extends: [ | ||||
|     'plugin:vue/essential', | ||||
|     '@vue/airbnb', | ||||
|     '@vue/typescript/recommended', | ||||
|     'prettier' | ||||
|   ], | ||||
|   parserOptions: { | ||||
|     ecmaVersion: 2020, | ||||
|   }, | ||||
|   rules: { | ||||
|     'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', | ||||
|     'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', | ||||
|     'implicit-arrow-linebreak': 'warn', | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										23
									
								
								web/frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								web/frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,23 +0,0 @@ | ||||
| .DS_Store | ||||
| node_modules | ||||
| /dist | ||||
|  | ||||
|  | ||||
| # local env files | ||||
| .env.local | ||||
| .env.*.local | ||||
|  | ||||
| # Log files | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
|  | ||||
| # Editor directories and files | ||||
| .idea | ||||
| .vscode | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
| @@ -1,24 +0,0 @@ | ||||
| # vue-frontend | ||||
|  | ||||
| ## Project setup | ||||
| ``` | ||||
| yarn install | ||||
| ``` | ||||
|  | ||||
| ### Compiles and hot-reloads for development | ||||
| ``` | ||||
| yarn serve | ||||
| ``` | ||||
|  | ||||
| ### Compiles and minifies for production | ||||
| ``` | ||||
| yarn build | ||||
| ``` | ||||
|  | ||||
| ### Lints and fixes files | ||||
| ``` | ||||
| yarn lint | ||||
| ``` | ||||
|  | ||||
| ### Customize configuration | ||||
| See [Configuration Reference](https://cli.vuejs.org/config/). | ||||
| @@ -1,5 +0,0 @@ | ||||
| module.exports = { | ||||
|   presets: [ | ||||
|     '@vue/cli-plugin-babel/preset', | ||||
|   ], | ||||
| }; | ||||
| @@ -1,50 +0,0 @@ | ||||
| { | ||||
|   "name": "apiary-frontend", | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "serve": "vue-cli-service serve", | ||||
|     "build": "vue-cli-service build", | ||||
|     "lint": "vue-cli-service lint" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fontsource/rubik": "^4.2.2", | ||||
|     "@fontsource/secular-one": "^4.2.2", | ||||
|     "axios": "^0.21.1", | ||||
|     "bootstrap": "^4.6.0", | ||||
|     "bootstrap-vue": "^2.21.2", | ||||
|     "chart.js": "^3.0.2", | ||||
|     "core-js": "^3.6.5", | ||||
|     "d3": "^6.6.2", | ||||
|     "d3-svg-legend": "^2.25.6", | ||||
|     "faker": "^5.5.2", | ||||
|     "randomcolor": "^0.6.2", | ||||
|     "s-ago": "^2.2.0", | ||||
|     "vue": "^2.6.11", | ||||
|     "vue-class-component": "^7.2.3", | ||||
|     "vue-property-decorator": "^9.1.2", | ||||
|     "vue-router": "^3.5.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/chart.js": "^2.9.32", | ||||
|     "@types/d3": "^6.3.0", | ||||
|     "@types/faker": "^5.5.1", | ||||
|     "@types/randomcolor": "^0.5.5", | ||||
|     "@typescript-eslint/eslint-plugin": "^4.18.0", | ||||
|     "@typescript-eslint/parser": "^4.18.0", | ||||
|     "@vue/cli-plugin-babel": "~4.5.0", | ||||
|     "@vue/cli-plugin-eslint": "~4.5.0", | ||||
|     "@vue/cli-plugin-typescript": "~4.5.0", | ||||
|     "@vue/cli-service": "~4.5.0", | ||||
|     "@vue/eslint-config-airbnb": "^5.0.2", | ||||
|     "@vue/eslint-config-typescript": "^7.0.0", | ||||
|     "eslint": "^6.7.2", | ||||
|     "eslint-config-prettier": "^8.1.0", | ||||
|     "eslint-plugin-import": "^2.20.2", | ||||
|     "eslint-plugin-vue": "^6.2.2", | ||||
|     "sass": "^1.26.5", | ||||
|     "sass-loader": "^8.0.2", | ||||
|     "typescript": "~4.1.5", | ||||
|     "vue-template-compiler": "^2.6.11" | ||||
|   } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 15 KiB | 
| @@ -1,22 +0,0 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|  | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <meta http-equiv="X-UA-Compatible" content="IE=edge"> | ||||
|   <meta name="description" content="Stats about logins, usernames and passwords received by the SSH honeypot."> | ||||
|   <meta name="viewport" content="width=device-width,initial-scale=1.0"> | ||||
|   <link rel="icon" href="<%= BASE_URL %>favicon.ico"> | ||||
|   <title>apiary.t-juice.club</title> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|   <noscript> | ||||
|     <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. | ||||
|         Please enable it to continue.</strong> | ||||
|   </noscript> | ||||
|   <div id="app"></div> | ||||
|   <!-- built files will be auto injected --> | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 13 KiB | 
| @@ -1,3 +0,0 @@ | ||||
| # https://www.robotstxt.org/robotstxt.html | ||||
| User-agent: * | ||||
| Disallow: | ||||
| @@ -1,75 +0,0 @@ | ||||
| <template> | ||||
|   <div id="app"> | ||||
|     <div id="apiary-navbar"> | ||||
|       <b-navbar toggleable="md" type="dark" variant="dark"> | ||||
|         <b-navbar-brand href="#"> | ||||
|           <img | ||||
|             alt="logo" | ||||
|             src="logo192.png" | ||||
|             width="29" | ||||
|             height="29" | ||||
|             class="d-inline-block align-top" | ||||
|           /> | ||||
|           apiary.t-juice.club | ||||
|         </b-navbar-brand> | ||||
|         <b-navbar-toggle target="nav-collapse"></b-navbar-toggle> | ||||
|         <b-collapse id="nav-collapse" is-nav> | ||||
|           <b-navbar-nav class="mr-auto"> | ||||
|             <b-nav-item :to="'/'">Home</b-nav-item> | ||||
|             <b-nav-item :to="'/stats'">Stats</b-nav-item> | ||||
|             <b-nav-item :to="'/attempts'">Attempts</b-nav-item> | ||||
|             <b-nav-item :to="'/search'">Search</b-nav-item> | ||||
|           </b-navbar-nav> | ||||
|         </b-collapse> | ||||
|       </b-navbar> | ||||
|     </div> | ||||
|     <b-container id="main-content-container" fluid="md"> | ||||
|       <router-view></router-view> | ||||
|     </b-container> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Vue } from 'vue-property-decorator'; | ||||
| import AttemptList from '@/components/AttemptList.vue'; | ||||
| import Stats from '@/components/Stats.vue'; | ||||
| import Home from '@/components/Home.vue'; | ||||
|  | ||||
| @Component({ | ||||
|   components: { | ||||
|     AttemptList, | ||||
|     Stats, | ||||
|     Home, | ||||
|   }, | ||||
| }) | ||||
| export default class App extends Vue {} | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| $font-stack-header: 'Secular One', sans-serif; | ||||
| $font-stack-content: 'Rubik', sans-serif; | ||||
| #app { | ||||
|   font-family: $font-stack-content; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
|   text-align: center; | ||||
|   color: #2c3e50; | ||||
|   margin-top: 0; | ||||
| } | ||||
| .navbar-brand { | ||||
|   font-family: $font-stack-header; | ||||
| } | ||||
| .navbar-nav a { | ||||
|   font-family: $font-stack-content; | ||||
| } | ||||
|  | ||||
| h1 h2 h3 { | ||||
|   font-family: $font-stack-header; | ||||
| } | ||||
|  | ||||
| #main-content-container { | ||||
|   margin-top: 50px; | ||||
|   text-align: center; | ||||
|   display: inline-block; | ||||
| } | ||||
| </style> | ||||
| @@ -1,8 +0,0 @@ | ||||
| import { ApiaryAPI, LoginAttempt } from "@/apiary/apiary"; | ||||
|  | ||||
|  | ||||
| export class ApiaryDummyClient implements ApiaryAPI { | ||||
|     streamLoginAttempts(): any { | ||||
|         return 'a'; | ||||
|     } | ||||
| } | ||||
| @@ -1,35 +0,0 @@ | ||||
| import FakerStatic from 'faker'; | ||||
|  | ||||
| export interface LoginAttempt { | ||||
|   readonly date: string; | ||||
|   readonly remoteIP: string; | ||||
|   readonly username: string; | ||||
|   readonly password: string; | ||||
|   readonly sshClientVersion: string; | ||||
|   readonly connectionUUID: string; | ||||
| } | ||||
|  | ||||
| export interface StatResult { | ||||
|   name: string; | ||||
|   count: number; | ||||
| } | ||||
|  | ||||
| export interface ApiaryAPI { | ||||
|   streamLoginAttempts(): ReadableStream<LoginAttempt>; | ||||
| } | ||||
|  | ||||
| export function fakeAttempt(): LoginAttempt { | ||||
|   return { | ||||
|     date: FakerStatic.date.recent().toLocaleDateString(), | ||||
|     remoteIP: FakerStatic.internet.ip(), | ||||
|     username: FakerStatic.internet.userName(), | ||||
|     password: FakerStatic.internet.password(), | ||||
|     sshClientVersion: FakerStatic.lorem.words(2), | ||||
|     connectionUUID: FakerStatic.datatype.uuid(), | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function fakeAttemptStream(): EventSource { | ||||
|   const es = new EventSource('/stream'); | ||||
|   return es; | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 6.7 KiB | 
| @@ -1,79 +0,0 @@ | ||||
| <template> | ||||
|   <div class="attemptlist"> | ||||
|     <h1>Live Attempts</h1> | ||||
|     <p> | ||||
|       <b-table | ||||
|         responsive="md" | ||||
|         striped | ||||
|         hover | ||||
|         :items="attempts" | ||||
|         :fields="fields" | ||||
|       /> | ||||
|     </p> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Prop, Vue } from 'vue-property-decorator'; | ||||
| import { BvTableFieldArray, BvTableFormatterCallback } from 'bootstrap-vue'; | ||||
| import { LoginAttempt } from '@/apiary/apiary'; | ||||
|  | ||||
| @Component | ||||
| export default class AttemptList extends Vue { | ||||
|   @Prop() private items!: LoginAttempt[]; | ||||
|  | ||||
|   attempts: LoginAttempt[]; | ||||
|  | ||||
|   fields: BvTableFieldArray = [ | ||||
|     { | ||||
|       key: 'date', | ||||
|       sortable: true, | ||||
|       formatter: (value: string): string => { | ||||
|         const d = new Date(value); | ||||
|         // This seems stupid... | ||||
|         return d.toTimeString().split(' ')[0]; | ||||
|       }, | ||||
|       sortByFormatted: false, | ||||
|     }, | ||||
|     { | ||||
|       key: 'username', | ||||
|     }, | ||||
|     { | ||||
|       key: 'password', | ||||
|     }, | ||||
|     { | ||||
|       key: 'remoteIP', | ||||
|       sortable: true, | ||||
|     }, | ||||
|     { | ||||
|       key: 'country', | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
|     this.attempts = []; | ||||
|   } | ||||
|  | ||||
|   mounted(): void { | ||||
|     /** | ||||
|     console.log(this); | ||||
|     const at: LoginAttempt[] = []; | ||||
|  | ||||
|     for (let i = 0; i < 5; i += 1) { | ||||
|       at.push(fakeAttempt()); | ||||
|     } | ||||
|  | ||||
|     setInterval(() => { | ||||
|       at.push(fakeAttempt()); | ||||
|     }, 1000); | ||||
|     */ | ||||
|  | ||||
|     const attemptStream = new EventSource('/api/stream'); | ||||
|     attemptStream.addEventListener('message', (ev: MessageEvent<string>) => { | ||||
|       const parsed: LoginAttempt = JSON.parse(ev.data); | ||||
|       this.attempts.unshift(parsed); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @@ -1,145 +0,0 @@ | ||||
| <template> | ||||
|   <div class="home"> | ||||
|     <b-container v-if="ready"> | ||||
|       <b-row> | ||||
|         <b-col class="total-title"> | ||||
|           <h2>Total login attempts</h2> | ||||
|         </b-col> | ||||
|         <b-col class="total-count"> | ||||
|           <h2>{{ totalLoginAttempts }}</h2> | ||||
|         </b-col> | ||||
|       </b-row> | ||||
|       <b-row> | ||||
|         <b-col class="total-title"> | ||||
|           <h2>Unique passwords</h2> | ||||
|         </b-col> | ||||
|         <b-col class="total-count"> | ||||
|           <h2>{{ uniquePasswords }}</h2> | ||||
|         </b-col> | ||||
|       </b-row> | ||||
|       <b-row> | ||||
|         <b-col class="total-title"> | ||||
|           <h2>Unique usernames</h2> | ||||
|         </b-col> | ||||
|         <b-col class="total-count"> | ||||
|           <h2>{{ uniqueUsernames }}</h2> | ||||
|         </b-col> | ||||
|       </b-row> | ||||
|       <b-row> | ||||
|         <b-col class="total-title"> | ||||
|           <h2>Unique IPs</h2> | ||||
|         </b-col> | ||||
|         <b-col class="total-count"> | ||||
|           <h2>{{ uniqueIPs }}</h2> | ||||
|         </b-col> | ||||
|       </b-row> | ||||
|     </b-container> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Vue, Watch } from 'vue-property-decorator'; | ||||
| import axios from 'axios'; | ||||
|  | ||||
| type TotalStatsHeaders = | ||||
|   | 'UniquePasswords' | ||||
|   | 'UniqueUsernames' | ||||
|   | 'UniqueIPs' | ||||
|   | 'UniqueCountries' | ||||
|   | 'TotalLoginAttempts'; | ||||
|  | ||||
| interface TotalStatsResult { | ||||
|   name: TotalStatsHeaders; | ||||
|   count: number; | ||||
| } | ||||
|  | ||||
| @Component | ||||
| export default class Home extends Vue { | ||||
|   totalLoginAttempts: number; | ||||
|  | ||||
|   uniquePasswords: number; | ||||
|  | ||||
|   uniqueUsernames: number; | ||||
|  | ||||
|   uniqueIPs: number; | ||||
|  | ||||
|   uniqueCountries: number; | ||||
|  | ||||
|   private updaterHandle?: number; | ||||
|  | ||||
|   ready = false; | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
|     this.totalLoginAttempts = 0; | ||||
|     this.uniquePasswords = 0; | ||||
|     this.uniqueUsernames = 0; | ||||
|     this.uniqueIPs = 0; | ||||
|     this.uniqueCountries = 0; | ||||
|   } | ||||
|  | ||||
|   updateStats(): void { | ||||
|     const url = `/api/stats?type=total`; | ||||
|     axios | ||||
|       .get<TotalStatsResult[]>(url) | ||||
|       .then((resp) => { | ||||
|         const totals = resp.data.find((e) => e.name === 'TotalLoginAttempts') | ||||
|           ?.count; | ||||
|         if (totals) { | ||||
|           this.totalLoginAttempts = totals; | ||||
|         } | ||||
|  | ||||
|         const passwords = resp.data.find((e) => e.name === 'UniquePasswords') | ||||
|           ?.count; | ||||
|         if (passwords) { | ||||
|           this.uniquePasswords = passwords; | ||||
|         } | ||||
|  | ||||
|         const usernames = resp.data.find((e) => e.name === 'UniqueUsernames') | ||||
|           ?.count; | ||||
|         if (usernames) { | ||||
|           this.uniqueUsernames = usernames; | ||||
|         } | ||||
|  | ||||
|         const ips = resp.data.find((e) => e.name === 'UniqueIPs')?.count; | ||||
|         if (ips) { | ||||
|           this.uniqueIPs = ips; | ||||
|         } | ||||
|  | ||||
|         const countries = resp.data.find((e) => e.name === 'UniqueCountries') | ||||
|           ?.count; | ||||
|         if (countries) { | ||||
|           this.uniqueCountries = countries; | ||||
|         } | ||||
|  | ||||
|         this.ready = true; | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.log(e); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   created(): void { | ||||
|     this.updateStats(); | ||||
|  | ||||
|     this.updaterHandle = setInterval(() => { | ||||
|       this.updateStats(); | ||||
|     }, 5000); | ||||
|   } | ||||
|  | ||||
|   beforeDestroy(): void { | ||||
|     clearInterval(this.updaterHandle); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
| .home { | ||||
|   text-align: left; | ||||
| } | ||||
| .total-title { | ||||
|   text-align: right; | ||||
| } | ||||
| .total-count { | ||||
|   text-align: left; | ||||
| } | ||||
| </style> | ||||
| @@ -1,81 +0,0 @@ | ||||
| <template> | ||||
|   <div class="search-result-container"> | ||||
|     <h1>Search</h1> | ||||
|     <b-form @submit="onSubmit"> | ||||
|       <b-form-input v-model="searchString" placeholder="" /> | ||||
|     </b-form> | ||||
|     <b-table | ||||
|       class="search-results-table" | ||||
|       responsive="md" | ||||
|       striped | ||||
|       hover | ||||
|       :items="attempts" | ||||
|       :fields="fields" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Vue } from 'vue-property-decorator'; | ||||
| import { LoginAttempt } from '@/apiary/apiary'; | ||||
| import { BvTableFieldArray, BvTableFormatterCallback } from 'bootstrap-vue'; | ||||
| import Axios from 'axios'; | ||||
|  | ||||
| @Component | ||||
| export default class SearchResult extends Vue { | ||||
|   attempts: LoginAttempt[]; | ||||
|  | ||||
|   searchString: string; | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
|     this.attempts = []; | ||||
|     this.searchString = ''; | ||||
|   } | ||||
|  | ||||
|   fields: BvTableFieldArray = [ | ||||
|     { | ||||
|       key: 'date', | ||||
|       sortable: true, | ||||
|       formatter: (value: string): string => { | ||||
|         const d = new Date(value); | ||||
|         // This seems stupid... | ||||
|         return d.toTimeString().split(' ')[0]; | ||||
|       }, | ||||
|       sortByFormatted: false, | ||||
|     }, | ||||
|     { | ||||
|       key: 'username', | ||||
|     }, | ||||
|     { | ||||
|       key: 'password', | ||||
|     }, | ||||
|     { | ||||
|       key: 'remoteIP', | ||||
|       sortable: true, | ||||
|     }, | ||||
|     { | ||||
|       key: 'country', | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   onSubmit(event: Event) { | ||||
|     event.preventDefault(); | ||||
|  | ||||
|     console.log(this.searchString); | ||||
|     const resp = Axios.get<LoginAttempt[]>( | ||||
|       `/api/query?query=${this.searchString}`, | ||||
|     ); | ||||
|  | ||||
|     resp.then((r) => { | ||||
|       this.attempts = r.data; | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .search-results-table { | ||||
|   margin-top: 50px; | ||||
| } | ||||
| </style> | ||||
| @@ -1,52 +0,0 @@ | ||||
| <template> | ||||
|   <div class="stats"> | ||||
|     <h1>Login Stats</h1> | ||||
|     <b-card no-body> | ||||
|       <b-tabs card> | ||||
|         <b-tab title="Totals"> | ||||
|           <b-card-body> | ||||
|             <home></home> | ||||
|           </b-card-body> | ||||
|         </b-tab> | ||||
|         <b-tab title="Usernames"> | ||||
|           <b-card-body> | ||||
|             <stats-pie statType="username" /> | ||||
|           </b-card-body> | ||||
|         </b-tab> | ||||
|         <b-tab title="Passwords"> | ||||
|           <b-card-body> | ||||
|             <stats-pie statType="password"></stats-pie> | ||||
|           </b-card-body> | ||||
|         </b-tab> | ||||
|         <b-tab title="Countries"> | ||||
|           <b-card-body> | ||||
|             <stats-pie statType="country"></stats-pie> | ||||
|           </b-card-body> | ||||
|         </b-tab> | ||||
|         <b-tab title="IPs"> | ||||
|           <b-card-body> | ||||
|             <stats-pie statType="ip"></stats-pie> | ||||
|           </b-card-body> | ||||
|         </b-tab> | ||||
|       </b-tabs> | ||||
|     </b-card> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Prop, Vue } from 'vue-property-decorator'; | ||||
| import StatsUsername from '@/components/StatsUsername.vue'; | ||||
| import StatsPie from '@/components/StatsPie.vue'; | ||||
| import Home from '@/components/Home.vue'; | ||||
|  | ||||
| @Component({ | ||||
|   components: { | ||||
|     StatsUsername, | ||||
|     StatsPie, | ||||
|     Home, | ||||
|   }, | ||||
| }) | ||||
| export default class Stats extends Vue {} | ||||
| </script> | ||||
| <style lang="scss" scoped> | ||||
| </style> | ||||
| @@ -1,187 +0,0 @@ | ||||
| <template> | ||||
|   <div class="stats-container" :class="containerClass"> | ||||
|     <h2> | ||||
|       {{ title() }} | ||||
|       <b-icon :id="chartSettingsID()" icon="gear-fill"></b-icon> | ||||
|     </h2> | ||||
|     <div class="chart-container"> | ||||
|       <canvas :id="chartID()" width="300" height="300"></canvas> | ||||
|     </div> | ||||
|     <b-popover | ||||
|       :target="chartSettingsID()" | ||||
|       triggers="click" | ||||
|       :show.sync="settingsShow" | ||||
|       placement="auto" | ||||
|       container="stats-container" | ||||
|       ref="popover" | ||||
|       @show="onShow" | ||||
|     > | ||||
|       <template #title> | ||||
|         <b-button @click="onClose" class="close" aria-label="Close"> | ||||
|           <span class="d-inline-block" aria-hidden="true">×</span> | ||||
|         </b-button> | ||||
|         Settings | ||||
|       </template> | ||||
|  | ||||
|       <div> | ||||
|         <b-form-group | ||||
|           label="Limit" | ||||
|           label-for="limitinput" | ||||
|           label-cols="3" | ||||
|           class="mb-1" | ||||
|           description="Limit number of items" | ||||
|           invalid-feedback="This field is required" | ||||
|         > | ||||
|           <b-form-input | ||||
|             ref="limitinput" | ||||
|             id="limitinput" | ||||
|             v-model="limitState" | ||||
|             type="number" | ||||
|             size="sm" | ||||
|           ></b-form-input> | ||||
|         </b-form-group> | ||||
|  | ||||
|         <b-button @click="onClose" size="sm" variant="danger">Cancel</b-button> | ||||
|         <b-button @click="onOk" size="sm" variant="primary">Ok</b-button> | ||||
|       </div> | ||||
|     </b-popover> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; | ||||
| import { StatResult } from '@/apiary/apiary'; | ||||
| import axios from 'axios'; | ||||
| import { Chart, ArcElement, Legend, DoughnutController, Title } from 'chart.js'; | ||||
| import randomColor from 'randomcolor'; | ||||
|  | ||||
| export type StatType = 'username' | 'password' | 'ip' | 'country' | 'total'; | ||||
|  | ||||
| Chart.register(ArcElement, Legend, DoughnutController, Title); | ||||
|  | ||||
| @Component | ||||
| export default class StatsPie extends Vue { | ||||
|   @Prop() private statType!: StatType; | ||||
|  | ||||
|   limit: number; | ||||
|  | ||||
|   limitState: number; | ||||
|  | ||||
|   settingsShow = false; | ||||
|  | ||||
|   stats: StatResult[]; | ||||
|  | ||||
|   chart?: Chart; | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
|     this.stats = []; | ||||
|     this.limit = 10; | ||||
|     this.limitState = 10; | ||||
|   } | ||||
|  | ||||
|   title(): string { | ||||
|     switch (this.statType) { | ||||
|       case 'password': | ||||
|         return `Top ${this.limit} Passwords`; | ||||
|       case 'username': | ||||
|         return `Top ${this.limit} Usernames`; | ||||
|       case 'ip': | ||||
|         return `Top ${this.limit} IPs`; | ||||
|       case 'country': | ||||
|         return `Top ${this.limit} Countries`; | ||||
|       case 'total': | ||||
|         return 'Totals'; | ||||
|       // Why doesn't eslint know that this switch is exhaustive? | ||||
|       default: | ||||
|         return 'Top 10 Passwords'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   containerClass(): string { | ||||
|     return `stats-container-${this.statType}`; | ||||
|   } | ||||
|  | ||||
|   chartID(): string { | ||||
|     return `chart-${this.statType}`; | ||||
|   } | ||||
|  | ||||
|   chartSettingsID(): string { | ||||
|     return `chartsettings-${this.statType}`; | ||||
|   } | ||||
|  | ||||
|   settingsID(): string { | ||||
|     return `settings-${this.statType}`; | ||||
|   } | ||||
|  | ||||
|   onClose(): void { | ||||
|     this.settingsShow = false; | ||||
|   } | ||||
|  | ||||
|   onOk(): void { | ||||
|     if (this.limitState) { | ||||
|       this.limit = this.limitState; | ||||
|       this.settingsShow = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onShow(): void { | ||||
|     // This is called just before the popover is shown | ||||
|     // Reset our popover form variables | ||||
|     this.limitState = this.limit; | ||||
|   } | ||||
|  | ||||
|   @Watch('limit') | ||||
|   limitChanged(value: number, oldValue: number): void { | ||||
|     this.fetchData(); | ||||
|   } | ||||
|  | ||||
|   fetchData(): void { | ||||
|     const url = `/api/stats?type=${this.statType}&limit=${this.limit}`; | ||||
|     axios.get<StatResult[]>(url).then((resp) => { | ||||
|       this.stats = resp.data; | ||||
|       this.renderPie(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   mounted(): void { | ||||
|     this.fetchData(); | ||||
|   } | ||||
|  | ||||
|   renderPie(): void { | ||||
|     const elem = document.getElementById(this.chartID()) as HTMLCanvasElement; | ||||
|     const ctx = elem.getContext('2d') as CanvasRenderingContext2D; | ||||
|     const sortedStats = this.stats.sort(); | ||||
|     const values = sortedStats.map((s) => s.count); | ||||
|     const headers = sortedStats.map((s) => s.name); | ||||
|     const colors = sortedStats.map(() => randomColor()); | ||||
|  | ||||
|     if (this.chart) { | ||||
|       this.chart.destroy(); | ||||
|     } | ||||
|     this.chart = new Chart(ctx, { | ||||
|       type: 'doughnut', | ||||
|       data: { | ||||
|         labels: headers, | ||||
|         datasets: [ | ||||
|           { | ||||
|             data: values, | ||||
|             backgroundColor: colors, | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| canvas { | ||||
|   width: 70%; | ||||
|   height: auto; | ||||
|   max-height: 60vmin; | ||||
| } | ||||
| .stats-container { | ||||
|   align-content: center; | ||||
| } | ||||
| </style> | ||||
| @@ -1,67 +0,0 @@ | ||||
| <template> | ||||
|   <div class="stats-container"> | ||||
|     <h2>Top 10 usernames</h2> | ||||
|     <div class="chart-container"> | ||||
|       <canvas id="chart" widht="400" height="400"></canvas> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { Component, Prop, Vue } from 'vue-property-decorator'; | ||||
| import { StatResult } from '@/apiary/apiary'; | ||||
| import axios from 'axios'; | ||||
| import Chart from 'chart.js/auto'; | ||||
| import randomColor from 'randomcolor'; | ||||
|  | ||||
| @Component | ||||
| export default class StatsUsername extends Vue { | ||||
|   @Prop() private msg!: string; | ||||
|  | ||||
|   stats: StatResult[]; | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
|     this.stats = []; | ||||
|   } | ||||
|  | ||||
|   mounted(): void { | ||||
|     axios | ||||
|       .get<StatResult[]>('/api/stats?type=username&limit=10') | ||||
|       .then((resp) => { | ||||
|         this.stats = resp.data; | ||||
|         this.renderPie(); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   renderPie(): void { | ||||
|     const elem = document.getElementById('chart') as HTMLCanvasElement; | ||||
|     const ctx = elem.getContext('2d') as CanvasRenderingContext2D; | ||||
|     const sortedStats = this.stats.sort(); | ||||
|     const values = sortedStats.map((s) => s.count); | ||||
|     const headers = sortedStats.map((s) => s.name); | ||||
|     const colors = sortedStats.map(() => randomColor()); | ||||
|  | ||||
|     const chart = new Chart(ctx, { | ||||
|       type: 'doughnut', | ||||
|       data: { | ||||
|         labels: headers, | ||||
|         options: {}, | ||||
|         datasets: [ | ||||
|           { | ||||
|             data: values, | ||||
|             backgroundColor: colors, | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .chart-container { | ||||
|   max-width: 500px; | ||||
|   max-height: 500px; | ||||
| } | ||||
| </style> | ||||
| @@ -1,58 +0,0 @@ | ||||
| import Vue from 'vue'; | ||||
| import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'; | ||||
| import VueRouter, { RouteConfig } from 'vue-router'; | ||||
| import AttemptList from '@/components/AttemptList.vue'; | ||||
| import Home from '@/components/Home.vue'; | ||||
| import Stats from '@/components/Stats.vue'; | ||||
| import SearchResult from '@/components/SearchResult.vue'; | ||||
| import 'bootstrap/dist/css/bootstrap.css'; | ||||
| import 'bootstrap-vue/dist/bootstrap-vue.css'; | ||||
| import '@fontsource/rubik'; | ||||
| import '@fontsource/secular-one'; | ||||
| import App from './App.vue'; | ||||
|  | ||||
| Vue.config.productionTip = false; | ||||
| // Make BootstrapVue available throughout your project | ||||
| Vue.use(BootstrapVue); | ||||
| // Optionally install the BootstrapVue icon components plugin | ||||
| Vue.use(IconsPlugin); | ||||
|  | ||||
| Vue.use(VueRouter); | ||||
|  | ||||
| const routes: Array<RouteConfig> = [ | ||||
|   { | ||||
|     path: '/', | ||||
|     name: 'Home', | ||||
|     component: Home, | ||||
|     alias: '/home', | ||||
|   }, | ||||
|   { | ||||
|     path: '/attempts', | ||||
|     name: 'Attempt List', | ||||
|     component: AttemptList, | ||||
|     props: { | ||||
|       // items: testAttempts, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     path: '/stats', | ||||
|     name: 'Stats', | ||||
|     component: Stats, | ||||
|   }, | ||||
|   { | ||||
|     path: '/search', | ||||
|     name: 'Search', | ||||
|     component: SearchResult, | ||||
|   } | ||||
| ]; | ||||
|  | ||||
| const router = new VueRouter({ | ||||
|   mode: 'history', | ||||
|   base: process.env.BASE_URL, | ||||
|   routes, | ||||
| }); | ||||
|  | ||||
| new Vue({ | ||||
|   router, | ||||
|   render: (h) => h(App), | ||||
| }).$mount('#app'); | ||||
							
								
								
									
										13
									
								
								web/frontend/src/shims-tsx.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								web/frontend/src/shims-tsx.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,13 +0,0 @@ | ||||
| import Vue, { VNode } from 'vue'; | ||||
|  | ||||
| declare global { | ||||
|   namespace JSX { | ||||
|     // tslint:disable no-empty-interface | ||||
|     interface Element extends VNode {} | ||||
|     // tslint:disable no-empty-interface | ||||
|     interface ElementClass extends Vue {} | ||||
|     interface IntrinsicElements { | ||||
|       [elem: string]: any | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										5
									
								
								web/frontend/src/shims-vue.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								web/frontend/src/shims-vue.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +0,0 @@ | ||||
| declare module '*.vue' { | ||||
|   import Vue from 'vue'; | ||||
|  | ||||
|   export default Vue; | ||||
| } | ||||
| @@ -1,40 +0,0 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "esnext", | ||||
|     "module": "esnext", | ||||
|     "strict": true, | ||||
|     "jsx": "preserve", | ||||
|     "importHelpers": true, | ||||
|     "moduleResolution": "node", | ||||
|     "experimentalDecorators": true, | ||||
|     "skipLibCheck": true, | ||||
|     "esModuleInterop": true, | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "sourceMap": true, | ||||
|     "baseUrl": ".", | ||||
|     "types": [ | ||||
|       "webpack-env" | ||||
|     ], | ||||
|     "paths": { | ||||
|       "@/*": [ | ||||
|         "src/*" | ||||
|       ] | ||||
|     }, | ||||
|     "lib": [ | ||||
|       "esnext", | ||||
|       "dom", | ||||
|       "dom.iterable", | ||||
|       "scripthost" | ||||
|     ] | ||||
|   }, | ||||
|   "include": [ | ||||
|     "src/**/*.ts", | ||||
|     "src/**/*.tsx", | ||||
|     "src/**/*.vue", | ||||
|     "tests/**/*.ts", | ||||
|     "tests/**/*.tsx" | ||||
|   ], | ||||
|   "exclude": [ | ||||
|     "node_modules" | ||||
|   ] | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user