diff --git a/frontend/src/components/SessionList.vue b/frontend/src/components/SessionList.vue
index 2f0c980..f538df1 100644
--- a/frontend/src/components/SessionList.vue
+++ b/frontend/src/components/SessionList.vue
@@ -30,7 +30,7 @@
@keydown="handleItemKeydown($event, session)"
>
-
{{ session.user_agent }}
+
{{ session.user_agent || '—' }}
Current
Selected
diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py
index 9ebd3a1..1af4265 100644
--- a/paskia/fastapi/api.py
+++ b/paskia/fastapi/api.py
@@ -17,7 +17,7 @@ from paskia import db
from paskia.authsession import EXPIRES, expires, get_reset
from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
-from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
+from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
@@ -91,7 +91,7 @@ async def validate_token(
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
auth,
- ip=request.client.host if request.client else "",
+ ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "",
expiry=expires(),
ctx=ctx,
diff --git a/paskia/fastapi/logging.py b/paskia/fastapi/logging.py
index 09f8e6c..731174f 100644
--- a/paskia/fastapi/logging.py
+++ b/paskia/fastapi/logging.py
@@ -38,10 +38,33 @@ _AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
def format_ipv6_network(ip: str) -> str:
- """Format IPv6 address to show only network part (first 64 bits)."""
+ """Format IPv6 address to show only network part (first 64 bits).
+
+ Special addresses are returned as-is for clarity:
+ - ::1 (loopback)
+ - :: (unspecified)
+ - ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
+ - fe80:: (link-local, returned as-is since interface-specific)
+ """
try:
+ # Strip brackets that some proxies add around IPv6
+ ip = ip.strip("[]")
+ # Strip zone ID (e.g., fe80::1%eth0)
+ if "%" in ip:
+ ip = ip.split("%")[0]
addr = IPv6Address(ip)
- # Get the integer representation and mask to first 64 bits
+
+ # Special cases - return as-is or with minimal processing
+ if addr.is_loopback: # ::1
+ return "::1"
+ if addr.is_unspecified: # ::
+ return "::"
+ if addr.ipv4_mapped: # ::ffff:x.x.x.x
+ return str(addr.ipv4_mapped)
+ if addr.is_link_local: # fe80::/10 - interface-specific, keep full
+ return str(addr)
+
+ # Regular addresses: truncate to /64 network prefix
network_int = int(addr) >> 64
# Format as IPv6 with trailing ::
# Split into 4 groups of 16 bits
@@ -61,7 +84,9 @@ def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
- if ":" in ip:
+ # Strip brackets for detection (some proxies add them)
+ stripped = ip.strip("[]")
+ if ":" in stripped:
return format_ipv6_network(ip)
return ip
diff --git a/paskia/fastapi/session.py b/paskia/fastapi/session.py
index e86b207..69a3696 100644
--- a/paskia/fastapi/session.py
+++ b/paskia/fastapi/session.py
@@ -8,6 +8,8 @@ This module provides FastAPI-specific session management functionality:
Generic session management functions have been moved to authsession.py
"""
+from ipaddress import IPv4Address, IPv6Address
+
from fastapi import Cookie, Request, Response, WebSocket
from paskia.authsession import EXPIRES
@@ -16,10 +18,44 @@ AUTH_COOKIE_NAME = "__Host-paskia"
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
+def normalize_ip(ip: str) -> str:
+ """Normalize IP address, stripping brackets and validating format.
+
+ Proxies may pass IPv6 in brackets like [::1] or with zone IDs.
+ IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are converted to plain IPv4.
+ Returns empty string for invalid addresses.
+ """
+ if not ip:
+ return ""
+ # Strip brackets that some proxies add around IPv6
+ ip = ip.strip("[]")
+ # Strip zone ID (e.g., fe80::1%eth0)
+ if "%" in ip:
+ ip = ip.split("%")[0]
+ try:
+ # Validate and normalize
+ if ":" in ip:
+ addr = IPv6Address(ip)
+ # Convert IPv4-mapped addresses to plain IPv4
+ if addr.ipv4_mapped:
+ return str(addr.ipv4_mapped)
+ return str(addr)
+ return str(IPv4Address(ip))
+ except ValueError:
+ return ip # Return as-is if not a valid IP (could be hostname)
+
+
+def get_client_ip(request: Request | WebSocket) -> str:
+ """Get client IP from request, normalized."""
+ if not request.client:
+ return ""
+ return normalize_ip(request.client.host)
+
+
def infodict(request: Request | WebSocket, type: str) -> dict:
"""Extract client information from request."""
return {
- "ip": request.client.host if request.client else "",
+ "ip": get_client_ip(request),
"user_agent": request.headers.get("user-agent", "")[:500],
"session_type": type,
}
diff --git a/paskia/util/useragent.py b/paskia/util/useragent.py
index 418d004..fdb8e1d 100644
--- a/paskia/util/useragent.py
+++ b/paskia/util/useragent.py
@@ -1,10 +1,29 @@
-import user_agents
+from ua_parser import parse
def compact_user_agent(ua: str | None) -> str:
- if not ua:
- return "-"
- u = user_agents.parse(ua)
- ver = u.browser.version_string.split(".")[0]
- dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
- return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
+ """Format user agent string into a compact display format.
+
+ Returns empty string for empty/missing user agents.
+ Returns original UA for unrecognized ones.
+ """
+ if not ua or not ua.strip() or ua == "-":
+ return ""
+ r = parse(ua)
+ browser = r.user_agent.family if r.user_agent else None
+ ver = r.user_agent.major if r.user_agent else ""
+ os_name = r.os.family if r.os else None
+ dev = r.device.family if r.device else None
+ # If browser is unrecognized, return original UA
+ if browser in (None, "Other") and os_name in (None, "Other"):
+ return ua
+ # Filter out "Other" values
+ browser = browser if browser and browser != "Other" else ""
+ os_name = os_name if os_name and os_name != "Other" else ""
+ # Exclude device if it's "Other" or matches browser family (parser bug)
+ if dev in (None, "Other") or dev == browser:
+ dev = ""
+ # Build compact string, filtering empty parts
+ parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
+ result = " ".join(p for p in parts if p).strip()
+ return result
diff --git a/pyproject.toml b/pyproject.toml
index fb2cd22..b6ebf03 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,11 +18,11 @@ dependencies = [
"base64url>=1.0.0",
"uuid7-standard>=1.0.0",
"pyjwt>=2.8.0",
- "user-agents>=2.2.0",
"jsondiff>=2.2.1",
"msgspec>=0.20.0",
"aiofiles>=25.1.0",
"fastapi-vue>=0.3.0",
+ "ua-parser[regex]>=1.0.1",
]
requires-python = ">=3.11"