Compare commits
13 Commits
v1.9.5
..
dddfa347e6
| Author | SHA1 | Date | |
|---|---|---|---|
| dddfa347e6 | |||
| bd5f2e7e32 | |||
| c5406e2c7c | |||
| c8c483ae95 | |||
| fd2b561308 | |||
| 3e5ee0800e | |||
| 5850898d5b | |||
| ae28d3fb26 | |||
| 4d6a95e2b0 | |||
| d1f0e38374 | |||
| 84d024f4c6 | |||
| 1fa28590b4 | |||
| 30a3ea66d5 |
+2
-1
@@ -4,7 +4,8 @@ CHANGELOG.md
|
||||
*.md
|
||||
|
||||
# Deployment files
|
||||
deployment.yml
|
||||
deployment-dev.yml
|
||||
deployment-prod.yml
|
||||
run.sh
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
@@ -14,21 +14,5 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t cr.jdbnet.co.uk/public/ipam:dev \
|
||||
--build-arg VERSION=dev \
|
||||
.
|
||||
docker push cr.jdbnet.co.uk/public/ipam:dev
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
needs: release
|
||||
runs-on: k3s-internal-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
run: |
|
||||
sudo kubectl replace -f deployment-dev.yml --grace-period=60 --force
|
||||
docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
|
||||
docker push cr.jdbnet.co.uk/public/ipam:dev
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release
|
||||
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
@@ -45,16 +46,29 @@ jobs:
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
needs: release
|
||||
runs-on: k3s-internal-htz-01
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
- name: Create Valid Project Key
|
||||
id: sonar_setup
|
||||
run: |
|
||||
sudo kubectl replace -f deployment-prod.yml --grace-period=60 --force
|
||||
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
|
||||
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: SonarQube Scan
|
||||
uses: sonarsource/sonarqube-scan-action@master
|
||||
continue-on-error: true
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
|
||||
-Dsonar.projectName=${{ gitea.repository }}
|
||||
-Dsonar.qualitygate.wait=true
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://projects.jdbnet.co.uk/ipam/img/favicon.png" alt="IPAM" width="200" />
|
||||
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
|
||||
|
||||
# IP Address Management
|
||||
</div>
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""
|
||||
In-memory caching module with TTL support and cache invalidation
|
||||
"""
|
||||
import time
|
||||
import sys
|
||||
from threading import Lock
|
||||
from functools import wraps
|
||||
|
||||
class Cache:
|
||||
"""Simple in-memory cache with TTL support and size limiting"""
|
||||
|
||||
def __init__(self, max_size_mb=50):
|
||||
self._cache = {}
|
||||
self._lock = Lock()
|
||||
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
|
||||
self._access_order = [] # Track access order for LRU eviction
|
||||
|
||||
def _get_size(self, obj):
|
||||
"""Estimate size of an object in bytes"""
|
||||
size = sys.getsizeof(obj)
|
||||
if isinstance(obj, dict):
|
||||
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
size += sum(self._get_size(item) for item in obj)
|
||||
elif isinstance(obj, str):
|
||||
size += sys.getsizeof(obj) - sys.getsizeof('')
|
||||
return size
|
||||
|
||||
def _get_cache_size(self):
|
||||
"""Get approximate total size of cache in bytes"""
|
||||
total_size = sys.getsizeof(self._cache)
|
||||
for key, (value, expiry) in self._cache.items():
|
||||
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
|
||||
return total_size
|
||||
|
||||
def _evict_if_needed(self):
|
||||
"""Evict entries if cache exceeds size limit"""
|
||||
current_size = self._get_cache_size()
|
||||
if current_size <= self._max_size_bytes:
|
||||
return
|
||||
|
||||
# First, remove expired entries
|
||||
current_time = time.time()
|
||||
expired_keys = []
|
||||
for key in list(self._cache.keys()):
|
||||
_, expiry = self._cache[key]
|
||||
if expiry is not None and current_time >= expiry:
|
||||
expired_keys.append(key)
|
||||
|
||||
for key in expired_keys:
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
# If still over limit, remove oldest entries (LRU)
|
||||
current_size = self._get_cache_size()
|
||||
while current_size > self._max_size_bytes and self._access_order:
|
||||
oldest_key = self._access_order.pop(0)
|
||||
if oldest_key in self._cache:
|
||||
del self._cache[oldest_key]
|
||||
current_size = self._get_cache_size()
|
||||
|
||||
def get(self, key):
|
||||
"""Get value from cache if it exists and hasn't expired"""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
value, expiry = self._cache[key]
|
||||
if expiry is None or time.time() < expiry:
|
||||
# Update access order (move to end for LRU)
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
self._access_order.append(key)
|
||||
return value
|
||||
else:
|
||||
# Expired, remove it
|
||||
del self._cache[key]
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
return None
|
||||
|
||||
def set(self, key, value, ttl=None):
|
||||
"""Set value in cache with optional TTL (time to live in seconds)"""
|
||||
with self._lock:
|
||||
# Remove old entry if it exists
|
||||
if key in self._cache:
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
expiry = None if ttl is None else time.time() + ttl
|
||||
self._cache[key] = (value, expiry)
|
||||
self._access_order.append(key)
|
||||
|
||||
# Evict if needed to stay under size limit
|
||||
self._evict_if_needed()
|
||||
|
||||
def delete(self, key):
|
||||
"""Delete a key from cache"""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
def clear(self, pattern=None):
|
||||
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
|
||||
with self._lock:
|
||||
if pattern is None:
|
||||
self._cache.clear()
|
||||
self._access_order.clear()
|
||||
else:
|
||||
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
def invalidate_subnet(self, subnet_id):
|
||||
"""Invalidate all cache entries related to a specific subnet"""
|
||||
patterns = [
|
||||
f'subnet:{subnet_id}',
|
||||
f'subnet_list',
|
||||
f'index',
|
||||
f'admin',
|
||||
f'utilization:{subnet_id}'
|
||||
]
|
||||
with self._lock:
|
||||
keys_to_delete = []
|
||||
for key in self._cache.keys():
|
||||
for pattern in patterns:
|
||||
if pattern in key:
|
||||
keys_to_delete.append(key)
|
||||
break
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
def invalidate_device(self, device_id):
|
||||
"""Invalidate all cache entries related to a specific device"""
|
||||
patterns = [
|
||||
f'device:{device_id}',
|
||||
f'device_list',
|
||||
f'devices',
|
||||
f'device_types'
|
||||
]
|
||||
with self._lock:
|
||||
keys_to_delete = []
|
||||
for key in self._cache.keys():
|
||||
for pattern in patterns:
|
||||
if pattern in key:
|
||||
keys_to_delete.append(key)
|
||||
break
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
def invalidate_all(self):
|
||||
"""Invalidate all cache entries"""
|
||||
self.clear()
|
||||
|
||||
# Global cache instance
|
||||
cache = Cache()
|
||||
|
||||
def cached(ttl=None, key_prefix=''):
|
||||
"""
|
||||
Decorator to cache function results
|
||||
|
||||
Args:
|
||||
ttl: Time to live in seconds (None = no expiration)
|
||||
key_prefix: Prefix for cache key
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Create cache key from function name, args, and kwargs
|
||||
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
|
||||
|
||||
# Try to get from cache
|
||||
cached_value = cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Call function and cache result
|
||||
result = func(*args, **kwargs)
|
||||
cache.set(cache_key, result, ttl)
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@@ -6,6 +6,7 @@ import mysql.connector
|
||||
import logging
|
||||
from flask import current_app
|
||||
|
||||
# ── Connection, crypto, schema init ─────────────────────────────────────────
|
||||
def hash_password(password, salt=None):
|
||||
if salt is None:
|
||||
salt = base64.b64encode(os.urandom(16)).decode('utf-8')
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ipam
|
||||
namespace: ipam
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ipam
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ipam
|
||||
spec:
|
||||
containers:
|
||||
- name: ipam
|
||||
image: cr.jdbnet.co.uk/public/ipam:dev
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
name: "ipam"
|
||||
env:
|
||||
- name: SECRET_KEY
|
||||
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
|
||||
- name: MYSQL_HOST
|
||||
value: "10.10.25.4"
|
||||
- name: MYSQL_USER
|
||||
value: "ipam"
|
||||
- name: MYSQL_PASSWORD
|
||||
value: "WXPmo05sGCfjGe"
|
||||
- name: MYSQL_DATABASE
|
||||
value: "ipam"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ipam-ingress-service
|
||||
namespace: ipam
|
||||
spec:
|
||||
selector:
|
||||
app: ipam
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 5000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ipam-ingress
|
||||
namespace: ipam
|
||||
spec:
|
||||
rules:
|
||||
- host: ipam.jdb143.uk
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: ipam-ingress-service
|
||||
port:
|
||||
number: 80
|
||||
@@ -1,64 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ipam
|
||||
namespace: ipam
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ipam
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ipam
|
||||
spec:
|
||||
containers:
|
||||
- name: ipam
|
||||
image: cr.jdbnet.co.uk/public/ipam:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
name: "ipam"
|
||||
env:
|
||||
- name: SECRET_KEY
|
||||
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
|
||||
- name: MYSQL_HOST
|
||||
value: "10.10.25.4"
|
||||
- name: MYSQL_USER
|
||||
value: "ipam"
|
||||
- name: MYSQL_PASSWORD
|
||||
value: "WXPmo05sGCfjGe"
|
||||
- name: MYSQL_DATABASE
|
||||
value: "ipam"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ipam-ingress-service
|
||||
namespace: ipam
|
||||
spec:
|
||||
selector:
|
||||
app: ipam
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 5000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ipam-ingress
|
||||
namespace: ipam
|
||||
spec:
|
||||
rules:
|
||||
- host: ipam.jdb143.uk
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: ipam-ingress-service
|
||||
port:
|
||||
number: 80
|
||||
+1
-2
@@ -4,5 +4,4 @@ dotenv
|
||||
gunicorn
|
||||
requests
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
Flask-Limiter
|
||||
qrcode[pil]
|
||||
+88
-4
@@ -1,12 +1,96 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const navToggle = document.getElementById('nav-toggle');
|
||||
const mobileNav = document.getElementById('mobile-nav');
|
||||
navToggle.addEventListener('click', function() {
|
||||
mobileNav.classList.toggle('hidden');
|
||||
});
|
||||
const searchModal = document.getElementById('search-modal');
|
||||
const searchModalOpen = document.getElementById('search-modal-open');
|
||||
const searchModalOpenMobile = document.getElementById('search-modal-open-mobile');
|
||||
const searchModalClose = document.getElementById('search-modal-close');
|
||||
const searchModalBackdrop = document.getElementById('search-modal-backdrop');
|
||||
const searchModalInput = document.getElementById('search-modal-input');
|
||||
|
||||
if (navToggle && mobileNav) {
|
||||
navToggle.addEventListener('click', function() {
|
||||
mobileNav.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
function openSearchModal() {
|
||||
if (!searchModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchModal.classList.remove('hidden');
|
||||
searchModal.classList.add('flex');
|
||||
document.body.classList.add('overflow-hidden');
|
||||
|
||||
if (mobileNav) {
|
||||
mobileNav.classList.add('hidden');
|
||||
}
|
||||
|
||||
setTimeout(function() {
|
||||
if (searchModalInput) {
|
||||
searchModalInput.focus();
|
||||
searchModalInput.select();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function closeSearchModal() {
|
||||
if (!searchModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchModal.classList.add('hidden');
|
||||
searchModal.classList.remove('flex');
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
}
|
||||
|
||||
if (searchModalOpen) {
|
||||
searchModalOpen.addEventListener('click', openSearchModal);
|
||||
}
|
||||
|
||||
if (searchModalOpenMobile) {
|
||||
searchModalOpenMobile.addEventListener('click', openSearchModal);
|
||||
}
|
||||
|
||||
if (searchModalClose) {
|
||||
searchModalClose.addEventListener('click', closeSearchModal);
|
||||
}
|
||||
|
||||
if (searchModalBackdrop) {
|
||||
searchModalBackdrop.addEventListener('click', closeSearchModal);
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
|
||||
if (mobileNav && navToggle && !mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
|
||||
mobileNav.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
const target = e.target;
|
||||
const isEditableTarget = target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
);
|
||||
|
||||
if (
|
||||
e.key === '/' &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
searchModal &&
|
||||
!isEditableTarget
|
||||
) {
|
||||
e.preventDefault();
|
||||
openSearchModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
closeSearchModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
document.addEventListener("DOMContentLoaded",function(){let t=document.getElementById("nav-toggle"),e=document.getElementById("mobile-nav");t.addEventListener("click",function(){e.classList.toggle("hidden")}),document.addEventListener("click",function(n){e.contains(n.target)||t.contains(n.target)||e.classList.add("hidden")})});
|
||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("nav-toggle"),t=document.getElementById("mobile-nav"),o=document.getElementById("search-modal"),n=document.getElementById("search-modal-open"),d=document.getElementById("search-modal-open-mobile"),c=document.getElementById("search-modal-close"),l=document.getElementById("search-modal-backdrop"),a=document.getElementById("search-modal-input");function s(){o&&(o.classList.remove("hidden"),o.classList.add("flex"),document.body.classList.add("overflow-hidden"),t&&t.classList.add("hidden"),setTimeout(function(){a&&(a.focus(),a.select())},0))}function i(){o&&(o.classList.add("hidden"),o.classList.remove("flex"),document.body.classList.remove("overflow-hidden"))}e&&t&&e.addEventListener("click",function(){t.classList.toggle("hidden")}),n&&n.addEventListener("click",s),d&&d.addEventListener("click",s),c&&c.addEventListener("click",i),l&&l.addEventListener("click",i),document.addEventListener("click",function(o){t&&e&&!t.contains(o.target)&&!e.contains(o.target)&&t.classList.add("hidden")}),document.addEventListener("keydown",function(e){let t=e.target,n=t&&("INPUT"===t.tagName||"TEXTAREA"===t.tagName||"SELECT"===t.tagName||t.isContentEditable);if("/"===e.key&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&o&&!n)return e.preventDefault(),void s();"Escape"===e.key&&i()})});
|
||||
+43
-25
@@ -6,17 +6,13 @@
|
||||
</a>
|
||||
<a href="https://git.jdbnet.co.uk/jamie/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">{{ VERSION }}</a>
|
||||
</div>
|
||||
<div class="hidden lg:flex items-center justify-center absolute left-1/2" style="transform: translateX(calc(-50% + 1.5rem));">
|
||||
<form action="/search" method="GET" class="flex items-center space-x-2">
|
||||
<input type="text" name="q" id="search-input" placeholder="Search..."
|
||||
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
|
||||
value="{{ request.args.get('q', '') }}">
|
||||
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
|
||||
{% if current_user_name %}
|
||||
<button type="button" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2 hover:cursor-pointer" id="search-modal-open" aria-label="Open search modal">
|
||||
<i class="fas fa-search"></i>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if has_permission('view_index') %}
|
||||
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-home"></i>
|
||||
@@ -58,22 +54,19 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lg:hidden flex items-center gap-3 flex-shrink-0">
|
||||
{% if current_user_name %}
|
||||
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="search-modal-open-mobile" aria-label="Open search modal">
|
||||
<i class="fas fa-search text-xl"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
|
||||
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="text" name="q" placeholder="Search..."
|
||||
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
|
||||
value="{{ request.args.get('q', '') }}">
|
||||
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if has_permission('view_index') %}
|
||||
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-home"></i>
|
||||
@@ -115,6 +108,31 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_user_name %}
|
||||
<div id="search-modal" class="hidden fixed inset-0 z-50 items-center justify-center p-4">
|
||||
<div id="search-modal-backdrop" class="absolute inset-0 bg-black/60"></div>
|
||||
<div class="relative w-full max-w-2xl rounded-lg bg-zinc-800 border border-zinc-700 shadow-xl">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
|
||||
<h3 class="text-white font-semibold text-lg">Search</h3>
|
||||
<button type="button" id="search-modal-close" class="text-gray-300 hover:text-white hover:cursor-pointer" aria-label="Close search modal">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/search" method="GET" class="p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" name="q" id="search-modal-input" placeholder="Search..."
|
||||
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-full"
|
||||
value="{{ request.args.get('q', '') }}">
|
||||
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0" aria-label="Submit search">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/js/header.min.js"></script>
|
||||
|
||||
<!-- Update Available Toast -->
|
||||
|
||||
+11
-5
@@ -24,12 +24,18 @@
|
||||
</div>
|
||||
<ul class="subnet-list hidden space-y-4 px-2 pb-4">
|
||||
{% for subnet in subnets %}
|
||||
<li class="p-4 bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 hover:dark:bg-zinc-700 rounded-lg shadow-md flex items-center justify-between">
|
||||
<a href="/subnet/{{ subnet.id }}">
|
||||
<li class="relative p-4 bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 hover:dark:bg-zinc-700 rounded-lg shadow-md flex items-center justify-between">
|
||||
<a href="/subnet/{{ subnet.id }}" class="absolute inset-0 z-0 rounded-lg"></a>
|
||||
<div class="pointer-events-none z-10 flex-grow">
|
||||
<p class="text-gray-900 dark:text-white text-lg font-medium">{{ subnet.name }}</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
|
||||
</a>
|
||||
<button type="button" class="export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 hover:cursor-pointer flex items-center justify-center rounded-full w-9 h-9" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
|
||||
{% if subnet.vlan_id %}
|
||||
<span class="px-2 py-0.5 text-xs font-semibold bg-gray-200 dark:bg-zinc-800 rounded-full">VLAN {{ subnet.vlan_id }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="relative z-10 export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 hover:cursor-pointer flex items-center justify-center rounded-full w-9 h-9 shrink-0" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
|
||||
<i class="fas fa-file-csv"></i>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import pyotp
|
||||
import qrcode
|
||||
import secrets
|
||||
import json
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from flask import current_app
|
||||
|
||||
def generate_totp_secret():
|
||||
"""Generate a new TOTP secret"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
def get_totp_uri(secret, email, issuer_name="IPAM"):
|
||||
"""Generate TOTP URI for QR code"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(
|
||||
name=email,
|
||||
issuer_name=issuer_name
|
||||
)
|
||||
|
||||
def generate_qr_code(uri):
|
||||
"""Generate QR code image from URI"""
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
def verify_totp(secret, code):
|
||||
"""Verify a TOTP code"""
|
||||
if not secret or not code:
|
||||
return False
|
||||
try:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1) # Allow 1 time step window for clock skew
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generate_backup_codes(count=10):
|
||||
"""Generate backup codes for 2FA"""
|
||||
return [secrets.token_urlsafe(8).upper() for _ in range(count)]
|
||||
|
||||
def verify_backup_code(backup_codes_json, code):
|
||||
"""Verify a backup code and remove it if valid"""
|
||||
if not backup_codes_json or not code:
|
||||
return False, None
|
||||
|
||||
try:
|
||||
codes = json.loads(backup_codes_json)
|
||||
code_upper = code.upper().strip()
|
||||
if code_upper in codes:
|
||||
codes.remove(code_upper)
|
||||
return True, json.dumps(codes) if codes else None
|
||||
return False, None
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return False, None
|
||||
|
||||
def format_backup_codes(codes):
|
||||
"""Format backup codes for display (group in pairs)"""
|
||||
formatted = []
|
||||
for i in range(0, len(codes), 2):
|
||||
if i + 1 < len(codes):
|
||||
formatted.append(f"{codes[i]} {codes[i+1]}")
|
||||
else:
|
||||
formatted.append(codes[i])
|
||||
return formatted
|
||||
|
||||
Reference in New Issue
Block a user