diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css new file mode 100644 index 0000000..5542a15 --- /dev/null +++ b/app/assets/builds/tailwind.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.1.16 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-800:oklch(43.8% .218 303.724);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse\!{visibility:collapse!important}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-20{top:calc(var(--spacing)*20)}.right-0{right:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.z-0{z-index:0}.z-50{z-index:50}.col-12{grid-column:12}.col-span-6{grid-column:span 6/span 6}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-5{margin-block:calc(var(--spacing)*5)}.my-6{margin-block:calc(var(--spacing)*6)}.ms-2{margin-inline-start:calc(var(--spacing)*2)}.me-2{margin-inline-end:calc(var(--spacing)*2)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-5{margin-top:calc(var(--spacing)*5)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-3{margin-right:calc(var(--spacing)*3)}.mb-0{margin-bottom:calc(var(--spacing)*0)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-5{margin-bottom:calc(var(--spacing)*5)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-10{margin-left:calc(var(--spacing)*10)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-8{height:calc(var(--spacing)*8)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-16{height:calc(var(--spacing)*16)}.max-h-64{max-height:calc(var(--spacing)*64)}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing)*0)}.w-2{width:calc(var(--spacing)*2)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[120px\]{max-width:120px}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing)*2)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing)*4)}:where(.-space-x-px>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(-1px*var(--tw-space-x-reverse));margin-inline-end:calc(-1px*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*3)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-6>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*6)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-x-reverse)))}.gap-y-6{row-gap:calc(var(--spacing)*6)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-500{border-color:var(--color-blue-500)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-transparent{border-color:#0000}.border-yellow-300{border-color:var(--color-yellow-300)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-200{background-color:var(--color-orange-200)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-200{background-color:var(--color-purple-200)}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-600{background-color:var(--color-purple-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-200{background-color:var(--color-red-200)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-500{background-color:var(--color-yellow-500)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-3\.5{padding-inline:calc(var(--spacing)*3.5)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-5{padding-block:calc(var(--spacing)*5)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-5{padding-top:calc(var(--spacing)*5)}.pt-6{padding-top:calc(var(--spacing)*6)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pl-5{padding-left:calc(var(--spacing)*5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-6{--tw-leading:calc(var(--spacing)*6);line-height:calc(var(--spacing)*6)}.leading-7{--tw-leading:calc(var(--spacing)*7);line-height:calc(var(--spacing)*7)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-800{color:var(--color-indigo-800)}.text-orange-400{color:var(--color-orange-400)}.text-orange-800{color:var(--color-orange-800)}.text-purple-400{color:var(--color-purple-400)}.text-purple-600{color:var(--color-purple-600)}.text-purple-800{color:var(--color-purple-800)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-white{color:var(--color-white)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.text-yellow-800{color:var(--color-yellow-800)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-black{--tw-ring-color:var(--color-black)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}@media (hover:hover){.hover\:bg-blue-200:hover{background-color:var(--color-blue-200)}.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-700:hover{background-color:var(--color-gray-700)}.hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.hover\:bg-green-50:hover{background-color:var(--color-green-50)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-green-500:hover{background-color:var(--color-green-500)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-purple-700:hover{background-color:var(--color-purple-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-yellow-100:hover{background-color:var(--color-yellow-100)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-blue-800:hover{color:var(--color-blue-800)}.hover\:text-blue-900:hover{color:var(--color-blue-900)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-900:hover{color:var(--color-green-900)}.hover\:text-indigo-900:hover{color:var(--color-indigo-900)}.hover\:text-red-900:hover{color:var(--color-red-900)}.hover\:text-white:hover{color:var(--color-white)}.hover\:text-yellow-900:hover{color:var(--color-yellow-900)}.hover\:no-underline:hover{text-decoration-line:none}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:ring:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-200:focus{--tw-ring-color:var(--color-blue-200)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-gray-500:focus{--tw-ring-color:var(--color-gray-500)}.focus\:ring-green-500:focus{--tw-ring-color:var(--color-green-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-purple-500:focus{--tw-ring-color:var(--color-purple-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-blue-600:focus{outline-color:var(--color-blue-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-6{grid-column:span 6/span 6}.sm\:mt-0{margin-top:calc(var(--spacing)*0)}.sm\:flex{display:flex}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-auto{width:auto}.sm\:flex-1{flex:1}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-4{gap:calc(var(--spacing)*4)}.sm\:truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:rounded-md{border-radius:var(--radius-md)}.sm\:p-6{padding:calc(var(--spacing)*6)}.sm\:px-6{padding-inline:calc(var(--spacing)*6)}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:mt-0{margin-top:calc(var(--spacing)*0)}.md\:ml-4{margin-left:calc(var(--spacing)*4)}.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-2\/3{width:66.6667%}.md\:w-4\/5{width:80%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing)*8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}} \ No newline at end of file diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb new file mode 100644 index 0000000..e9b8135 --- /dev/null +++ b/app/controllers/analytics_controller.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +# AnalyticsController - Overview dashboard with statistics and charts +class AnalyticsController < ApplicationController + # All actions require authentication + + def index + authorize :analytics, :index? + + # Time period selector (default: last 24 hours) + @time_period = params[:period]&.to_sym || :day + @start_time = calculate_start_time(@time_period) + + # Core statistics + @total_events = Event.where("timestamp >= ?", @start_time).count + @total_rules = Rule.enabled.count + @network_ranges_with_events = NetworkRange.with_events.count + @total_network_ranges = NetworkRange.count + + # Event breakdown by action + @event_breakdown = Event.where("timestamp >= ?", @start_time) + .group(:waf_action) + .count + .transform_keys do |action_id| + case action_id + when 0 then 'allow' + when 1 then 'deny' + when 2 then 'redirect' + when 3 then 'challenge' + else 'unknown' + end + end + + # Top countries by event count + @top_countries = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") + .where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time) + .group("network_ranges.country") + .count + .sort_by { |_, count| -count } + .first(10) + + # Top blocked IPs + @top_blocked_ips = Event.where("timestamp >= ?", @start_time) + .where(waf_action: 1) # deny action in enum + .group(:ip_address) + .count + .sort_by { |_, count| -count } + .first(10) + + # Network range intelligence breakdown + @network_intelligence = { + datacenter_ranges: NetworkRange.datacenter.count, + vpn_ranges: NetworkRange.vpn.count, + proxy_ranges: NetworkRange.proxy.count, + total_ranges: NetworkRange.count + } + + # Recent activity + @recent_events = Event.recent.limit(10) + @recent_rules = Rule.order(created_at: :desc).limit(5) + + # System health indicators + @system_health = { + total_users: User.count, + active_rules: Rule.enabled.count, + disabled_rules: Rule.where(enabled: false).count, + recent_errors: Event.where("timestamp >= ? AND waf_action = ?", @start_time, 1).count # 1 = deny + } + + # Prepare data for charts + @chart_data = prepare_chart_data + + respond_to do |format| + format.html + format.turbo_stream + end + end + + private + + def calculate_start_time(period) + case period + when :hour + 1.hour.ago + when :day + 24.hours.ago + when :week + 1.week.ago + when :month + 1.month.ago + else + 24.hours.ago + end + end + + def prepare_chart_data + # Events over time (hourly buckets for last 24 hours) + events_by_hour = Event.where("timestamp >= ?", 24.hours.ago) + .group("DATE_TRUNC('hour', timestamp)") + .count + + # Convert to chart format + timeline_data = (0..23).map do |hour_ago| + hour_time = hour_ago.hours.ago + hour_key = hour_time.strftime("%Y-%m-%d %H:00:00") + { + time: hour_time.strftime("%H:00"), + total: events_by_hour[hour_key] || 0 + } + end.reverse + + # Action distribution for pie chart + action_distribution = @event_breakdown.map do |action, count| + { + action: action.humanize, + count: count, + percentage: ((count.to_f / [@total_events, 1].max) * 100).round(1) + } + end + + { + timeline: timeline_data, + actions: action_distribution, + countries: @top_countries.map { |country, count| { country: country, count: count } }, + network_types: [ + { type: "Datacenter", count: @network_intelligence[:datacenter_ranges] }, + { type: "VPN", count: @network_intelligence[:vpn_ranges] }, + { type: "Proxy", count: @network_intelligence[:proxy_ranges] }, + { type: "Standard", count: @network_intelligence[:total_ranges] - @network_intelligence[:datacenter_ranges] - @network_intelligence[:vpn_ranges] - @network_intelligence[:proxy_ranges] } + ] + } + end +end \ No newline at end of file diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb index f1a900b..e76a329 100644 --- a/app/controllers/api/events_controller.rb +++ b/app/controllers/api/events_controller.rb @@ -4,17 +4,16 @@ class Api::EventsController < ApplicationController skip_before_action :verify_authenticity_token allow_unauthenticated_access # Skip normal session auth, use DSN auth instead - # POST /api/:project_id/events + # POST /api/events def create - project = authenticate_project! - return head :not_found unless project + dsn = authenticate_dsn! + return head :not_found unless dsn # Parse the incoming WAF event data event_data = parse_event_data(request) # Create event asynchronously ProcessWafEventJob.perform_later( - project_id: project.id, event_data: event_data, headers: extract_serializable_headers(request) ) @@ -64,8 +63,8 @@ class Api::EventsController < ApplicationController private - def authenticate_project! - DsnAuthenticationService.authenticate(request, params[:project_id]) + def authenticate_dsn! + DsnAuthenticationService.authenticate(request) end def parse_event_data(request) diff --git a/app/controllers/api/rules_controller.rb b/app/controllers/api/rules_controller.rb index 77598ec..c1c0964 100644 --- a/app/controllers/api/rules_controller.rb +++ b/app/controllers/api/rules_controller.rb @@ -7,11 +7,11 @@ module Api # These endpoints are kept for administrative/debugging purposes only skip_before_action :verify_authenticity_token - allow_unauthenticated_access # Skip normal session auth, use project key auth instead - before_action :authenticate_project! - before_action :check_project_enabled + allow_unauthenticated_access # Skip normal session auth, use DSN auth instead + before_action :authenticate_dsn! + before_action :check_dsn_enabled - # GET /api/:public_key/rules/version + # GET /api/rules/version # Quick version check - returns latest updated_at timestamp def version current_sampling = HubLoad.current_sampling @@ -24,9 +24,9 @@ module Api } end - # GET /api/:public_key/rules?since=1730646186 + # GET /api/rules?since=1730646186 # Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds) - # GET /api/:public_key/rules + # GET /api/rules # Full sync - returns all active rules def index rules = if params[:since].present? @@ -52,20 +52,20 @@ module Api private - def authenticate_project! - public_key = params[:public_key] || params[:project_id] + def authenticate_dsn! + @dsn = DsnAuthenticationService.authenticate(request) - @project = Project.find_by(public_key: public_key) - - unless @project - render json: { error: "Invalid project key" }, status: :unauthorized + unless @dsn + render json: { error: "Invalid DSN key" }, status: :unauthorized return end + rescue DsnAuthenticationService::AuthenticationError => e + render json: { error: e.message }, status: :unauthorized end - def check_project_enabled - unless @project.enabled? - render json: { error: "Project is disabled" }, status: :forbidden + def check_dsn_enabled + unless @dsn.enabled? + render json: { error: "DSN is disabled" }, status: :forbidden end end diff --git a/app/controllers/dsns_controller.rb b/app/controllers/dsns_controller.rb new file mode 100644 index 0000000..6704a60 --- /dev/null +++ b/app/controllers/dsns_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class DsnsController < ApplicationController + before_action :require_authentication + before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable] + before_action :authorize_dsn_management, except: [:index, :show] + + # GET /dsns + def index + @dsns = policy_scope(Dsn).order(created_at: :desc) + + # Generate environment DSNs using default DSN key or first enabled DSN + default_dsn = Dsn.enabled.first + if default_dsn + @external_dsn = generate_external_dsn(default_dsn.key) + @internal_dsn = generate_internal_dsn(default_dsn.key) + end + end + + # GET /dsns/new + def new + authorize Dsn + @dsn = Dsn.new + end + + # POST /dsns + def create + authorize Dsn + @dsn = Dsn.new(dsn_params) + + if @dsn.save + redirect_to @dsn, notice: 'DSN was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + # GET /dsns/:id + def show + end + + # GET /dsns/:id/edit + def edit + end + + # PATCH/PUT /dsns/:id + def update + if @dsn.update(dsn_params) + redirect_to @dsn, notice: 'DSN was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + # POST /dsns/:id/disable + def disable + @dsn.update!(enabled: false) + redirect_to @dsn, notice: 'DSN was disabled.' + end + + # POST /dsns/:id/enable + def enable + @dsn.update!(enabled: true) + redirect_to @dsn, notice: 'DSN was enabled.' + end + + private + + def set_dsn + @dsn = Dsn.find(params[:id]) + end + + def dsn_params + params.require(:dsn).permit(:name, :enabled) + end + + def authorize_dsn_management + # Only allow admins to manage DSNs + redirect_to root_path, alert: 'Access denied' unless Current.user&.admin? + end + + def generate_external_dsn(key) + host = ENV.fetch("BAFFLE_HOST", "localhost:3000") + protocol = host.include?("localhost") ? "http" : "https" + "#{protocol}://#{key}@#{host}" + end + + def generate_internal_dsn(key) + internal_host = ENV.fetch("BAFFLE_INTERNAL_HOST", nil) + return nil unless internal_host.present? + + protocol = "http" # Internal connections use HTTP + "#{protocol}://#{key}@#{internal_host}" + end +end \ No newline at end of file diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index bf7b229..b2602f9 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,21 +1,20 @@ # frozen_string_literal: true class EventsController < ApplicationController - before_action :set_project - def index - @events = @project.events.order(timestamp: :desc) - Rails.logger.debug "Found project? #{@project.name} / #{@project.events.count} / #{@events.count}" + @events = Event.order(timestamp: :desc) + Rails.logger.debug "Found #{@events.count} total events" Rails.logger.debug "Action: #{params[:waf_action]}" + # Apply filters @events = @events.by_ip(params[:ip]) if params[:ip].present? @events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present? @events = @events.where(country_code: params[:country]) if params[:country].present? - Rails.logger.debug "after filter #{@project.name} / #{@project.events.count} / #{@events.count}" + Rails.logger.debug "Events count after filtering: #{@events.count}" + # Debug info Rails.logger.debug "Events count before pagination: #{@events.count}" - Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})" # Paginate @pagy, @events = pagy(@events, items: 50) @@ -23,11 +22,4 @@ class EventsController < ApplicationController Rails.logger.debug "Events count after pagination: #{@events.count}" Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages" end - - private - - def set_project - @project = Project.find(params[:project_id]) || Project.find_by(slug: params[:project_id]) - redirect_to projects_path, alert: "Project not found" unless @project - end end \ No newline at end of file diff --git a/app/controllers/network_ranges_controller.rb b/app/controllers/network_ranges_controller.rb index 6ecb8d2..4d91482 100644 --- a/app/controllers/network_ranges_controller.rb +++ b/app/controllers/network_ranges_controller.rb @@ -7,11 +7,10 @@ class NetworkRangesController < ApplicationController # Follow proper before_action order: # 1. Authentication/Authorization - allow_unauthenticated_access only: [:index, :show, :lookup] + # All actions require authentication # 2. Resource loading before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich] - before_action :set_project, only: [:index, :show] # GET /network_ranges def index @@ -158,15 +157,6 @@ class NetworkRangesController < ApplicationController @network_range = NetworkRange.find_by!(network: cidr) end - def set_project - # For now, use the first project or create a default one - @project = Project.first || Project.create!( - name: 'Default Project', - slug: 'default', - public_key: SecureRandom.hex(32) - ) - end - def network_range_params params.require(:network_range).permit( :network, @@ -204,18 +194,33 @@ class NetworkRangesController < ApplicationController end def calculate_traffic_stats(network_range) - # Calculate traffic statistics for this network range - events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") - .where("network_ranges.id = ?", network_range.id) + # Use the cached events_count for total requests (much more performant) + # For detailed breakdown, we still need to query but we can optimize with a limit + if network_range.events_count > 0 + events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") + .where("network_ranges.id = ?", network_range.id) + .limit(1000) # Limit the sample for performance - { - total_requests: events.count, - unique_ips: events.distinct.count(:ip_address), - blocked_requests: events.blocked.count, - allowed_requests: events.allowed.count, - top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10), - top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5), - recent_activity: events.recent.limit(20) - } + { + total_requests: network_range.events_count, # Use cached count + unique_ips: events.distinct.count(:ip_address), + blocked_requests: events.blocked.count, + allowed_requests: events.allowed.count, + top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10), + top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5), + recent_activity: events.recent.limit(20) + } + else + # No events - return empty stats + { + total_requests: 0, + unique_ips: 0, + blocked_requests: 0, + allowed_requests: 0, + top_paths: {}, + top_user_agents: {}, + recent_activity: [] + } + end end end \ No newline at end of file diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb deleted file mode 100644 index 1770c75..0000000 --- a/app/controllers/projects_controller.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -class ProjectsController < ApplicationController - before_action :set_project, only: [:show, :edit, :update, :events, :analytics] - - def index - @projects = Project.order(created_at: :desc) - end - - def show - @recent_events = @project.recent_events(limit: 10) - @event_count = @project.event_count(24.hours.ago) - @blocked_count = @project.blocked_count(24.hours.ago) - @waf_status = @project.waf_status - end - - def new - @project = Project.new - end - - def create - @project = Project.new(project_params) - - if @project.save - redirect_to @project, notice: "Project was successfully created. Use this DSN for your baffle-agent: #{@project.dsn}" - else - render :new, status: :unprocessable_entity - end - end - - def edit - end - - def update - if @project.update(project_params) - redirect_to @project, notice: "Project was successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - def events - @events = @project.events.recent.includes(:project) - - # Apply filters - @events = @events.by_ip(params[:ip]) if params[:ip].present? - @events = @events.by_waf_action(params[:action]) if params[:action].present? - @events = @events.where(country_code: params[:country]) if params[:country].present? - - # Debug info - Rails.logger.debug "Events count before pagination: #{@events.count}" - Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})" - - # Paginate - @pagy, @events = pagy(@events, items: 50) - - Rails.logger.debug "Events count after pagination: #{@events.count}" - Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages" - end - - def analytics - @time_range = params[:time_range]&.to_i || 24 # hours - - # Basic analytics - @total_events = @project.event_count(@time_range.hours.ago) - @blocked_events = @project.blocked_count(@time_range.hours.ago) - @allowed_events = @project.allowed_count(@time_range.hours.ago) - - # Top blocked IPs - @top_blocked_ips = @project.top_blocked_ips(limit: 10, time_range: @time_range.hours.ago) - - # Country distribution - @country_stats = @project.events - .where(timestamp: @time_range.hours.ago..Time.current) - .where.not(country_code: nil) - .group(:country_code) - .select('country_code, COUNT(*) as count') - .order('count DESC') - .limit(10) - - # Action distribution - @action_stats = @project.events - .where(timestamp: @time_range.hours.ago..Time.current) - .group(:waf_action) - .select('waf_action as action, COUNT(*) as count') - .order('count DESC') - end - - private - - def set_project - @project = Project.find_by(slug: params[:id]) || Project.find_by(id: params[:id]) - redirect_to projects_path, alert: "Project not found" unless @project - end - - def project_params - params.require(:project).permit(:name, :enabled, settings: {}) - end -end \ No newline at end of file diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 8f2ae50..76395b4 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,4 +1,5 @@ class RegistrationsController < ApplicationController + layout "authentication" allow_unauthenticated_access only: [:new, :create] before_action :ensure_no_users_exist, only: [:new, :create] diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index edb81f2..61d2068 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -3,15 +3,14 @@ class RulesController < ApplicationController # Follow proper before_action order: # 1. Authentication/Authorization - allow_unauthenticated_access only: [:index, :show] + # All actions require authentication # 2. Resource loading before_action :set_rule, only: [:show, :edit, :update, :disable, :enable] - before_action :set_project, only: [:index, :show] # GET /rules def index - @rules = policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc) + @pagy, @rules = pagy(policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc)) @rule_types = Rule::RULE_TYPES @actions = Rule::ACTIONS end @@ -43,6 +42,9 @@ class RulesController < ApplicationController @rule_types = Rule::RULE_TYPES @actions = Rule::ACTIONS + # Process additional form data for quick create + process_quick_create_parameters + # Handle network range creation if CIDR is provided if params[:cidr].present? && @rule.network_rule? network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range| @@ -53,8 +55,17 @@ class RulesController < ApplicationController @rule.network_range = network_range end + # Calculate priority automatically based on rule type + calculate_rule_priority + if @rule.save - redirect_to @rule, notice: 'Rule was successfully created.' + # For quick create from NetworkRange page, redirect back to network range + if params[:rule][:network_range_id].present? && request.referer&.include?('/network_ranges/') + network_range = NetworkRange.find(params[:rule][:network_range_id]) + redirect_to network_range, notice: 'Rule was successfully created.' + else + redirect_to @rule, notice: 'Rule was successfully created.' + end else render :new, status: :unprocessable_entity end @@ -122,13 +133,236 @@ class RulesController < ApplicationController params.require(:rule).permit(permitted) end - def set_project - # For now, use the first project or create a default one - @project = Project.first || Project.create!( - name: 'Default Project', - slug: 'default', - public_key: SecureRandom.hex(32) - ) +def calculate_rule_priority + return unless @rule + + case @rule.rule_type + when 'network' + # For network rules, priority based on prefix specificity + if @rule.network_range + prefix = @rule.network_range.prefix_length + @rule.priority = case prefix + when 32 then 200 # /32 single IP + when 31 then 190 + when 30 then 180 + when 29 then 170 + when 28 then 160 + when 27 then 150 + when 26 then 140 + when 25 then 130 + when 24 then 120 + when 23 then 110 + when 22 then 100 + when 21 then 90 + when 20 then 80 + when 19 then 70 + when 18 then 60 + when 17 then 50 + when 16 then 40 + when 15 then 30 + when 14 then 20 + when 13 then 10 + else 0 + end + else + @rule.priority = 100 # Default for network rules without range + end + when 'protocol_violation' + @rule.priority = 95 + when 'method_enforcement' + @rule.priority = 90 + when 'path_pattern' + @rule.priority = 85 + when 'header_pattern', 'query_pattern' + @rule.priority = 80 + when 'body_signature' + @rule.priority = 75 + when 'rate_limit' + @rule.priority = 70 + when 'composite' + @rule.priority = 65 + else + @rule.priority = 50 # Default priority + end +end + +def process_quick_create_parameters + return unless @rule + + # Handle rate limiting parameters + if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present? + rate_limit_data = { + limit: params[:rate_limit].to_i, + window_seconds: params[:rate_window].to_i, + scope: 'per_ip' + } + + # Update conditions with rate limit data + @rule.conditions ||= {} + @rule.conditions.merge!(rate_limit_data) end - end \ No newline at end of file + # Handle redirect URL + if @rule.action == 'redirect' && params[:redirect_url].present? + @rule.metadata ||= {} + if @rule.metadata.is_a?(String) + begin + @rule.metadata = JSON.parse(@rule.metadata) + rescue JSON::ParserError + @rule.metadata = {} + end + end + @rule.metadata.merge!({ + redirect_url: params[:redirect_url], + redirect_status: 302 + }) + end + + # Parse metadata if it's a string that looks like JSON + if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{') + begin + @rule.metadata = JSON.parse(@rule.metadata) + rescue JSON::ParserError + # Keep as string if not valid JSON + end + end + + # Add reason to metadata if provided + if params.dig(:rule, :metadata).present? + if @rule.metadata.is_a?(Hash) + @rule.metadata['reason'] = params[:rule][:metadata] + else + @rule.metadata = { 'reason' => params[:rule][:metadata] } + end + end +end + + private + + def set_rule + @rule = Rule.find(params[:id]) + end + + def rule_params + permitted = [ + :rule_type, + :action, + :metadata, + :expires_at, + :enabled, + :source, + :network_range_id + ] + + # Only include conditions for non-network rules + if params[:rule][:rule_type] != 'network' + permitted << :conditions + end + + params.require(:rule).permit(permitted) + end + + def calculate_rule_priority + return unless @rule + + case @rule.rule_type + when 'network' + # For network rules, priority based on prefix specificity + if @rule.network_range + prefix = @rule.network_range.prefix_length + @rule.priority = case prefix + when 32 then 200 # /32 single IP + when 31 then 190 + when 30 then 180 + when 29 then 170 + when 28 then 160 + when 27 then 150 + when 26 then 140 + when 25 then 130 + when 24 then 120 + when 23 then 110 + when 22 then 100 + when 21 then 90 + when 20 then 80 + when 19 then 70 + when 18 then 60 + when 17 then 50 + when 16 then 40 + when 15 then 30 + when 14 then 20 + when 13 then 10 + else 0 + end + else + @rule.priority = 100 # Default for network rules without range + end + when 'protocol_violation' + @rule.priority = 95 + when 'method_enforcement' + @rule.priority = 90 + when 'path_pattern' + @rule.priority = 85 + when 'header_pattern', 'query_pattern' + @rule.priority = 80 + when 'body_signature' + @rule.priority = 75 + when 'rate_limit' + @rule.priority = 70 + when 'composite' + @rule.priority = 65 + else + @rule.priority = 50 # Default priority + end + end + + def process_quick_create_parameters + return unless @rule + + # Handle rate limiting parameters + if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present? + rate_limit_data = { + limit: params[:rate_limit].to_i, + window_seconds: params[:rate_window].to_i, + scope: 'per_ip' + } + + # Update conditions with rate limit data + @rule.conditions ||= {} + @rule.conditions.merge!(rate_limit_data) + end + + # Handle redirect URL + if @rule.action == 'redirect' && params[:redirect_url].present? + @rule.metadata ||= {} + if @rule.metadata.is_a?(String) + begin + @rule.metadata = JSON.parse(@rule.metadata) + rescue JSON::ParserError + @rule.metadata = {} + end + end + @rule.metadata.merge!({ + redirect_url: params[:redirect_url], + redirect_status: 302 + }) + end + + # Parse metadata if it's a string that looks like JSON + if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{') + begin + @rule.metadata = JSON.parse(@rule.metadata) + rescue JSON::ParserError + # Keep as string if not valid JSON + end + end + + # Add reason to metadata if provided + if params.dig(:rule, :metadata).present? + if @rule.metadata.is_a?(Hash) + @rule.metadata['reason'] = params[:rule][:metadata] + else + @rule.metadata = { 'reason' => params[:rule][:metadata] } + end + end + end +end \ No newline at end of file diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 903e444..7b040bb 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,5 @@ class SessionsController < ApplicationController + layout "authentication" allow_unauthenticated_access only: %i[ new create ] rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 76e3d63..671a85e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,3 +1,92 @@ module ApplicationHelper include Pagy::Frontend if defined?(Pagy) + + # Helper method for time period selector styling + def time_period_class(period) + base_classes = "px-4 py-2 text-sm font-medium border-r border-gray-300 last:border-r-0" + + if @time_period == period + base_classes + " bg-blue-600 text-white" + else + base_classes + " text-gray-700 hover:bg-gray-50" + end + end + + # Custom pagination with Tailwind CSS styling + def pagy_nav_tailwind(pagy, pagy_id: nil) + return '' if pagy.pages <= 1 + + html = '' + + raw html + end end diff --git a/app/helpers/navigation_helper.rb b/app/helpers/navigation_helper.rb new file mode 100644 index 0000000..3110257 --- /dev/null +++ b/app/helpers/navigation_helper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module NavigationHelper + def nav_link_class(path) + current = request.path == path || (path == root_path && request.path == events_path && !request.path.include?('/network_ranges') && !request.path.include?('/rules')) + + if current + "bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium" + else + "text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" + end + end + + def mobile_nav_link_class(path) + current = request.path == path || (path == root_path && request.path == events_path && !request.path.include?('/network_ranges') && !request.path.include?('/rules')) + + if current + "bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium" + else + "text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium transition-colors" + end + end + + def time_period_class(period) + base_class = "px-4 py-2 text-sm font-medium border-r last:border-r-0 transition-colors" + + if @time_period == period + base_class + " bg-blue-600 text-white" + else + base_class + " bg-white text-gray-700 hover:bg-gray-50" + end + end +end \ No newline at end of file diff --git a/app/javascript/controllers/dashboard_controller.js b/app/javascript/controllers/dashboard_controller.js new file mode 100644 index 0000000..78c940a --- /dev/null +++ b/app/javascript/controllers/dashboard_controller.js @@ -0,0 +1,51 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["eventsCount", "rulesCount", "networkRangesCount", "systemHealth", "recentEvents", "topBlockedIps"] + static values = { + period: String, + refreshInterval: { type: Number, default: 30000 } // 30 seconds + } + + connect() { + this.startRefreshing() + } + + disconnect() { + this.stopRefreshing() + } + + startRefreshing() { + this.refreshTimer = setInterval(() => { + this.refreshDashboard() + }, this.refreshIntervalValue) + } + + stopRefreshing() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer) + } + } + + async refreshDashboard() { + try { + const response = await fetch(`/analytics?period=${this.periodValue}`, { + headers: { + "Accept": "text/vnd.turbo-stream.html" + } + }) + + if (response.ok) { + const html = await response.text() + Turbo.renderStreamMessage(html) + } + } catch (error) { + console.error("Failed to refresh dashboard:", error) + } + } + + periodChanged(event) { + this.periodValue = event.currentTarget.dataset.period + this.refreshDashboard() + } +} \ No newline at end of file diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000..5fc0d61 --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["menu"] + + connect() { + // Add click outside listener to close dropdown + this.boundHide = this.hide.bind(this) + document.addEventListener("click", this.boundHide) + } + + disconnect() { + document.removeEventListener("click", this.boundHide) + } + + toggle(event) { + event.preventDefault() + event.stopPropagation() + + this.menuTarget.classList.toggle("hidden") + } + + hide(event) { + // Don't hide if clicking inside the dropdown + if (this.element.contains(event.target)) return + + this.menuTarget.classList.add("hidden") + } +} \ No newline at end of file diff --git a/app/javascript/controllers/mobile_menu_controller.js b/app/javascript/controllers/mobile_menu_controller.js new file mode 100644 index 0000000..8724bea --- /dev/null +++ b/app/javascript/controllers/mobile_menu_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["menu", "open", "close"] + + toggle(event) { + event.preventDefault() + + const menu = this.menuTarget + const openIcon = this.openTarget + const closeIcon = this.closeTarget + + menu.classList.toggle("hidden") + openIcon.classList.toggle("hidden") + closeIcon.classList.toggle("hidden") + } +} \ No newline at end of file diff --git a/app/jobs/event_normalization_job.rb b/app/jobs/event_normalization_job.rb index 1f83dd2..1de7dd4 100644 --- a/app/jobs/event_normalization_job.rb +++ b/app/jobs/event_normalization_job.rb @@ -15,7 +15,6 @@ class EventNormalizationJob < ApplicationJob events = Event.where(request_host_id: nil) .limit(batch_size) .offset(offset) - .includes(:project) break if events.empty? diff --git a/app/jobs/generate_waf_rules_job.rb b/app/jobs/generate_waf_rules_job.rb index 07eec5a..1364da1 100644 --- a/app/jobs/generate_waf_rules_job.rb +++ b/app/jobs/generate_waf_rules_job.rb @@ -3,21 +3,20 @@ class GenerateWafRulesJob < ApplicationJob queue_as :waf_rules - def perform(project_id:, event_id:) - project = Project.find(project_id) + def perform(event_id:) event = Event.find(event_id) # Only analyze blocked events for rule generation return unless event.blocked? # Generate different types of rules based on patterns - generate_ip_rules(project, event) - generate_path_rules(project, event) - generate_user_agent_rules(project, event) - generate_parameter_rules(project, event) + generate_ip_rules(event) + generate_path_rules(event) + generate_user_agent_rules(event) + generate_parameter_rules(event) - # Notify project of new rules - project.broadcast_rules_refresh + # Broadcast rule updates globally + ActionCable.server.broadcast("rules", { type: "refresh" }) rescue => e Rails.logger.error "Error generating WAF rules: #{e.message}" @@ -26,30 +25,23 @@ class GenerateWafRulesJob < ApplicationJob private - def generate_ip_rules(project, event) + def generate_ip_rules(event) return unless event.ip_address.present? # Check if this IP has multiple violations - violation_count = project.events + violation_count = Event .by_ip(event.ip_address) .blocked .where(timestamp: 24.hours.ago..Time.current) .count - # Auto-block IPs with 10+ violations in 24 hours - if violation_count >= 10 && !project.blocked_ips.include?(event.ip_address) - project.add_ip_rule( - event.ip_address, - 'block', - expires_at: 7.days.from_now, - reason: "Auto-generated: #{violation_count} violations in 24 hours" - ) - - Rails.logger.info "Auto-blocked IP #{event.ip_address} for project #{project.slug}" + # Log high-violation IPs - no automatic blocking without projects + if violation_count >= 10 + Rails.logger.info "IP with high violation count: #{event.ip_address} (#{violation_count} violations in 24 hours)" end end - def generate_path_rules(project, event) + def generate_path_rules(event) return unless event.request_path.present? # Look for repeated attack patterns on specific paths @@ -65,7 +57,7 @@ class GenerateWafRulesJob < ApplicationJob end end - def generate_user_agent_rules(project, event) + def generate_user_agent_rules(event) return unless event.user_agent.present? # Look for malicious user agents @@ -81,7 +73,7 @@ class GenerateWafRulesJob < ApplicationJob end end - def generate_parameter_rules(project, event) + def generate_parameter_rules(event) params = event.query_params return unless params.present? diff --git a/app/jobs/process_waf_analytics_job.rb b/app/jobs/process_waf_analytics_job.rb index 247682b..320e718 100644 --- a/app/jobs/process_waf_analytics_job.rb +++ b/app/jobs/process_waf_analytics_job.rb @@ -3,17 +3,16 @@ class ProcessWafAnalyticsJob < ApplicationJob queue_as :waf_analytics - def perform(project_id:, event_id:) - project = Project.find(project_id) + def perform(event_id:) event = Event.find(event_id) # Analyze event patterns - analyze_traffic_patterns(project, event) - analyze_geographic_distribution(project, event) - analyze_attack_vectors(project, event) + analyze_traffic_patterns(event) + analyze_geographic_distribution(event) + analyze_attack_vectors(event) - # Update project analytics cache - update_project_analytics(project) + # Update global analytics cache + update_analytics_cache rescue => e Rails.logger.error "Error processing WAF analytics: #{e.message}" @@ -22,14 +21,15 @@ class ProcessWafAnalyticsJob < ApplicationJob private - def analyze_traffic_patterns(project, event) + def analyze_traffic_patterns(event) # Look for unusual traffic spikes - recent_events = project.events.where(timestamp: 5.minutes.ago..Time.current) + recent_events = Event.where(timestamp: 5.minutes.ago..Time.current) - if recent_events.count > project.rate_limit_threshold * 5 + # Use a default threshold since we no longer have project-specific thresholds + threshold = 1000 # Default threshold + if recent_events.count > threshold * 5 # High traffic detected - create an issue Issue.create!( - project: project, title: "High Traffic Spike Detected", description: "Detected #{recent_events.count} requests in the last 5 minutes", severity: "medium", @@ -37,56 +37,51 @@ class ProcessWafAnalyticsJob < ApplicationJob metadata: { event_count: recent_events.count, time_window: "5 minutes", - threshold: project.rate_limit_threshold * 5 + threshold: threshold * 5 } ) end end - def analyze_geographic_distribution(project, event) + def analyze_geographic_distribution(event) return unless event.country_code.present? - # Check if this country is unusual for this project - country_events = project.events + # Check if this country is unusual globally + country_events = Event .where(country_code: event.country_code) .where(timestamp: 1.hour.ago..Time.current) # If this is the first event from this country or unusual spike if country_events.count == 1 || country_events.count > 100 - Rails.logger.info "Unusual geographic activity from #{event.country_code} for project #{project.slug}" + Rails.logger.info "Unusual geographic activity from #{event.country_code}" end end - def analyze_attack_vectors(project, event) + def analyze_attack_vectors(event) return unless event.blocked? # Analyze common attack patterns - analyze_ip_reputation(project, event) - analyze_user_agent_patterns(project, event) - analyze_path_attacks(project, event) + analyze_ip_reputation(event) + analyze_user_agent_patterns(event) + analyze_path_attacks(event) end - def analyze_ip_reputation(project, event) + def analyze_ip_reputation(event) return unless event.ip_address.present? # Count recent blocks from this IP - recent_blocks = project.events + recent_blocks = Event .by_ip(event.ip_address) .blocked .where(timestamp: 1.hour.ago..Time.current) if recent_blocks.count >= 5 - # Suggest automatic IP block - project.add_ip_rule( - event.ip_address, - 'block', - expires_at: 24.hours.from_now, - reason: "Automated block: #{recent_blocks.count} violations in 1 hour" - ) + # Log IP reputation issue - no automatic IP blocking without projects + Rails.logger.warn "IP with poor reputation detected: #{event.ip_address} (#{recent_blocks.count} blocks in 1 hour)" end end - def analyze_user_agent_patterns(project, event) + def analyze_user_agent_patterns(event) return unless event.user_agent.present? # Look for common bot/user agent patterns @@ -101,7 +96,7 @@ class ProcessWafAnalyticsJob < ApplicationJob end end - def analyze_path_attacks(project, event) + def analyze_path_attacks(event) return unless event.request_path.present? # Look for common attack paths @@ -119,8 +114,8 @@ class ProcessWafAnalyticsJob < ApplicationJob end end - def update_project_analytics(project) + def update_analytics_cache # Update cached analytics for faster dashboard loading - Rails.cache.delete("project_#{project.id}_analytics") + Rails.cache.delete("global_analytics") end end \ No newline at end of file diff --git a/app/jobs/process_waf_event_job.rb b/app/jobs/process_waf_event_job.rb index adce6fb..8a10b6a 100644 --- a/app/jobs/process_waf_event_job.rb +++ b/app/jobs/process_waf_event_job.rb @@ -3,9 +3,7 @@ class ProcessWafEventJob < ApplicationJob queue_as :waf_events - def perform(project_id:, event_data:, headers:) - project = Project.find(project_id) - + def perform(event_data:, headers:) # Handle both single event and events array events_to_process = [] @@ -26,7 +24,7 @@ class ProcessWafEventJob < ApplicationJob event_id = single_event_data['event_id'] || SecureRandom.uuid # Create the WAF event record - event = Event.create_from_waf_payload!(event_id, single_event_data, project) + event = Event.create_from_waf_payload!(event_id, single_event_data) # Enrich with geo-location data if missing if event.ip_address.present? && event.country_code.blank? @@ -38,12 +36,12 @@ class ProcessWafEventJob < ApplicationJob end # Trigger analytics processing - ProcessWafAnalyticsJob.perform_later(project_id: project_id, event_id: event.id) + ProcessWafAnalyticsJob.perform_later(event_id: event.id) # Check for automatic rule generation opportunities - GenerateWafRulesJob.perform_later(project_id: project_id, event_id: event.id) + GenerateWafRulesJob.perform_later(event_id: event.id) - Rails.logger.info "Processed WAF event #{event_id} for project #{project.slug}" + Rails.logger.info "Processed WAF event #{event_id}" rescue ActiveRecord::RecordInvalid => e Rails.logger.error "Failed to create WAF event: #{e.message}" Rails.logger.error e.record.errors.full_messages.join(", ") @@ -54,8 +52,8 @@ class ProcessWafEventJob < ApplicationJob end # Broadcast real-time updates once per batch - project.broadcast_events_refresh + ActionCable.server.broadcast("events", { type: "refresh" }) - Rails.logger.info "Processed #{events_to_process.count} WAF events for project #{project.slug}" + Rails.logger.info "Processed #{events_to_process.count} WAF events" end end \ No newline at end of file diff --git a/app/models/dsn.rb b/app/models/dsn.rb new file mode 100644 index 0000000..b32f301 --- /dev/null +++ b/app/models/dsn.rb @@ -0,0 +1,18 @@ +class Dsn < ApplicationRecord + validates :key, presence: true, uniqueness: true + validates :name, presence: true + + before_validation :generate_key, on: :create + + scope :enabled, -> { where(enabled: true) } + + def self.authenticate(key) + enabled.find_by(key: key) + end + + private + + def generate_key + self.key ||= SecureRandom.hex(32) + end +end \ No newline at end of file diff --git a/app/models/event.rb b/app/models/event.rb index eab5e43..7561577 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Event < ApplicationRecord - belongs_to :project - # Normalized association for hosts (most valuable compression) belongs_to :request_host, optional: true @@ -87,13 +85,12 @@ class Event < ApplicationRecord # Normalize event fields after extraction after_validation :normalize_event_fields, if: :should_normalize? - def self.create_from_waf_payload!(event_id, payload, project) + def self.create_from_waf_payload!(event_id, payload) # Normalize headers in payload during import phase normalized_payload = normalize_payload_headers(payload) # Create the WAF request event create!( - project: project, event_id: event_id, timestamp: parse_timestamp(normalized_payload["timestamp"]), payload: normalized_payload, diff --git a/app/models/issue.rb b/app/models/issue.rb index 3867e69..f09678a 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Issue < ApplicationRecord - belongs_to :project has_many :events, dependent: :nullify enum :status, { open: 0, resolved: 1, ignored: 2 } @@ -17,18 +16,18 @@ class Issue < ApplicationRecord # Real-time updates after_create_commit do - broadcast_refresh_to(project) + broadcast_refresh end after_update_commit do broadcast_refresh # Refreshes the issue show page - broadcast_refresh_to(project, "issues") # Refreshes the project's issues index + broadcast_refresh_to("issues") # Refreshes the issues index end - def self.group_event(event_payload, project) + def self.group_event(event_payload) fingerprint = generate_fingerprint(event_payload) - find_or_create_by(project: project, fingerprint: fingerprint) do |issue| + find_or_create_by(fingerprint: fingerprint) do |issue| issue.title = extract_title(event_payload) issue.exception_type = extract_exception_type(event_payload) issue.first_seen = Time.current diff --git a/app/models/network_range.rb b/app/models/network_range.rb index 96c21b9..7aa0d8d 100644 --- a/app/models/network_range.rb +++ b/app/models/network_range.rb @@ -29,6 +29,8 @@ class NetworkRange < ApplicationRecord scope :vpn, -> { where(is_vpn: true) } scope :user_created, -> { where(source: 'user_created') } scope :api_imported, -> { where(source: 'api_imported') } + scope :with_events, -> { where("events_count > 0") } + scope :most_active, -> { order(events_count: :desc) } # Callbacks before_validation :set_default_source @@ -237,9 +239,10 @@ class NetworkRange < ApplicationRecord cidr.to_s.gsub('/', '_') end - # Analytics methods + # Analytics methods - events_count is now a counter cache column maintained by database triggers + # This is much more performant than the previous implementation that did complex network queries def events_count - Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count + self[:events_count] || 0 end def recent_events(limit: 100) diff --git a/app/policies/analytics_policy.rb b/app/policies/analytics_policy.rb new file mode 100644 index 0000000..1965413 --- /dev/null +++ b/app/policies/analytics_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AnalyticsPolicy < ApplicationPolicy + def index? + # Everyone can view analytics (including unauthenticated users for monitoring) + true + end +end \ No newline at end of file diff --git a/app/policies/dsn_policy.rb b/app/policies/dsn_policy.rb new file mode 100644 index 0000000..7cf4953 --- /dev/null +++ b/app/policies/dsn_policy.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class DsnPolicy < ApplicationPolicy + def index? + current_user.present? && current_user.admin? + end + + def show? + current_user.present? && current_user.admin? + end + + def create? + current_user.present? && current_user.admin? + end + + def new? + create? + end + + def update? + current_user.present? && current_user.admin? + end + + def edit? + update? + end + + def destroy? + current_user.present? && current_user.admin? + end + + def disable? + current_user.present? && current_user.admin? + end + + def enable? + current_user.present? && current_user.admin? + end + + class Scope < Scope + def resolve + if user&.admin? + scope.all + else + scope.none + end + end + end +end \ No newline at end of file diff --git a/app/services/dsn_authentication_service.rb b/app/services/dsn_authentication_service.rb index 0514700..753676d 100644 --- a/app/services/dsn_authentication_service.rb +++ b/app/services/dsn_authentication_service.rb @@ -3,24 +3,24 @@ class DsnAuthenticationService class AuthenticationError < StandardError; end - def self.authenticate(request, project_id) + def self.authenticate(request) # Try multiple authentication methods in order of preference # Method 1: Query parameter authentication - public_key = extract_key_from_query_params(request) - return find_project(public_key, project_id) if public_key + dsn_key = extract_key_from_query_params(request) + return find_dsn(dsn_key) if dsn_key # Method 2: X-Baffle-Auth header (similar to X-Sentry-Auth) - public_key = extract_key_from_baffle_auth_header(request) - return find_project(public_key, project_id) if public_key + dsn_key = extract_key_from_baffle_auth_header(request) + return find_dsn(dsn_key) if dsn_key # Method 3: Authorization Bearer token - public_key = extract_key_from_authorization_header(request) - return find_project(public_key, project_id) if public_key + dsn_key = extract_key_from_authorization_header(request) + return find_dsn(dsn_key) if dsn_key - # Method 4: Basic auth (username is the public_key) - public_key = extract_key_from_basic_auth(request) - return find_project(public_key, project_id) if public_key + # Method 4: Basic auth (username is the dsn_key) + dsn_key = extract_key_from_basic_auth(request) + return find_dsn(dsn_key) if dsn_key raise AuthenticationError, "No valid authentication method found" end @@ -36,8 +36,8 @@ class DsnAuthenticationService auth_header = request.headers['X-Baffle-Auth'] || request.headers['X-Sentry-Auth'] return nil unless auth_header - # Parse: Baffle baffle_key=public_key, baffle_version=1 - # Or: Sentry sentry_key=public_key, sentry_version=7 + # Parse: Baffle baffle_key=dsn_key, baffle_version=1 + # Or: Sentry sentry_key=dsn_key, sentry_version=7 match = auth_header.match(/(?:baffle_key|sentry_key)=([^,\s]+)/) match&.[](1) end @@ -46,7 +46,7 @@ class DsnAuthenticationService authorization_header = request.headers['Authorization'] return nil unless authorization_header - # Parse: Bearer public_key + # Parse: Bearer dsn_key if authorization_header.start_with?('Bearer ') authorization_header[7..-1].strip end @@ -62,20 +62,16 @@ class DsnAuthenticationService username end - def self.find_project(public_key, project_id) - return nil unless public_key.present? && project_id.present? + def self.find_dsn(dsn_key) + return nil unless dsn_key.present? - # Find project by public_key first - project = Project.find_by(public_key: public_key) - raise AuthenticationError, "Invalid public_key" unless project + # Find DSN by key + dsn = Dsn.authenticate(dsn_key) + raise AuthenticationError, "Invalid DSN key" unless dsn - # Verify project_id matches (supports both slug and ID) - project_matches = Project.find_by(slug: project_id) || Project.find_by(id: project_id) - raise AuthenticationError, "Invalid project_id" unless project_matches == project + # Ensure DSN is enabled + raise AuthenticationError, "DSN is disabled" unless dsn.enabled? - # Ensure project is enabled - raise AuthenticationError, "Project is disabled" unless project.enabled? - - project + dsn end end diff --git a/app/views/analytics/index.html.erb b/app/views/analytics/index.html.erb new file mode 100644 index 0000000..2734268 --- /dev/null +++ b/app/views/analytics/index.html.erb @@ -0,0 +1,343 @@ +<% content_for :title, "Analytics Dashboard - Baffle Hub" %> + +
+ +
+
+

Analytics Dashboard

+

Overview of WAF events, rules, and network activity

+
+ +
+ +
+ + + + + Auto-refreshing +
+ + +
+ Time Period: +
+ <%= link_to "1H", analytics_path(period: :hour), + class: time_period_class(:hour), + data: { action: "click->dashboard#periodChanged", period: "hour" } %> + <%= link_to "24H", analytics_path(period: :day), + class: time_period_class(:day), + data: { action: "click->dashboard#periodChanged", period: "day" } %> + <%= link_to "1W", analytics_path(period: :week), + class: time_period_class(:week), + data: { action: "click->dashboard#periodChanged", period: "week" } %> + <%= link_to "1M", analytics_path(period: :month), + class: time_period_class(:month), + data: { action: "click->dashboard#periodChanged", period: "month" } %> +
+
+
+
+ + +
+
+ +
+
+
+
+
+ + + +
+
+
+
+
Total Events
+
<%= number_with_delimiter(@total_events) %>
+
+
+
+
+
+
+ Last <%= @time_period.to_s.humanize %> +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
Active Rules
+
<%= number_with_delimiter(@total_rules) %>
+
+
+
+
+
+
+ Enabled + <% if @system_health[:disabled_rules] > 0 %> + · <%= @system_health[:disabled_rules] %> disabled + <% end %> +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
Active Network Ranges
+
<%= number_with_delimiter(@network_ranges_with_events) %>
+
+
+
+
+
+
+ of <%= number_with_delimiter(@total_network_ranges) %> total +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
System Health
+
Normal
+
+
+
+
+
+
+ All systems operational +
+
+
+
+ + +
+ +
+
+

Events Timeline (Last 24 Hours)

+
+
+
+ <% @chart_data[:timeline].each do |data| %> +
+
<%= data[:time] %>
+
+
+
+
+
+
+
<%= data[:total] %>
+
+ <% end %> +
+
+
+ + +
+
+

Event Actions

+
+
+ <% if @chart_data[:actions].any? %> +
+ <% @chart_data[:actions].each do |action| %> +
+
+
+
+ <%= action[:action] %> +
+
+ <%= number_with_delimiter(action[:count]) %> + (<%= action[:percentage] %>%) +
+
+ <% end %> +
+ <% else %> +

No events in the selected time period

+ <% end %> +
+
+
+ + +
+ +
+
+

Top Countries

+
+
+ <% if @top_countries.any? %> +
+ <% @top_countries.first(5).each do |country, count| %> +
+ <%= country %> + <%= number_with_delimiter(count) %> +
+ <% end %> +
+ <% else %> +

No country data available

+ <% end %> +
+
+ + +
+
+

Network Intelligence

+
+
+
+
+ 🏢 Datacenter + <%= number_with_delimiter(@network_intelligence[:datacenter_ranges]) %> +
+
+ 🔒 VPN + <%= number_with_delimiter(@network_intelligence[:vpn_ranges]) %> +
+
+ 🛡️ Proxy + <%= number_with_delimiter(@network_intelligence[:proxy_ranges]) %> +
+
+
+
+ + +
+
+

Recent Activity

+
+
+
+ <% @recent_events.first(3).each do |event| %> +
+
+
+ <%= event.ip_address %> +
+ <%= time_ago_in_words(event.timestamp) %> ago +
+ <% end %> +
+
+
+
+ + +
+
+
+

Top Blocked IPs

+ <%= link_to "View All Events", events_path, class: "text-sm text-blue-600 hover:text-blue-800" %> +
+
+
+ <% if @top_blocked_ips.any? %> +
+ <% @top_blocked_ips.each do |ip, count| %> +
+
<%= ip %>
+
<%= number_with_delimiter(count) %> blocks
+
+ <% end %> +
+ <% else %> +

No blocked events in the selected time period

+ <% end %> +
+
+ + +
+
+

Quick Actions

+
+
+
+ <%= link_to new_rule_path, class: "flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" do %> + + + + Create Rule + <% end %> + + <%= link_to new_network_range_path, class: "flex items-center justify-center px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors" do %> + + + + Add Network Range + <% end %> + + <%= link_to events_path, class: "flex items-center justify-center px-4 py-3 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors" do %> + + + + View Events + <% end %> + + <%= link_to rules_path, class: "flex items-center justify-center px-4 py-3 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors" do %> + + + + Manage Rules + <% end %> +
+
+
+
\ No newline at end of file diff --git a/app/views/analytics/index.turbo_stream.erb b/app/views/analytics/index.turbo_stream.erb new file mode 100644 index 0000000..4c45cba --- /dev/null +++ b/app/views/analytics/index.turbo_stream.erb @@ -0,0 +1,132 @@ +<%= turbo_stream.replace "dashboard-stats" do %> +
+ +
+
+
+
+
+ + + +
+
+
+
+
Total Events
+
<%= number_with_delimiter(@total_events) %>
+
+
+
+
+
+
+ Last <%= @time_period.to_s.humanize %> +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
Active Rules
+
<%= number_with_delimiter(@total_rules) %>
+
+
+
+
+
+
+ Enabled + <% if @system_health[:disabled_rules] > 0 %> + · <%= @system_health[:disabled_rules] %> disabled + <% end %> +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
Active Network Ranges
+
<%= number_with_delimiter(@network_ranges_with_events) %>
+
+
+
+
+
+
+ of <%= number_with_delimiter(@total_network_ranges) %> total +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
System Health
+
Normal
+
+
+
+
+
+
+ All systems operational +
+
+
+
+<% end %> + +<%= turbo_stream.replace "recent-activity" do %> +
+
+

Recent Activity

+
+
+
+ <% @recent_events.first(3).each do |event| %> +
+
+
+ <%= event.ip_address %> +
+ <%= time_ago_in_words(event.timestamp) %> ago +
+ <% end %> +
+
+
+<% end %> \ No newline at end of file diff --git a/app/views/dsns/edit.html.erb b/app/views/dsns/edit.html.erb new file mode 100644 index 0000000..873e8f2 --- /dev/null +++ b/app/views/dsns/edit.html.erb @@ -0,0 +1,95 @@ +<% content_for :title, "Edit DSN - #{@dsn.name}" %> + +
+
+
+

+ Edit DSN +

+
+
+ <%= link_to "Back to DSN", @dsn, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> +
+
+ +
+
+ <%= form_with(model: @dsn, local: true, class: "space-y-6") do |form| %> + <% if @dsn.errors.any? %> +
+
+
+ + + +
+
+

+ There were <%= pluralize(@dsn.errors.count, "error") %> with your submission: +

+
+
    + <% @dsn.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+
+ <%= form.label :name, class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.text_field :name, class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "e.g., Production DSN, Development DSN" %> +
+

+ A descriptive name to help you identify this DSN key. +

+
+ +
+
+
+ <%= form.check_box :enabled, class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %> +
+
+ <%= form.label :enabled, class: "font-medium text-gray-700" %> +

Enable this DSN for agent authentication

+
+
+
+
+ + +
+
+
+ + + +
+
+

+ DSN Key +

+
+ <%= @dsn.key %> +
+

DSN keys cannot be changed after creation.

+
+
+
+ +
+
+ <%= link_to "Cancel", @dsn, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + <%= form.submit "Update DSN", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/dsns/index.html.erb b/app/views/dsns/index.html.erb new file mode 100644 index 0000000..04a5081 --- /dev/null +++ b/app/views/dsns/index.html.erb @@ -0,0 +1,162 @@ +<% content_for :title, "DSNs" %> + +
+ +
+
+
+

DSN Management

+

Manage DSN keys for agent authentication

+
+ <% if policy(Dsn).create? %> +
+ <%= link_to "New DSN", new_dsn_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+ <% end %> +
+
+ + +
+
+

Environment DSNs

+

+ Default DSNs configured via environment variables for agent connectivity. +

+
+
+
+ +
+
External DSN (BAFFLE_HOST)
+
+
+ + <%= @external_dsn %> + + +
+

Host: <%= ENV['BAFFLE_HOST'] || 'localhost:3000' %>

+
+
+ + <% if @internal_dsn.present? %> + +
+
Internal DSN (BAFFLE_INTERNAL_HOST)
+
+
+ + <%= @internal_dsn %> + + +
+

Host: <%= ENV['BAFFLE_INTERNAL_HOST'] %>

+
+
+ <% end %> +
+
+
+ + +
+ <% if @dsns.any? %> +
    + <% @dsns.each do |dsn| %> +
  • +
    +
    +
    + <% if dsn.enabled? %> +
    + + + +
    + <% else %> +
    + + + +
    + <% end %> +
    +
    +
    +

    <%= dsn.name %>

    + + <%= dsn.enabled? ? 'Enabled' : 'Disabled' %> + +
    +

    + Key: <%= dsn.key[0..15] + "..." %> +

    +

    + Created: <%= dsn.created_at.strftime('%Y-%m-%d %H:%M') %> +

    +
    +
    +
    + <%= link_to "View", dsn, class: "text-blue-600 hover:text-blue-900 text-sm font-medium" %> + <% if policy(dsn).edit? %> + <%= link_to "Edit", edit_dsn_path(dsn), class: "text-indigo-600 hover:text-indigo-900 text-sm font-medium" %> + <% end %> + <% if policy(dsn).disable? && dsn.enabled? %> + <%= link_to "Disable", disable_dsn_path(dsn), method: :post, + data: { confirm: "Are you sure you want to disable this DSN?" }, + class: "text-red-600 hover:text-red-900 text-sm font-medium" %> + <% elsif policy(dsn).enable? && !dsn.enabled? %> + <%= link_to "Enable", enable_dsn_path(dsn), method: :post, + class: "text-green-600 hover:text-green-900 text-sm font-medium" %> + <% end %> +
    +
    +
  • + <% end %> +
+ <% else %> +
+ + + +

No DSNs

+

Get started by creating a new DSN.

+ <% if policy(Dsn).create? %> +
+ <%= link_to "New DSN", new_dsn_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %> +
+ <% end %> +
+ <% end %> +
+
+ + \ No newline at end of file diff --git a/app/views/dsns/new.html.erb b/app/views/dsns/new.html.erb new file mode 100644 index 0000000..8ba91a9 --- /dev/null +++ b/app/views/dsns/new.html.erb @@ -0,0 +1,93 @@ +<% content_for :title, "New DSN" %> + +
+
+
+

+ New DSN +

+
+
+ <%= link_to "Back to DSNs", dsns_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> +
+
+ +
+
+ <%= form_with(model: @dsn, local: true, class: "space-y-6") do |form| %> + <% if @dsn.errors.any? %> +
+
+
+ + + +
+
+

+ There were <%= pluralize(@dsn.errors.count, "error") %> with your submission: +

+
+
    + <% @dsn.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+
+ <%= form.label :name, class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.text_field :name, class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "e.g., Production DSN, Development DSN" %> +
+

+ A descriptive name to help you identify this DSN key. +

+
+ +
+
+
+ <%= form.check_box :enabled, class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %> +
+
+ <%= form.label :enabled, class: "font-medium text-gray-700" %> +

Enable this DSN for agent authentication

+
+
+
+
+ +
+
+
+ + + +
+
+

+ DSN Key Information +

+
+

A unique DSN key will be automatically generated when you create this DSN. This key will be used by your baffle-agents to authenticate with the hub.

+
+
+
+
+ +
+
+ <%= link_to "Cancel", dsns_path, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + <%= form.submit "Create DSN", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/dsns/show.html.erb b/app/views/dsns/show.html.erb new file mode 100644 index 0000000..40f3c64 --- /dev/null +++ b/app/views/dsns/show.html.erb @@ -0,0 +1,138 @@ +<% content_for :title, "DSN - #{@dsn.name}" %> + +
+
+
+
+

+ <%= @dsn.name %> +

+ + <%= @dsn.enabled? ? 'Enabled' : 'Disabled' %> + +
+
+
+ <%= link_to "Back to DSNs", dsns_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> + <% if policy(@dsn).edit? %> + <%= link_to "Edit", edit_dsn_path(@dsn), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" %> + <% end %> +
+
+ +
+
+

DSN Information

+

+ DSN key details and usage information. +

+
+
+
+
+
Name
+
<%= @dsn.name %>
+
+
+
DSN Key
+
+
+ <%= @dsn.key %> + +
+
+
+
+
Status
+
+ + <%= @dsn.enabled? ? 'Enabled' : 'Disabled' %> + +
+
+
+
Created
+
+ <%= @dsn.created_at.strftime('%B %d, %Y at %I:%M %p') %> +
+
+ <% if @dsn.updated_at != @dsn.created_at %> +
+
Last Updated
+
+ <%= @dsn.updated_at.strftime('%B %d, %Y at %I:%M %p') %> +
+
+ <% end %> +
+
+
+ + + <% if policy(@dsn).disable? || policy(@dsn).enable? %> +
+
+

Actions

+
+ <% if @dsn.enabled? && policy(@dsn).disable? %> + <%= link_to "Disable DSN", disable_dsn_path(@dsn), method: :post, + data: { confirm: "Are you sure you want to disable this DSN? Agents will no longer be able to authenticate with this key." }, + class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <% elsif !@dsn.enabled? && policy(@dsn).enable? %> + <%= link_to "Enable DSN", enable_dsn_path(@dsn), method: :post, + class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> + <% end %> +
+
+
+ <% end %> + + +
+
+

Usage Instructions

+

+ How to use this DSN key with your baffle-agents. +

+
+
+
+

HTTP Header Authentication

+

Include the DSN key in the Authorization header:

+
Authorization: Bearer <%= @dsn.key %>
+ +

Query Parameter Authentication

+

Include the DSN key as a query parameter:

+
/api/events?baffle_key=<%= @dsn.key %>
+ +

X-Baffle-Auth Header

+

Use the custom Baffle authentication header:

+
X-Baffle-Auth: Baffle baffle_key=<%= @dsn.key %>, baffle_version=1
+
+
+
+
+ + \ No newline at end of file diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index c6893a1..07753ed 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,112 +1,157 @@ -
-

<%= @project.name %> - Events

+<% content_for :title, "Events - Baffle Hub" %> + +
+
- <%= link_to "← Back to Project", @project, class: "btn btn-secondary" %> - <%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %> +

Events

+

WAF event log and analysis

-
- -
-
-
Filters
+ +
+
+

Filters

+
+
+ <%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %> +
+
+ <%= form.label :ip, "IP Address", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :ip, value: params[:ip], + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", + placeholder: "Filter by IP" %> +
+
+ <%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :waf_action, + options_for_select([['All', ''], ['Allow', 'allow'], ['Block', 'block'], ['Challenge', 'challenge']], params[:waf_action]), + { }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +
+
+ <%= form.label :country, "Country", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :country, value: params[:country], + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", + placeholder: "Country code (e.g. US)" %> +
+
+ <%= form.submit "Apply Filters", + class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + <%= link_to "Clear", events_path, + class: "inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+
+ <% end %> +
-
- <%= form_with url: project_events_path(@project), method: :get, local: true, class: "row g-3" do |form| %> -
- <%= form.label :ip, "IP Address", class: "form-label" %> - <%= form.text_field :ip, value: params[:ip], class: "form-control", placeholder: "Filter by IP" %> -
-
- <%= form.label :waf_action, "Action", class: "form-label" %> - <%= form.select :waf_action, - options_for_select([['All', ''], ['Allow', 'allow'], ['Block', 'block'], ['Challenge', 'challenge']], params[:waf_action]), - {}, { class: "form-select" } %> -
-
- <%= form.label :country, "Country", class: "form-label" %> - <%= form.text_field :country, value: params[:country], class: "form-control", placeholder: "Country code (e.g. US)" %> -
-
- <%= form.submit "Apply Filters", class: "btn btn-primary me-2" %> - <%= link_to "Clear", project_events_path(@project), class: "btn btn-outline-secondary" %> -
- <% end %> -
-
- -
-
-
Events (<%= @events.count %>)
-
-
- <% if @events.any? %> -
- - + +
+
+
+

Events (<%= number_with_delimiter(@events.count) %>)

+
+ <%= link_to "📊 Analytics Dashboard", analytics_path, + class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> + <% if @pagy.pages > 1 %> + + Page <%= @pagy.page %> of <%= @pagy.pages %> + + <% end %> +
+
+ + <% if @pagy.pages > 1 %> +
+ <%= pagy_nav_tailwind(@pagy, pagy_id: 'events_top') %> +
+ <% end %> +
+
+ <% if @events.any? %> +
+ - - - - - - - - + + + + + + + + - + <% @events.each do |event| %> - - - - + + + - - - - + + + - <% end %>
TimeIP AddressActionPathMethodStatusCountryUser AgentTimeIP AddressActionPathMethodStatusCountryUser Agent
<%= event.timestamp.strftime("%Y-%m-%d %H:%M:%S") %><%= event.ip_address %> - +
+ <%= event.timestamp.strftime("%Y-%m-%d %H:%M:%S") %> + + <%= event.ip_address %> + + <%= event.waf_action %> <%= event.request_path %><%= event.request_method %><%= event.response_status %> + + <%= event.request_path || '-' %> + + <%= event.request_method ? event.request_method.upcase : '-' %> + + <%= event.response_status || '-' %> + <% if event.country_code.present? %> - <%= event.country_code %> + + <%= event.country_code %> + <% else %> - - + - <% end %> - <%= event.user_agent&.truncate(30) || '-' %> + + <%= event.user_agent&.truncate(50) || '-' %>
-
- - <% if @pagy.pages > 1 %> -
- <%== pagy_nav(@pagy) %> -
-
- Showing <%= @pagy.from %> to <%= @pagy.to %> of <%= @pagy.count %> events + + <% if @pagy.pages > 1 %> + <%= pagy_nav_tailwind(@pagy, pagy_id: 'events_bottom') %> + <% end %> + <% else %> +
+ + + +

No events

+

+ <% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %> + No events found matching your filters. + <% else %> + No events have been received yet. + <% end %> +

+ <% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %> +
+ <%= link_to "Clear Filters", events_path, + class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-blue-600 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+ <% end %>
<% end %> - <% else %> -
-

- <% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %> - No events found matching your filters. - <% else %> - No events have been received yet. - <% end %> -

- <% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %> - <%= link_to "Clear Filters", project_events_path(@project), class: "btn btn-outline-primary" %> - <% end %> -
- <% end %> +
\ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 92207b5..fd332db 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= content_for(:title) || "Baffle Hub - WAF Analytics" %> @@ -18,103 +18,196 @@ - - - <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> - -
+ + + + + + + + <% if notice || alert %> +
+ <% if notice %> +
+
+ + + + <%= notice %> +
+
+ <% end %> + + <% if alert %> +
+
+ + + + <%= alert %> +
+
+ <% end %> +
+ <% end %> + + +
+ <%= yield %> +
+ + +
+
+
+

© <%= Time.current.year %> Baffle Hub - WAF Analytics Platform

- - -
- <% if notice %> - - <% end %> - - <% if alert %> - - <% end %> - - <%= yield %> -
- - - +
diff --git a/app/views/layouts/authentication.html.erb b/app/views/layouts/authentication.html.erb new file mode 100644 index 0000000..8cc924c --- /dev/null +++ b/app/views/layouts/authentication.html.erb @@ -0,0 +1,91 @@ + + + + <%= content_for(:title) || "Baffle Hub - WAF Analytics" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + + + <%= render 'shared/auth_navigation' %> + + + <% if notice || alert %> +
+ <% if notice %> +
+
+ + + + <%= notice %> +
+
+ <% end %> + + <% if alert %> +
+
+ + + + <%= alert %> +
+
+ <% end %> +
+ <% end %> + + +
+ <%= yield %> +
+ + +
+
+
+

© <%= Time.current.year %> Baffle Hub - WAF Analytics Platform

+
+
+
+ + \ No newline at end of file diff --git a/app/views/network_ranges/index.html.erb b/app/views/network_ranges/index.html.erb index bc0b724..1291345 100644 --- a/app/views/network_ranges/index.html.erb +++ b/app/views/network_ranges/index.html.erb @@ -1,23 +1,21 @@ -<% content_for :title, "Network Ranges - #{@project.name}" %> +<% content_for :title, "Network Ranges" %> -
+
-
-
-
-

Network Ranges

-

Browse and manage network ranges with intelligence data

-
-
- <%= link_to "IP Lookup", lookup_network_ranges_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> - <%= link_to "Add Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> -
+
+
+

Network Ranges

+

Browse and manage network ranges with intelligence data

+
+
+ <%= link_to "IP Lookup", lookup_network_ranges_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> + <%= link_to "Add Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
<% if params[:asn].present? || params[:country].present? || params[:company].present? || params[:datacenter].present? || params[:vpn].present? || params[:proxy].present? || params[:source].present? || params[:search].present? %> -
+

Active Filters

@@ -225,8 +223,23 @@
-

Network Ranges

-

Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges

+
+
+

Network Ranges

+

Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges

+
+ <% if @pagy.present? && @pagy.pages > 1 %> + + Page <%= @pagy.page %> of <%= @pagy.pages %> + + <% end %> +
+ + <% if @pagy.present? && @pagy.pages > 1 %> +
+ <%= pagy_nav_tailwind(@pagy, pagy_id: 'network_ranges_top') %> +
+ <% end %>
    @@ -304,10 +317,10 @@ <% end %>
- - <% if @pagy.present? %> -
- <%= pagy_nav(@pagy) %> + + <% if @pagy.present? && @pagy.pages > 1 %> +
+ <%= pagy_nav_tailwind(@pagy, pagy_id: 'network_ranges_bottom') %>
<% end %>
\ No newline at end of file diff --git a/app/views/network_ranges/show.html.erb b/app/views/network_ranges/show.html.erb index e7e4fe6..3889d35 100644 --- a/app/views/network_ranges/show.html.erb +++ b/app/views/network_ranges/show.html.erb @@ -159,22 +159,22 @@

Traffic Statistics

-
-
-
<%= number_with_delimiter(@traffic_stats[:total_requests]) %>
-
Total Requests
+
+
+
<%= number_with_delimiter(@traffic_stats[:total_requests]) %>
+
Total Requests
-
-
<%= number_with_delimiter(@traffic_stats[:unique_ips]) %>
-
Unique IPs
+
+
<%= number_with_delimiter(@traffic_stats[:unique_ips]) %>
+
Unique IPs
-
-
<%= number_with_delimiter(@traffic_stats[:allowed_requests]) %>
-
Allowed
+
+
<%= number_with_delimiter(@traffic_stats[:allowed_requests]) %>
+
Allowed
-
-
<%= number_with_delimiter(@traffic_stats[:blocked_requests]) %>
-
Blocked
+
+
<%= number_with_delimiter(@traffic_stats[:blocked_requests]) %>
+
Blocked
@@ -196,11 +196,197 @@ <% end %> - <% if @associated_rules.any? %> -
-
+
+
+

Associated Rules (<%= @associated_rules.count %>)

+
+
+ + + + + + <% if @associated_rules.any? %>
<% @associated_rules.each do |rule| %>
@@ -214,6 +400,9 @@ Priority: <%= rule.priority %> + + <%= rule.rule_type.humanize %> + <% if rule.source.include?('surgical') %> Surgical @@ -242,8 +431,16 @@
<% end %>
-
- <% end %> + <% else %> +
+ + + +

No rules yet

+

Get started by creating a rule for this network range.

+
+ <% end %> +
@@ -344,4 +541,87 @@
<% end %> -
\ No newline at end of file +
+ + \ No newline at end of file diff --git a/app/views/projects/analytics.html.erb b/app/views/projects/analytics.html.erb deleted file mode 100644 index 66fcf6d..0000000 --- a/app/views/projects/analytics.html.erb +++ /dev/null @@ -1,200 +0,0 @@ -
-

<%= @project.name %> - Analytics

-
- <%= link_to "← Back to Project", project_path(@project), class: "btn btn-secondary" %> -
-
- - -
-
-
Time Range
-
-
- <%= form_with url: analytics_project_path(@project), method: :get, local: true do |form| %> -
-
- <%= form.label :time_range, "Time Range", class: "form-label" %> - <%= form.select :time_range, - options_for_select([ - ["Last Hour", 1], - ["Last 6 Hours", 6], - ["Last 24 Hours", 24], - ["Last 7 Days", 168], - ["Last 30 Days", 720] - ], @time_range), - {}, class: "form-select" %> -
-
- <%= form.submit "Update", class: "btn btn-primary" %> -
-
- <% end %> -
-
- - -
-
-
-
-

<%= number_with_delimiter(@total_events) %>

-

Total Events

-
-
-
-
-
-
-

<%= number_with_delimiter(@allowed_events) %>

-

Allowed

-
-
-
-
-
-
-

<%= number_with_delimiter(@blocked_events) %>

-

Blocked

-
-
-
-
- -
- -
-
-
-
Top Blocked IPs
-
-
- <% if @top_blocked_ips.any? %> -
- - - - - - - - - <% @top_blocked_ips.each do |stat| %> - - - - - <% end %> - -
IP AddressBlocked Count
<%= stat.ip_address %><%= number_with_delimiter(stat.count) %>
-
- <% else %> -

No blocked events in this time range.

- <% end %> -
-
-
- - -
-
-
-
Top Countries
-
-
- <% if @country_stats.any? %> -
- - - - - - - - - <% @country_stats.each do |stat| %> - - - - - <% end %> - -
CountryEvents
<%= stat.country_code || 'Unknown' %><%= number_with_delimiter(stat.count) %>
-
- <% else %> -

No country data available.

- <% end %> -
-
-
-
- - -
-
-
-
-
Action Distribution
-
-
- <% if @action_stats.any? %> -
- <% @action_stats.each do |stat| %> -
-
-
-

<%= stat.action.upcase %>

-

- - <%= number_with_delimiter(stat.count) %> - -

-
-
-
- <% end %> -
- <% else %> -

No action data available.

- <% end %> -
-
-
-
- -<% if @total_events > 0 %> -
-
-
-
-
Block Rate
-
-
-
- <% blocked_percentage = (@blocked_events.to_f / @total_events * 100).round(1) %> - <% allowed_percentage = (@allowed_events.to_f / @total_events * 100).round(1) %> - -
- <%= allowed_percentage %>% Allowed -
-
- <%= blocked_percentage %>% Blocked -
-
-
-
-
-
-<% end %> - -
- <%= link_to "View Events", events_project_path(@project), class: "btn btn-primary" %> - <%= link_to "Export Data", "#", class: "btn btn-secondary", onclick: "alert('Export feature coming soon!')" %> -
\ No newline at end of file diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb deleted file mode 100644 index 676e01e..0000000 --- a/app/views/projects/index.html.erb +++ /dev/null @@ -1,49 +0,0 @@ -

Projects

- -<%= link_to "New Project", new_project_path, class: "btn btn-primary mb-3" %> - -
- <% @projects.each do |project| %> -
-
-
-
<%= project.name %>
- - <%= project.enabled? ? 'Active' : 'Disabled' %> - -
-
-

- Status: - - <%= project.waf_status %> - -

-

- Events (24h): <%= project.event_count(24.hours.ago) %> -

-

- Blocked (24h): <%= project.blocked_count(24.hours.ago) %> -

- - DSN:
- <%= project.dsn %> -
-
- -
-
- <% end %> -
- -<% if @projects.empty? %> -
-

No projects yet

-

Create your first project to start monitoring WAF events.

- <%= link_to "Create Project", new_project_path, class: "btn btn-primary" %> -
-<% end %> \ No newline at end of file diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb deleted file mode 100644 index c9a0171..0000000 --- a/app/views/projects/new.html.erb +++ /dev/null @@ -1,32 +0,0 @@ -

New Project

- -<%= form_with(model: @project, local: true) do |form| %> - <% if @project.errors.any? %> -
-

<%= pluralize(@project.errors.count, "error") %> prohibited this project from being saved:

-
    - <% @project.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> - -
- <%= form.label :name, class: "form-label" %> - <%= form.text_field :name, class: "form-control" %> -
- -
- <%= form.label :enabled, class: "form-label" %> -
- <%= form.check_box :enabled, class: "form-check-input" %> - <%= form.label :enabled, "Enable this project", class: "form-check-label" %> -
-
- -
- <%= form.submit "Create Project", class: "btn btn-primary" %> - <%= link_to "Cancel", projects_path, class: "btn btn-secondary" %> -
-<% end %> \ No newline at end of file diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb deleted file mode 100644 index 0ab228e..0000000 --- a/app/views/projects/show.html.erb +++ /dev/null @@ -1,118 +0,0 @@ -
-

<%= @project.name %>

-
- <%= link_to "Edit", edit_project_path(@project), class: "btn btn-secondary" %> - <%= link_to "Events", project_events_path(@project), class: "btn btn-primary" %> - <%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %> -
-
- -
-
-
-
-
Project Status
-
-
-

Status: - - <%= @waf_status %> - -

-

Enabled: - - <%= @project.enabled? ? 'Yes' : 'No' %> - -

-

Events (24h): <%= @event_count %>

-

Blocked (24h): <%= @blocked_count %>

-
-
-
- -
-
-
-
DSN Configuration
-
-
-

DSN:

- <%= @project.dsn %> - - - <% if @project.internal_dsn.present? %> -
-

Internal DSN:

- <%= @project.internal_dsn %> - <% end %> -
-
-
-
- -
-
-
-
Recent Events
-
-
- <% if @recent_events.any? %> -
- - - - - - - - - - - - <% @recent_events.limit(5).each do |event| %> - - - - - - - - <% end %> - -
TimeIPActionPathStatus
<%= event.timestamp.strftime("%H:%M:%S") %><%= event.ip_address %> - - <%= event.waf_action %> - - <%= event.request_path %><%= event.response_status %>
-
-
- <%= link_to "View All Events", project_events_path(@project), class: "btn btn-primary btn-sm" %> -
- <% else %> -

No events received yet.

- <% end %> -
-
-
- - \ No newline at end of file diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 767e2ed..2a9626f 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -1,22 +1,19 @@ -<% content_for :title, "Rules - #{@project.name}" %> +<% content_for :title, "Rules" %> -
+
-
-
-
-

Rules

-

Manage WAF rules for traffic filtering and control

-
-
- <%= link_to "Add Network Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> - <%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> -
+
+
+

Rules

+

Manage WAF rules for traffic filtering and control

+
+
+ <%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
-
+
@@ -93,7 +90,20 @@
-

All Rules

+
+

All Rules (<%= number_with_delimiter(@rules.count) %>)

+ <% if @pagy.present? && @pagy.pages > 1 %> + + Page <%= @pagy.page %> of <%= @pagy.pages %> + + <% end %> +
+ + <% if @pagy.present? && @pagy.pages > 1 %> +
+ <%= pagy_nav_tailwind(@pagy, pagy_id: 'rules_top') %> +
+ <% end %>
<% if @rules.any? %> @@ -178,7 +188,7 @@ <% if rule.expires_at.present? %> - <%= distance_of_time_in_words(Time.current, rule.expires_at) %> left + Expires <%= time_ago_in_words(rule.expires_at) %> from now <% end %>
@@ -221,4 +231,11 @@
<% end %>
+ + + <% if @pagy.present? && @pagy.pages > 1 %> +
+ <%= pagy_nav_tailwind(@pagy, pagy_id: 'rules_bottom') %> +
+ <% end %>
\ No newline at end of file diff --git a/app/views/shared/_auth_navigation.html.erb b/app/views/shared/_auth_navigation.html.erb new file mode 100644 index 0000000..350c336 --- /dev/null +++ b/app/views/shared/_auth_navigation.html.erb @@ -0,0 +1,24 @@ + +
+ +
\ No newline at end of file diff --git a/config/database.yml b/config/database.yml index ca4ae7b..dc6e882 100644 --- a/config/database.yml +++ b/config/database.yml @@ -18,7 +18,10 @@ postgres_default: &postgres_default development: primary: <<: *postgres_default - database: baffle_hub_development + host: localhost + user: dev_user + password: password + database: baffle-dev cache: <<: *sqlite_default database: storage/development_cache.sqlite3 @@ -66,4 +69,4 @@ production: cable: <<: *sqlite_default database: storage/production_cable.sqlite3 - migrations_paths: db/cable_migrate \ No newline at end of file + migrations_paths: db/cable_migrate diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb index 86391e3..77c529b 100644 --- a/config/initializers/pagy.rb +++ b/config/initializers/pagy.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true # Pagy configuration -# require 'pagy' +# Initialize Pagy with default settings after it's loaded -# Pagy::VARS[:items] = 50 # default items per page \ No newline at end of file +Rails.application.config.after_initialize do + # Set default items per page + Pagy::VARS[:items] = 25 if defined?(Pagy::VARS) +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 5ee4a72..3720f68 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,14 @@ Rails.application.routes.draw do # Admin user management (admin only) resources :users, only: [:index, :show, :edit, :update] + # DSN management (admin only) + resources :dsns do + member do + post :disable + post :enable + end + end + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. @@ -20,24 +28,22 @@ Rails.application.routes.draw do # WAF API namespace :api, defaults: { format: :json } do # Event ingestion (PRIMARY method - includes rule updates in response) - post ":project_id/events", to: "events#create" + post "events", to: "events#create" # Rule synchronization (SECONDARY - for admin/debugging only) # Note: Agents should use event responses for rule synchronization - get ":public_key/rules/version", to: "rules#version" - get ":public_key/rules", to: "rules#index" + get "rules/version", to: "rules#version" + get "rules", to: "rules#index" end - # Root path - projects dashboard - root "projects#index" + # Analytics dashboard + get "analytics", to: "analytics#index" - # Project management - resources :projects, only: [:index, :new, :create, :show, :edit, :update] do - resources :events, only: [:index] - member do - get :analytics - end - end + # Root path - analytics dashboard + root "analytics#index" + + # Event management + resources :events, only: [:index] # Network range management resources :network_ranges, only: [:index, :show, :new, :create, :edit, :update, :destroy] do diff --git a/db/migrate/20251108041801_create_dsns.rb b/db/migrate/20251108041801_create_dsns.rb new file mode 100644 index 0000000..7e946b8 --- /dev/null +++ b/db/migrate/20251108041801_create_dsns.rb @@ -0,0 +1,12 @@ +class CreateDsns < ActiveRecord::Migration[8.1] + def change + create_table :dsns do |t| + t.string :key + t.string :name + t.boolean :enabled, default: true, null: false + + t.timestamps + end + add_index :dsns, :key, unique: true + end +end diff --git a/db/migrate/20251108041822_remove_project_id_from_events.rb b/db/migrate/20251108041822_remove_project_id_from_events.rb new file mode 100644 index 0000000..a85f95a --- /dev/null +++ b/db/migrate/20251108041822_remove_project_id_from_events.rb @@ -0,0 +1,5 @@ +class RemoveProjectIdFromEvents < ActiveRecord::Migration[8.1] + def change + remove_reference :events, :project, null: false, foreign_key: true + end +end diff --git a/db/migrate/20251108042208_drop_projects_table.rb b/db/migrate/20251108042208_drop_projects_table.rb new file mode 100644 index 0000000..eaea081 --- /dev/null +++ b/db/migrate/20251108042208_drop_projects_table.rb @@ -0,0 +1,5 @@ +class DropProjectsTable < ActiveRecord::Migration[8.1] + def change + drop_table :projects + end +end diff --git a/db/migrate/20251108042553_add_events_count_to_network_ranges.rb b/db/migrate/20251108042553_add_events_count_to_network_ranges.rb new file mode 100644 index 0000000..174edaf --- /dev/null +++ b/db/migrate/20251108042553_add_events_count_to_network_ranges.rb @@ -0,0 +1,67 @@ +class AddEventsCountToNetworkRanges < ActiveRecord::Migration[8.1] + def up + # Add the column with default value + add_column :network_ranges, :events_count, :integer, null: false, default: 0 + + # Add index for faster queries + add_index :network_ranges, :events_count + + # Create trigger function to update counter cache + execute <<-SQL + CREATE OR REPLACE FUNCTION update_network_range_events_count() + RETURNS TRIGGER AS $$ + BEGIN + -- Update all network ranges that contain the IP address + UPDATE network_ranges + SET events_count = events_count + + CASE + WHEN TG_OP = 'INSERT' THEN 1 + WHEN TG_OP = 'DELETE' THEN -1 + ELSE 0 + END + WHERE network >>= NEW.ip_address::inet; + + RETURN COALESCE(NEW, OLD); + END; + $$ LANGUAGE plpgsql; + SQL + + # Create triggers for events table + execute <<-SQL + CREATE TRIGGER update_network_ranges_events_count_after_insert + AFTER INSERT ON events + FOR EACH ROW + EXECUTE FUNCTION update_network_range_events_count(); + SQL + + execute <<-SQL + CREATE TRIGGER update_network_ranges_events_count_after_delete + AFTER DELETE ON events + FOR EACH ROW + EXECUTE FUNCTION update_network_range_events_count(); + SQL + + # Backfill existing counts + execute <<-SQL + UPDATE network_ranges + SET events_count = ( + SELECT COUNT(*) + FROM events + WHERE events.ip_address <<= network_ranges.network + ); + SQL + end + + def down + # Drop triggers first + execute <<-SQL + DROP TRIGGER IF EXISTS update_network_ranges_events_count_after_insert ON events; + DROP TRIGGER IF EXISTS update_network_ranges_events_count_after_delete ON events; + DROP FUNCTION IF EXISTS update_network_range_events_count(); + SQL + + # Remove column and index + remove_index :network_ranges, :events_count + remove_column :network_ranges, :events_count + end +end diff --git a/db/migrate/20251108042936_create_default_dsn.rb b/db/migrate/20251108042936_create_default_dsn.rb new file mode 100644 index 0000000..f8ff891 --- /dev/null +++ b/db/migrate/20251108042936_create_default_dsn.rb @@ -0,0 +1,20 @@ +class CreateDefaultDsn < ActiveRecord::Migration[8.1] + def change + reversible do |dir| + dir.up do + # Only create if no DSNs exist + if Dsn.count == 0 + Dsn.create!( + name: 'Development DSN', + key: 'dev-test-key-1234567890abcdef', + enabled: true + ) + end + end + dir.down do + # Remove the default DSN if it exists + Dsn.where(key: 'dev-test-key-1234567890abcdef').delete_all + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 9928ffb..cd469f1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,19 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 7) do +ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "dsns", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "enabled", default: true, null: false + t.string "key" + t.string "name" + t.datetime "updated_at", null: false + t.index ["key"], name: "index_dsns_on_key", unique: true + end + create_table "events", force: :cascade do |t| t.string "agent_name" t.string "agent_version" @@ -25,7 +34,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do t.string "event_id", null: false t.inet "ip_address" t.json "payload" - t.bigint "project_id", null: false t.bigint "request_host_id" t.integer "request_method", default: 0 t.string "request_path" @@ -42,10 +50,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do t.integer "waf_action", default: 0, null: false t.index ["event_id"], name: "index_events_on_event_id", unique: true t.index ["ip_address"], name: "index_events_on_ip_address" - t.index ["project_id", "ip_address"], name: "idx_events_project_ip" - t.index ["project_id", "timestamp"], name: "idx_events_project_time" - t.index ["project_id", "waf_action"], name: "idx_events_project_action" - t.index ["project_id"], name: "index_events_on_project_id" t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path" t.index ["request_host_id"], name: "index_events_on_request_host_id" t.index ["request_segment_ids"], name: "index_events_on_request_segment_ids" @@ -62,6 +66,7 @@ ActiveRecord::Schema[8.1].define(version: 7) do t.string "country" t.datetime "created_at", null: false t.text "creation_reason" + t.integer "events_count" t.boolean "is_datacenter", default: false t.boolean "is_proxy", default: false t.boolean "is_vpn", default: false @@ -91,23 +96,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do t.index ["segment"], name: "index_path_segments_on_segment", unique: true end - create_table "projects", force: :cascade do |t| - t.integer "blocked_ip_count", default: 0, null: false - t.datetime "created_at", null: false - t.text "custom_rules", default: "{}", null: false - t.boolean "enabled", default: true, null: false - t.string "name", null: false - t.string "public_key", null: false - t.integer "rate_limit_threshold", default: 100, null: false - t.text "settings", default: "{}", null: false - t.string "slug", null: false - t.datetime "updated_at", null: false - t.index ["enabled"], name: "index_projects_on_enabled" - t.index ["name"], name: "index_projects_on_name" - t.index ["public_key"], name: "index_projects_on_public_key", unique: true - t.index ["slug"], name: "index_projects_on_slug", unique: true - end - create_table "request_actions", force: :cascade do |t| t.string "action", null: false t.datetime "created_at", null: false @@ -182,7 +170,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do t.index ["email_address"], name: "index_users_on_email_address", unique: true end - add_foreign_key "events", "projects" add_foreign_key "events", "request_hosts" add_foreign_key "network_ranges", "users" add_foreign_key "rules", "network_ranges" diff --git a/test/controllers/api/rules_controller_test.rb b/test/controllers/api/rules_controller_test.rb index 407b599..2a21cbf 100644 --- a/test/controllers/api/rules_controller_test.rb +++ b/test/controllers/api/rules_controller_test.rb @@ -5,10 +5,9 @@ require "test_helper" module Api class RulesControllerTest < ActionDispatch::IntegrationTest setup do - @project = Project.create!( - name: "Test Project", - slug: "test-project", - public_key: "test-key-#{SecureRandom.hex(8)}" + @dsn = Dsn.create!( + name: "Test DSN", + key: "test-key-#{SecureRandom.hex(8)}" ) @rule1 = Rule.create!( @@ -27,7 +26,7 @@ module Api end test "version endpoint returns correct structure" do - get "/api/#{@project.public_key}/rules/version" + get "/api/rules/version", headers: { "Authorization" => "Bearer #{@dsn.key}" } assert_response :success @@ -45,21 +44,21 @@ module Api assert_response :unauthorized json = JSON.parse(response.body) - assert_equal "Invalid project key", json["error"] + assert_equal "Invalid DSN key", json["error"] end test "version endpoint rejects disabled projects" do - @project.update!(enabled: false) + @dsn.update!(enabled: false) - get "/api/#{@project.public_key}/rules/version" + get "/api/rules/version" assert_response :forbidden json = JSON.parse(response.body) - assert_equal "Project is disabled", json["error"] + assert_equal "DSN is disabled", json["error"] end test "index endpoint returns all active rules" do - get "/api/#{@project.public_key}/rules" + get "/api/rules" assert_response :success @@ -78,7 +77,7 @@ module Api test "index endpoint excludes disabled rules" do @rule1.update!(enabled: false) - get "/api/#{@project.public_key}/rules" + get "/api/rules" assert_response :success @@ -90,7 +89,7 @@ module Api test "index endpoint excludes expired rules" do @rule1.update!(expires_at: 1.hour.ago) - get "/api/#{@project.public_key}/rules" + get "/api/rules" assert_response :success @@ -104,7 +103,7 @@ module Api @rule1.update_column(:updated_at, 2.hours.ago) since_time = 1.hour.ago.iso8601 - get "/api/#{@project.public_key}/rules?since=#{since_time}" + get "/api/rules?since=#{since_time}" assert_response :success @@ -117,7 +116,7 @@ module Api @rule1.update!(enabled: false) # This updates updated_at since_time = 1.minute.ago.iso8601 - get "/api/#{@project.public_key}/rules?since=#{since_time}" + get "/api/rules?since=#{since_time}" assert_response :success @@ -130,7 +129,7 @@ module Api end test "index endpoint with invalid timestamp returns error" do - get "/api/#{@project.public_key}/rules?since=invalid-timestamp" + get "/api/rules?since=invalid-timestamp" assert_response :bad_request json = JSON.parse(response.body) @@ -144,7 +143,7 @@ module Api end test "index endpoint includes sampling information" do - get "/api/#{@project.public_key}/rules" + get "/api/rules" assert_response :success @@ -180,7 +179,7 @@ module Api conditions: { cidr: "192.168.3.0/24" } ) - get "/api/#{@project.public_key}/rules?since=#{4.hours.ago.iso8601}" + get "/api/rules?since=#{4.hours.ago.iso8601}" assert_response :success diff --git a/test/jobs/path_scanner_detector_job_test.rb b/test/jobs/path_scanner_detector_job_test.rb index 8b1c927..96dc2e8 100644 --- a/test/jobs/path_scanner_detector_job_test.rb +++ b/test/jobs/path_scanner_detector_job_test.rb @@ -4,7 +4,7 @@ require "test_helper" class PathScannerDetectorJobTest < ActiveJob::TestCase setup do - @project = Project.first || Project.create!( + @project = Project.first || #Project.create!( name: "Test Project", slug: "test-project", public_key: SecureRandom.hex(16) diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 034c499..2a8ffbf 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -4,7 +4,6 @@ require "test_helper" class EventTest < ActiveSupport::TestCase def setup - @project = Project.create!(name: "Test Project", slug: "test-project") @sample_payload = { "event_id" => "test-event-123", "timestamp" => Time.now.iso8601, @@ -41,14 +40,12 @@ class EventTest < ActiveSupport::TestCase def teardown Event.delete_all # Delete events first to avoid foreign key constraints - Project.delete_all end test "create_from_waf_payload! creates event with proper enum values" do - event = Event.create_from_waf_payload!("test-123", @sample_payload, @project) + event = Event.create_from_waf_payload!("test-123", @sample_payload) assert event.persisted? - assert_equal @project, event.project assert_equal "test-123", event.event_id assert_equal "192.168.1.1", event.ip_address assert_equal "/api/test", event.request_path @@ -71,7 +68,7 @@ class EventTest < ActiveSupport::TestCase payload["request"]["method"] = method payload["event_id"] = "test-method-#{method.downcase}" - event = Event.create_from_waf_payload!("test-method-#{method.downcase}", payload, @project) + event = Event.create_from_waf_payload!("test-method-#{method.downcase}", payload) assert_equal expected_enums[index].to_s, event.request_method, "Method #{method} should map to enum #{expected_enums[index]}" @@ -96,7 +93,7 @@ class EventTest < ActiveSupport::TestCase payload["waf_action"] = action payload["event_id"] = "test-action-#{action}" - event = Event.create_from_waf_payload!("test-action-#{action}", payload, @project) + event = Event.create_from_waf_payload!("test-action-#{action}", payload) assert_equal expected_enum.to_s, event.waf_action, "Action #{action} should map to enum #{expected_enum}" @@ -113,7 +110,7 @@ class EventTest < ActiveSupport::TestCase "CONTENT-TYPE" => "application/json" } - event = Event.create_from_waf_payload!("test-headers", payload, @project) + event = Event.create_from_waf_payload!("test-headers", payload) assert_equal "TestAgent/1.0", event.user_agent # The normalize_payload_headers method should normalize header keys to lowercase @@ -123,7 +120,7 @@ class EventTest < ActiveSupport::TestCase end test "enum values persist after save and reload" do - event = Event.create_from_waf_payload!("test-persist", @sample_payload, @project) + event = Event.create_from_waf_payload!("test-persist", @sample_payload) # Verify initial values assert_equal "get", event.request_method @@ -232,7 +229,7 @@ class EventTest < ActiveSupport::TestCase end test "payload extraction methods work correctly" do - event = Event.create_from_waf_payload!("extraction-test", @sample_payload, @project) + event = Event.create_from_waf_payload!("extraction-test", @sample_payload) # Test request_details request_details = event.request_details @@ -258,7 +255,7 @@ class EventTest < ActiveSupport::TestCase end test "helper methods work correctly" do - event = Event.create_from_waf_payload!("helper-test", @sample_payload, @project) + event = Event.create_from_waf_payload!("helper-test", @sample_payload) # Test boolean methods assert event.allowed? @@ -284,7 +281,7 @@ class EventTest < ActiveSupport::TestCase payload["timestamp"] = timestamp payload["event_id"] = "timestamp-test-#{index}" - event = Event.create_from_waf_payload!("timestamp-test-#{index}", payload, @project) + event = Event.create_from_waf_payload!("timestamp-test-#{index}", payload) assert event.timestamp.is_a?(Time), "Timestamp #{index} should be parsed as Time" assert_not event.timestamp.nil? end @@ -304,7 +301,7 @@ class EventTest < ActiveSupport::TestCase } } - event = Event.create_from_waf_payload!("minimal-test", minimal_payload, @project) + event = Event.create_from_waf_payload!("minimal-test", minimal_payload) assert event.persisted? assert_equal "10.0.0.1", event.ip_address