Compare commits

..

2 Commits

Author SHA1 Message Date
70d0e808f8 Updated agent to include ping in heartbeat. 2026-02-03 15:24:11 +00:00
bebaaf1367 Refactor to Flowbite for UI 2026-02-03 09:54:49 +00:00
20 changed files with 783 additions and 345 deletions

View File

@@ -144,11 +144,16 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
func reportHost(ctx context.Context, apiClient *client.Client, cfg *config.Config) error { func reportHost(ctx context.Context, apiClient *client.Client, cfg *config.Config) error {
info := host.Detect() info := host.Detect()
var pingPtr *int
if pingMs, err := apiClient.Ping(ctx); err == nil {
pingPtr = &pingMs
}
return retry(ctx, []time.Duration{250 * time.Millisecond, time.Second, 2 * time.Second}, func() error { return retry(ctx, []time.Duration{250 * time.Millisecond, time.Second, 2 * time.Second}, func() error {
return apiClient.UpdateHost(ctx, cfg.ServerID, client.HeartbeatRequest{ return apiClient.UpdateHost(ctx, cfg.ServerID, client.HeartbeatRequest{
Host: info.Hostname, Host: info.Hostname,
IPv4: info.IPv4, IPv4: info.IPv4,
IPv6: info.IPv6, IPv6: info.IPv6,
PingMs: pingPtr,
}) })
}) })
} }

View File

@@ -2,7 +2,7 @@
"server_url": "https://keywarden.dev.ntbx.io/api/v1", "server_url": "https://keywarden.dev.ntbx.io/api/v1",
"server_id": "4", "server_id": "4",
"server_ca_path": "", "server_ca_path": "",
"sync_interval_seconds": 30, "sync_interval_seconds": 5,
"log_batch_size": 500, "log_batch_size": 500,
"state_dir": "/var/lib/keywarden-agent", "state_dir": "/var/lib/keywarden-agent",
"account_policy": { "account_policy": {

View File

@@ -8,7 +8,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
@@ -22,6 +24,10 @@ const defaultTimeout = 15 * time.Second
type Client struct { type Client struct {
baseURL string baseURL string
http *http.Client http *http.Client
tlsCfg *tls.Config
scheme string
host string
addr string
} }
func New(cfg *config.Config) (*Client, error) { func New(cfg *config.Config) (*Client, error) {
@@ -62,7 +68,36 @@ func New(cfg *config.Config) (*Client, error) {
Transport: transport, Transport: transport,
} }
return &Client{baseURL: baseURL, http: httpClient}, nil parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parse server url: %w", err)
}
if parsed.Host == "" {
return nil, errors.New("server url missing host")
}
scheme := parsed.Scheme
if scheme == "" {
scheme = "https"
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
if scheme == "http" {
port = "80"
} else {
port = "443"
}
}
addr := net.JoinHostPort(host, port)
return &Client{
baseURL: baseURL,
http: httpClient,
tlsCfg: tlsConfig,
scheme: scheme,
host: host,
addr: addr,
}, nil
} }
type EnrollRequest struct { type EnrollRequest struct {
@@ -293,9 +328,10 @@ func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []by
} }
type HeartbeatRequest struct { type HeartbeatRequest struct {
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
IPv4 string `json:"ipv4,omitempty"` IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"` IPv6 string `json:"ipv6,omitempty"`
PingMs *int `json:"ping_ms,omitempty"`
} }
func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody HeartbeatRequest) error { func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody HeartbeatRequest) error {
@@ -318,3 +354,29 @@ func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody Heartb
} }
return nil return nil
} }
func (c *Client) Ping(ctx context.Context) (int, error) {
if c.addr == "" {
return 0, errors.New("server address not configured")
}
start := time.Now()
dialer := &net.Dialer{Timeout: defaultTimeout}
if c.scheme == "http" {
conn, err := dialer.DialContext(ctx, "tcp", c.addr)
if err != nil {
return 0, err
}
_ = conn.Close()
return int(time.Since(start).Milliseconds()), nil
}
cfg := c.tlsCfg.Clone()
if cfg.ServerName == "" && c.host != "" {
cfg.ServerName = c.host
}
conn, err := tls.DialWithDialer(dialer, "tcp", c.addr, cfg)
if err != nil {
return 0, err
}
_ = conn.Close()
return int(time.Since(start).Milliseconds()), nil
}

Binary file not shown.

View File

@@ -8,7 +8,7 @@ class ErasureRequestForm(forms.Form):
attrs={ attrs={
"rows": 4, "rows": 4,
"placeholder": "Explain why you are requesting data erasure.", "placeholder": "Explain why you are requesting data erasure.",
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", "class": "block w-full resize-y rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
} }
), ),
min_length=10, min_length=10,
@@ -18,22 +18,22 @@ class ErasureRequestForm(forms.Form):
class SSHKeyForm(forms.Form): class SSHKeyForm(forms.Form):
name = forms.CharField( name = forms.CharField(
label="Key name", label="Key Name",
max_length=128, max_length=128,
widget=forms.TextInput( widget=forms.TextInput(
attrs={ attrs={
"placeholder": "MacBook Pro", "placeholder": "Device Name",
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", "class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
} }
), ),
) )
public_key = forms.CharField( public_key = forms.CharField(
label="SSH public key", label="SSH Public Key",
widget=forms.Textarea( widget=forms.Textarea(
attrs={ attrs={
"rows": 4, "rows": 4,
"placeholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB... you@host", "placeholder": "ssh-ed25519 AAAaBBbBcCcc111122223333... user@host",
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", "class": "block w-full resize-y rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
} }
), ),
) )

View File

@@ -4,32 +4,53 @@
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8"> <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1> <div class="space-y-2">
<form method="post" class="space-y-4"> <h1 class="text-2xl font-semibold tracking-tight text-gray-900">Welcome back</h1>
<p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p>
</div>
<form method="post" class="mt-6 space-y-5">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}"> <input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
<div class="space-y-1.5"> <div>
<label class="block text-sm font-medium text-gray-700">Username</label> <label class="mb-2 block text-sm font-medium text-gray-900">Username</label>
<input type="text" name="username" autocomplete="username" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600"> <input
type="text"
name="username"
autocomplete="username"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div> </div>
<div class="space-y-1.5"> <div>
<label class="block text-sm font-medium text-gray-700">Password</label> <label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
<input type="password" name="password" autocomplete="current-password" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600"> <input
type="password"
name="password"
autocomplete="current-password"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div> </div>
{% if form.errors %} {% if form.errors %}
<p class="text-sm text-red-600">Please check your username and password.</p> <div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
<span class="font-medium">Sign-in failed.</span>
<span>Please check your username and password.</span>
</div>
{% endif %} {% endif %}
<div class="pt-2"> <button
<button type="submit" class="inline-flex w-full items-center justify-center rounded-md bg-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600"> type="submit"
Sign in class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
</button> >
</div> Sign in
</button>
</form> </form>
<div class="mt-6 border-t border-gray-200 pt-6"> <div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Or, if configured, use Or, if configured, use
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">OIDC login</a>. <a href="/oidc/authenticate/" class="font-medium text-blue-700 hover:underline">OIDC login</a>.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -3,136 +3,225 @@
{% block title %}Profile • Keywarden{% endblock %} {% block title %}Profile • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="space-y-6">
<div> <div class="grid gap-6 lg:grid-cols-3">
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1> <div class="space-y-2">
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> <h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
<div> <p class="text-sm text-gray-500">Account details and contact information.</p>
<dt class="text-sm font-medium text-gray-500">Username</dt> </div>
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd> <dl class="mt-6 grid grid-cols-1 gap-4 text-sm text-gray-600 sm:grid-cols-2">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Username</dt>
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.username }}</dd>
</div> </div>
<div> <div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-sm font-medium text-gray-500">Email</dt> <dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Email</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.email }}</dd> <dd class="mt-2 text-sm font-medium text-gray-900">{{ user.email }}</dd>
</div> </div>
<div> <div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-sm font-medium text-gray-500">First name</dt> <dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">First name</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.first_name|default:"—" }}</dd> <dd class="mt-2 text-sm font-medium text-gray-900">{{ user.first_name|default:"—" }}</dd>
</div> </div>
<div> <div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-sm font-medium text-gray-500">Last name</dt> <dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Last name</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.last_name|default:"—" }}</dd> <dd class="mt-2 text-sm font-medium text-gray-900">{{ user.last_name|default:"—" }}</dd>
</div> </div>
</dl> </dl>
</div> </section>
</div>
<div>
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="mb-4 text-base font-semibold tracking-tight text-gray-900">Single Sign-On</h2>
{% if auth_mode == "hybrid" %}
<div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600">
Optional: Link your account with your identity provider for single sign-on.
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">Link with SSO</a>
</p>
</div>
{% elif auth_mode == "oidc" %}
<p class="text-sm text-gray-600">OIDC is required. Sign-in is managed by your identity provider.</p>
{% else %}
<p class="text-sm text-gray-600">OIDC is disabled. You are using native authentication.</p>
{% endif %}
</div>
</div>
</div>
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-base font-semibold tracking-tight text-gray-900">SSH certificates</h2> <div class="space-y-2">
<p class="mt-2 text-sm text-gray-600"> <h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
Upload your SSH public key to receive a signed certificate for server access. <p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
</p> </div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
{% if auth_mode == "hybrid" %}
Optional: Link your account with your identity provider for single sign-on.
<a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a>
{% elif auth_mode == "oidc" %}
OIDC is required. Sign-in is managed by your identity provider.
{% else %}
OIDC is disabled. You are using native authentication.
{% endif %}
</div>
</section>
</div>
{% if can_add_key %} <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<form method="post" class="mt-4 space-y-3"> <div class="flex flex-wrap items-start justify-between gap-4">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div> <div>
<label for="{{ key_form.name.id_for_label }}" class="block text-sm font-medium text-gray-700"> <h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
Key name <p class="mt-1 text-sm text-gray-500">
</label> Upload your SSH public key to receive a signed certificate for server access.
<div class="mt-1"> </p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Certificates</span>
</div>
{% if can_add_key %}
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Key name
</label>
{{ key_form.name }} {{ key_form.name }}
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div> </div>
{% if key_form.name.errors %} <div class="lg:col-span-2">
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p> <label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
{% endif %} SSH public key
</div> </label>
<div>
<label for="{{ key_form.public_key.id_for_label }}" class="block text-sm font-medium text-gray-700">
SSH public key
</label>
<div class="mt-1">
{{ key_form.public_key }} {{ key_form.public_key }}
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div> </div>
{% if key_form.public_key.errors %} {% if key_form.non_field_errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p> <p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Upload key
</button>
</div>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
<table class="w-full text-left text-sm text-gray-500">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th scope="col" class="px-6 py-3">Key</th>
<th scope="col" class="px-6 py-3">Fingerprint</th>
<th scope="col" class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for key in ssh_keys %}
<tr class="border-t bg-white">
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
{{ key.name }}
</th>
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap items-center justify-end gap-2">
{% if key.is_active %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Data erasure request</h2>
<p class="mt-1 text-sm text-gray-500">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">GDPR</span>
</div>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %} {% endif %}
</div> </div>
{% if key_form.non_field_errors %} {% endif %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
Upload key
</button>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %} {% if not erasure_request or erasure_request.status != "pending" %}
<div class="mt-6 divide-y divide-gray-200"> <form method="post" class="mt-6 grid gap-4">
{% for key in ssh_keys %} {% csrf_token %}
<div class="flex items-center justify-between py-4"> <input type="hidden" name="form_type" value="erasure">
<div> <div>
<p class="text-sm font-semibold text-gray-900">{{ key.name }}</p> <label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
<p class="text-xs text-gray-500">{{ key.fingerprint }}</p> Reason for request
</div> </label>
<div class="flex items-center gap-3"> {{ erasure_form.reason }}
{% if key.is_active %} {% if erasure_form.reason.errors %}
<div class="inline-flex overflow-hidden rounded-md shadow-sm"> <p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
<button {% endif %}
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="text-xs font-semibold text-gray-500">Revoked</span>
{% endif %}
</div>
</div> </div>
{% endfor %} {% if erasure_form.non_field_errors %}
</div> <p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% else %} {% endif %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p> <div>
{% endif %} <button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit erasure request
</button>
</div>
</form>
{% endif %}
</section>
</div> </div>
<script> <script>
(function () { (function () {
function getCookie(name) { function getCookie(name) {
@@ -191,61 +280,4 @@
} }
})(); })();
</script> </script>
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="text-base font-semibold tracking-tight text-gray-900">Data erasure request</h2>
<p class="mt-2 text-sm text-gray-600">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</p>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold">Status:</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %}
</div>
{% endif %}
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="block text-sm font-medium text-gray-700">
Reason for request
</label>
<div class="mt-1">
{{ erasure_form.reason }}
</div>
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %}
</div>
{% if erasure_form.non_field_errors %}
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% endif %}
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
Submit erasure request
</button>
</form>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0008_remove_server_host_key"),
]
operations = [
migrations.AddField(
model_name="server",
name="last_heartbeat_at",
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="server",
name="last_ping_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@@ -28,6 +28,8 @@ class Server(models.Model):
agent_enrolled_at = models.DateTimeField(null=True, blank=True) agent_enrolled_at = models.DateTimeField(null=True, blank=True)
agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True) agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True)
agent_cert_serial = models.CharField(max_length=64, null=True, blank=True) agent_cert_serial = models.CharField(max_length=64, null=True, blank=True)
last_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@@ -1,42 +1,113 @@
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-4">
<div class="flex items-center gap-4"> <nav class="flex" aria-label="Breadcrumb">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white text-xl font-semibold"> <ol class="inline-flex items-center space-x-1 text-sm text-gray-500">
{{ server.initial }} <li class="inline-flex items-center">
<a href="{% url 'servers:dashboard' %}" class="inline-flex items-center gap-1 font-medium text-gray-600 hover:text-blue-700">
<svg class="h-4 w-4" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 3.172 2 10v7a1 1 0 0 0 1 1h5v-5h4v5h5a1 1 0 0 0 1-1v-7l-8-6.828Z"></path>
</svg>
Servers
</a>
</li>
<li class="inline-flex items-center">
<svg class="h-4 w-4 text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M7.05 4.55a1 1 0 0 1 1.4-1.42l6 5.9a1 1 0 0 1 0 1.42l-6 5.9a1 1 0 1 1-1.4-1.42L12.5 10 7.05 4.55Z"></path>
</svg>
<span class="ml-1 font-medium text-gray-700">{{ server.display_name }}</span>
</li>
</ol>
</nav>
<div class="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-700 text-lg font-semibold text-white shadow-sm">
{{ server.initial }}
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1>
<p class="text-sm text-gray-500">
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
</p>
</div>
</div> </div>
<div> <div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1> <div class="relative">
<p class="text-sm text-gray-600"> <button
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }} type="button"
</p> data-tooltip-target="server-header-status-{{ server.id }}"
class="{% if server_status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ server_status.label }}: {{ server_status.detail }}
</button>
<div
id="server-header-status-{{ server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ server_status.label }}: {{ server_status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if server_status.ping_ms is not None %}{{ server_status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if server_status.heartbeat_at %}{{ server_status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<a href="{% url 'servers:dashboard' %}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Back to servers
</a>
</div> </div>
</div> </div>
<a href="{% url 'servers:dashboard' %}" class="text-sm font-semibold text-purple-700 hover:text-purple-800">Back to servers</a>
</div> </div>
<nav class="flex flex-wrap gap-2 border-b border-gray-200 pb-2 text-sm font-semibold text-gray-500"> <nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500">
<a <a
href="{% url 'servers:detail' server.id %}" href="{% url 'servers:detail' server.id %}"
class="{% if active_tab == 'details' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}" class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
> >
Details Details
</a> </a>
<a <a
href="{% url 'servers:audit' server.id %}" href="{% url 'servers:audit' server.id %}"
class="{% if active_tab == 'audit' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}" class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
> >
Audit Audit
</a> </a>
{% if can_shell %} {% if can_shell %}
<a <a
href="{% url 'servers:shell' server.id %}" href="{% url 'servers:shell' server.id %}"
class="{% if active_tab == 'shell' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}" class="{% if active_tab == 'shell' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
> >
Shell Shell
</a> </a>
{% endif %} {% endif %}
<a <a
href="{% url 'servers:settings' server.id %}" href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}" class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
> >
Settings Settings
</a> </a>

View File

@@ -3,26 +3,43 @@
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %} {% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-6">
{% include "servers/_header.html" %} {% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-start justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2> <div>
<span class="text-xs font-semibold text-gray-500">Placeholder</span> <h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div> </div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600"> <div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
Logs will appear here once collection is enabled for this server. <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p>
</div> </div>
</section> </section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-start justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2> <div>
<span class="text-xs font-semibold text-gray-500">Placeholder</span> <h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div> </div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600"> <div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
Metrics will appear here once collection is enabled for this server. <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12h18M7 8v8M17 8v8" />
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -3,15 +3,24 @@
{% block title %}Servers • Keywarden{% endblock %} {% block title %}Servers • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1>
<p class="mt-1 text-sm text-gray-500">Review the servers you can access and their certificate status.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
{{ servers|length }} total
</span>
</div>
{% if servers %} {% if servers %}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %} {% for item in servers %}
<article class="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"> <article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-600 text-white font-semibold"> <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
{{ item.server.initial }} {{ item.server.initial }}
</div> </div>
<div> <div>
@@ -21,11 +30,56 @@
</p> </p>
</div> </div>
</div> </div>
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</span> <div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div> </div>
<dl class="mt-4 space-y-2 text-sm text-gray-600"> <dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Access until</dt> <dt>Access until</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if item.expires_at %} {% if item.expires_at %}
@@ -35,7 +89,7 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Last accessed</dt> <dt>Last accessed</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if item.last_accessed %} {% if item.last_accessed %}
@@ -47,16 +101,23 @@
</div> </div>
</dl> </dl>
<div class="mt-4 border-t border-gray-100 pt-3 text-xs text-gray-500"> <div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-purple-700 hover:text-purple-800">View details and logs</a> <span>Certificates and access</span>
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center"> <div class="rounded-2xl border border-dashed border-gray-200 bg-white p-10 text-center">
<h2 class="text-lg font-semibold text-gray-900">No server access yet</h2> <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it here.</p> <svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -3,32 +3,39 @@
{% block title %}{{ server.display_name }} • Keywarden{% endblock %} {% block title %}{{ server.display_name }} • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-6">
{% include "servers/_header.html" %} {% include "servers/_header.html" %}
<section class="grid gap-4 lg:grid-cols-3"> <section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Server details</h2> <h2 class="text-lg font-semibold text-gray-900">Server details</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600"> <dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Hostname</dt> <dt>Hostname</dt>
<dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd> <dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>IPv4</dt> <dt>IPv4</dt>
<dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd> <dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>IPv6</dt> <dt>IPv6</dt>
<dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd> <dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd>
</div> </div>
</dl> </dl>
</div> </div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm lg:col-span-2"> <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900">Account & certificate</h2> <div class="flex flex-wrap items-start justify-between gap-4">
<dl class="mt-4 space-y-3 text-sm text-gray-600"> <div>
<div class="flex items-center justify-between"> <h2 class="text-lg font-semibold text-gray-900">Account & certificate</h2>
<p class="mt-1 text-sm text-gray-500">Credentials and certificate download options.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Access</span>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Account name</dt> <dt>Account name</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if system_username %} {% if system_username %}
@@ -38,7 +45,7 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Account status</dt> <dt>Account status</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if account_present is None %} {% if account_present is None %}
@@ -50,22 +57,22 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex flex-col gap-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt>Certificate</dt> <dt>Certificate</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if certificate_key_id %} {% if certificate_key_id %}
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<div class="inline-flex overflow-hidden rounded-md shadow-sm"> <div class="inline-flex rounded-lg shadow-sm" role="group">
<button <button
type="button" type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700" class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate" data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
> >
Download Download
</button> </button>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200" class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256" data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
> >
Hash Hash
@@ -73,7 +80,7 @@
</div> </div>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}" data-key-id="{{ certificate_key_id }}"
> >
Regenerate Regenerate
@@ -87,10 +94,16 @@
</dl> </dl>
</div> </div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm lg:col-span-3"> <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-3">
<h2 class="text-lg font-semibold text-gray-900">Access</h2> <div class="flex flex-wrap items-start justify-between gap-4">
<dl class="mt-4 space-y-3 text-sm text-gray-600"> <div>
<div class="flex items-center justify-between"> <h2 class="text-lg font-semibold text-gray-900">Access</h2>
<p class="mt-1 text-sm text-gray-500">Review access windows and last usage.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Usage</span>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt> <dt>Access until</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if expires_at %} {% if expires_at %}
@@ -100,7 +113,7 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Last accessed</dt> <dt>Last accessed</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if last_accessed %} {% if last_accessed %}

View File

@@ -3,16 +3,25 @@
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %} {% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-6">
{% include "servers/_header.html" %} {% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-start justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900">Settings</h2> <div>
<span class="text-xs font-semibold text-gray-500">Placeholder</span> <h2 class="text-lg font-semibold text-gray-900">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div> </div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600"> <div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
Settings will appear here as server options are added. <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -18,25 +18,25 @@
{% block content %} {% block content %}
{% if is_popout %} {% if is_popout %}
<div class="w-screen"> <div class="w-screen">
<div id="shell-popout-shell" class="w-full border border-gray-200 bg-gray-900 shadow-sm"> <div id="shell-popout-shell" class="w-full rounded-2xl border border-gray-200 bg-slate-950 shadow-sm">
<div id="shell-terminal" class="h-full w-full p-2"></div> <div id="shell-terminal" class="h-full w-full p-3"></div>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="space-y-8"> <div class="space-y-6">
{% include "servers/_header.html" %} {% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">Shell access</h2> <h2 class="text-lg font-semibold text-gray-900">Shell access</h2>
<p class="mt-1 text-sm text-gray-600"> <p class="mt-1 text-sm text-gray-500">
Connect with your private key and the signed certificate for this server. Connect with your private key and the signed certificate for this server.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
id="shell-popout" id="shell-popout"
data-popout-url="{% url 'servers:shell' server.id %}?popout=1" data-popout-url="{% url 'servers:shell' server.id %}?popout=1"
> >
@@ -44,7 +44,7 @@
</button> </button>
</div> </div>
<div class="mt-4 space-y-4 text-sm text-gray-600"> <div class="mt-5 grid gap-4 text-sm text-gray-600 lg:grid-cols-2">
<dl class="space-y-4"> <dl class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<dt>Account name</dt> <dt>Account name</dt>
@@ -69,46 +69,46 @@
</dl> </dl>
{% if shell_command %} {% if shell_command %}
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4"> <div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-semibold text-gray-500">SSH command</span> <span class="text-xs font-semibold uppercase tracking-wide text-gray-500">SSH command</span>
<button <button
type="button" type="button"
class="text-xs font-semibold text-purple-700 hover:text-purple-800" class="text-xs font-semibold text-blue-700 hover:underline"
data-copy-target="shell-command" data-copy-target="shell-command"
> >
Copy command Copy command
</button> </button>
</div> </div>
<code class="mt-2 block break-all text-xs text-gray-800" id="shell-command">{{ shell_command }}</code> <code class="mt-3 block break-all rounded-lg bg-white p-3 text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
</div> <div class="mt-4 flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2"> {% if certificate_key_id %}
{% if certificate_key_id %} <div class="inline-flex rounded-lg shadow-sm" role="group">
<div class="inline-flex overflow-hidden rounded-md shadow-sm"> <button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate" data-key-id="{{ certificate_key_id }}"
> >
Download Regenerate
</button> </button>
<button {% endif %}
type="button" <span class="text-xs text-gray-500">Use the command above for local SSH.</span>
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200" </div>
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
{% endif %}
<span class="text-xs text-gray-500">Use the command above for local SSH.</span>
</div> </div>
{% else %} {% else %}
<p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p> <p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p>
@@ -116,26 +116,26 @@
</div> </div>
</section> </section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">Browser terminal</h2> <h2 class="text-lg font-semibold text-gray-900">Browser terminal</h2>
<p class="mt-1 text-sm text-gray-600"> <p class="mt-1 text-sm text-gray-500">
Launch a proxied terminal session to the target host in your browser. Launch a proxied terminal session to the target host in your browser.
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs font-semibold text-gray-500">Beta</span> <span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Beta</span>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
id="shell-start" id="shell-start"
> >
Start terminal Start terminal
</button> </button>
</div> </div>
</div> </div>
<div class="mt-4 rounded-xl border border-gray-200 bg-gray-900 p-2"> <div class="mt-4 rounded-xl border border-gray-200 bg-slate-950 p-2">
<div id="shell-terminal" class="h-96"></div> <div id="shell-terminal" class="h-96"></div>
</div> </div>
<p class="mt-3 text-xs text-gray-500"> <p class="mt-3 text-xs text-gray-500">
@@ -279,8 +279,8 @@
startButton.textContent = isRunning ? "Stop terminal" : "Start terminal"; startButton.textContent = isRunning ? "Stop terminal" : "Start terminal";
startButton.classList.toggle("bg-red-600", isRunning); startButton.classList.toggle("bg-red-600", isRunning);
startButton.classList.toggle("hover:bg-red-700", isRunning); startButton.classList.toggle("hover:bg-red-700", isRunning);
startButton.classList.toggle("bg-purple-600", !isRunning); startButton.classList.toggle("bg-blue-700", !isRunning);
startButton.classList.toggle("hover:bg-purple-700", !isRunning); startButton.classList.toggle("hover:bg-blue-800", !isRunning);
} }
function stopTerminal() { function stopTerminal() {
@@ -313,9 +313,9 @@
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: 13, fontSize: 13,
theme: { theme: {
background: "#0b0f12", background: "#0b1120",
foreground: "#d1d5db", foreground: "#e2e8f0",
cursor: "#a855f7" cursor: "#38bdf8"
} }
}); });
term.open(termContainer); term.open(termContainer);

View File

@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.http import Http404 from django.http import Http404
@@ -44,14 +47,16 @@ def dashboard(request):
if expires_at is None or expires_at > current: if expires_at is None or expires_at > current:
expires_map[access.server_id] = expires_at expires_map[access.server_id] = expires_at
servers = [ servers = []
{ for server in server_qs:
"server": server, servers.append(
"expires_at": expires_map.get(server.id), {
"last_accessed": None, "server": server,
} "expires_at": expires_map.get(server.id),
for server in server_qs "last_accessed": None,
] "status": _build_server_status(server, now),
}
)
context = { context = {
"servers": servers, "servers": servers,
@@ -89,12 +94,14 @@ def detail(request, server_id: int):
"certificate_key_id": certificate_key_id, "certificate_key_id": certificate_key_id,
"active_tab": "details", "active_tab": "details",
"can_shell": can_shell, "can_shell": can_shell,
"server_status": _build_server_status(server, now),
} }
return render(request, "servers/detail.html", context) return render(request, "servers/detail.html", context)
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def shell(request, server_id: int): def shell(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id) server = _get_server_or_404(request, server_id)
# We intentionally return a 404 on denied shell access to avoid # We intentionally return a 404 on denied shell access to avoid
# disclosing that the server exists but is restricted. # disclosing that the server exists but is restricted.
@@ -122,28 +129,33 @@ def shell(request, server_id: int):
"active_tab": "shell", "active_tab": "shell",
"is_popout": request.GET.get("popout") == "1", "is_popout": request.GET.get("popout") == "1",
"can_shell": True, "can_shell": True,
"server_status": _build_server_status(server, now),
} }
return render(request, "servers/shell.html", context) return render(request, "servers/shell.html", context)
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def audit(request, server_id: int): def audit(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id) server = _get_server_or_404(request, server_id)
context = { context = {
"server": server, "server": server,
"active_tab": "audit", "active_tab": "audit",
"can_shell": user_can_shell(request.user, server), "can_shell": user_can_shell(request.user, server),
"server_status": _build_server_status(server, now),
} }
return render(request, "servers/audit.html", context) return render(request, "servers/audit.html", context)
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def settings(request, server_id: int): def settings(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id) server = _get_server_or_404(request, server_id)
context = { context = {
"server": server, "server": server,
"active_tab": "settings", "active_tab": "settings",
"can_shell": user_can_shell(request.user, server), "can_shell": user_can_shell(request.user, server),
"server_status": _build_server_status(server, now),
} }
return render(request, "servers/settings.html", context) return render(request, "servers/settings.html", context)
@@ -170,3 +182,49 @@ def _load_account_context(request, server: Server):
active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first() active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
certificate_key_id = active_key.id if active_key else None certificate_key_id = active_key.id if active_key else None
return account, system_username, certificate_key_id return account, system_username, certificate_key_id
def _format_age_short(delta: timedelta) -> str:
seconds = max(0, int(delta.total_seconds()))
if seconds < 60:
return f"{seconds}s"
minutes = seconds // 60
rem_seconds = seconds % 60
if minutes < 60:
return f"{minutes}m {rem_seconds}s"
hours = minutes // 60
rem_minutes = minutes % 60
if hours < 48:
return f"{hours}h {rem_minutes}m {rem_seconds}s"
days = hours // 24
if days < 14:
return f"{days}d {hours % 24}h"
weeks = days // 7
return f"{weeks}w {days % 7}d"
def _build_server_status(server: Server, now):
stale_seconds = int(getattr(settings, "KEYWARDEN_HEARTBEAT_STALE_SECONDS", 120))
heartbeat_at = getattr(server, "last_heartbeat_at", None)
ping_ms = getattr(server, "last_ping_ms", None)
if heartbeat_at:
age = now - heartbeat_at
age_seconds = max(0, int(age.total_seconds()))
is_active = age_seconds <= stale_seconds
age_short = _format_age_short(age)
else:
is_active = False
age_short = "never"
label = "Active" if is_active else "Inactive"
if is_active:
detail = f"{ping_ms}ms" if ping_ms is not None else ""
else:
detail = age_short
return {
"is_active": is_active,
"label": label,
"detail": detail,
"ping_ms": ping_ms,
"age_short": age_short,
"heartbeat_at": heartbeat_at,
}

View File

@@ -109,6 +109,7 @@ class AgentHeartbeatIn(Schema):
host: Optional[str] = None host: Optional[str] = None
ipv4: Optional[str] = None ipv4: Optional[str] = None
ipv6: Optional[str] = None ipv6: Optional[str] = None
ping_ms: Optional[int] = None
def build_router() -> Router: def build_router() -> Router:
@@ -304,7 +305,7 @@ def build_router() -> Router:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Server not found") raise HttpError(404, "Server not found")
updates: dict[str, str] = {} updates: dict[str, str | int | datetime] = {}
host = (payload.host or "").strip()[:253] host = (payload.host or "").strip()[:253]
if host: if host:
try: try:
@@ -319,6 +320,10 @@ def build_router() -> Router:
ipv6 = _normalize_ip(payload.ipv6, 6) ipv6 = _normalize_ip(payload.ipv6, 6)
if ipv6 and server.ipv6 != ipv6: if ipv6 and server.ipv6 != ipv6:
updates["ipv6"] = ipv6 updates["ipv6"] = ipv6
now = timezone.now()
updates["last_heartbeat_at"] = now
if payload.ping_ms is not None:
updates["last_ping_ms"] = max(0, int(payload.ping_ms))
if updates: if updates:
for field, value in updates.items(): for field, value in updates.items():
setattr(server, field, value) setattr(server, field, value)

View File

@@ -111,6 +111,7 @@ KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALI
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv( KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}" "KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
) )
KEYWARDEN_HEARTBEAT_STALE_SECONDS = int(os.getenv("KEYWARDEN_HEARTBEAT_STALE_SECONDS", "120"))
CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL) CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL) CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)

File diff suppressed because one or more lines are too long

View File

@@ -8,47 +8,107 @@
<title>{% block title %}Keywarden{% endblock %}</title> <title>{% block title %}Keywarden{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}"> <link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}">
<link rel="icon" type="image/png" href="{% static 'ninja/favicon.png' %}"> <link rel="icon" type="image/png" href="{% static 'ninja/favicon.png' %}">
<meta name="theme-color" content="#0b1f24"> <meta name="theme-color" content="#0f172a">
<meta property="og:title" content="Keywarden"> <meta property="og:title" content="Keywarden">
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
{% tailwind_css %} {% tailwind_css %}
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased"> <body class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 text-slate-900 antialiased font-['Space_Grotesk']">
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
{% if not is_popout %} {% if not is_popout %}
<header class="border-b border-gray-200 bg-white"> <header class="border-b border-gray-200 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/75">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <nav class="mx-auto max-w-screen-xl px-4 py-3 lg:px-6">
<div class="flex h-20 items-center justify-between"> <div class="flex flex-wrap items-center justify-between">
<a href="/" class="inline-flex items-center gap-2"> <a href="/" class="flex items-center gap-3">
<img src="{% static 'branding/keywarden-favicon.svg' %}" alt="Keywarden logo" class="h-10 w-10"> <span class="flex h-11 w-11 items-center justify-center rounded-2xl bg-white p-1 shadow-sm ring-1 ring-blue-100">
<span class="text-2xl font-semibold tracking-tight">Keywarden</span> <img src="{% static 'branding/keywarden-favicon.svg' %}" alt="Keywarden logo" class="h-8 w-8">
</span>
<span class="flex flex-col">
<span class="text-xl font-semibold leading-tight tracking-tight">Keywarden</span>
<span class="text-xs text-gray-500">Access control vault</span>
</span>
</a> </a>
<nav class="flex items-center gap-4"> <div class="flex items-center gap-3 md:order-2">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<a href="{% url 'servers:dashboard' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Servers</a> <a
<a href="{% url 'accounts:profile' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Profile</a> href="{% url 'accounts:logout' %}"
<a href="{% url 'accounts:logout' %}" class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">Logout</a> class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Logout
</a>
{% else %} {% else %}
<a href="{% url 'accounts:login' %}" class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">Login</a> <a
href="{% url 'accounts:login' %}"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Login
</a>
{% endif %} {% endif %}
</nav> <button
data-collapse-toggle="navbar-default"
type="button"
class="inline-flex items-center rounded-lg p-2 text-sm text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 md:hidden"
aria-controls="navbar-default"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<div class="hidden w-full md:order-1 md:block md:w-auto" id="navbar-default">
<ul class="mt-4 flex flex-col rounded-lg border border-gray-100 bg-gray-50 p-4 text-sm font-medium text-gray-700 md:mt-0 md:flex-row md:space-x-6 md:border-0 md:bg-transparent md:p-0">
{% if request.user.is_authenticated %}
<li>
<a
href="{% url 'servers:dashboard' %}"
class="block rounded px-3 py-2 hover:bg-gray-100 md:px-0 md:py-0 md:hover:bg-transparent md:hover:text-blue-700"
>
Servers
</a>
</li>
<li>
<a
href="{% url 'accounts:profile' %}"
class="block rounded px-3 py-2 hover:bg-gray-100 md:px-0 md:py-0 md:hover:bg-transparent md:hover:text-blue-700"
>
Profile
</a>
</li>
{% endif %}
</ul>
</div>
</div> </div>
</div> </nav>
</header> </header>
{% endif %} {% endif %}
<main class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 flex-1 w-full"> <main class="mx-auto w-full max-w-screen-xl flex-1 px-4 py-8 sm:px-6 lg:px-8">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% if not is_popout %} {% if not is_popout %}
<footer class="border-t border-gray-200 bg-white"> <footer class="border-t border-gray-200 bg-white">
<div class="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-500 sm:px-6 lg:px-8"> <div class="mx-auto flex w-full max-w-screen-xl flex-col gap-2 px-4 py-6 text-sm text-gray-500 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://git.ntbx.io/boris/keywarden">Keywarden</a> | <a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://ntbx.io">George Wilkinson</a> (2025) <div class="flex items-center gap-2">
<span class="font-medium text-gray-700">Keywarden</span>
<span class="text-gray-300"></span>
<span>Secure access manager</span>
</div>
<div class="flex items-center gap-3">
<a class="font-medium text-blue-700 hover:underline" href="https://git.ntbx.io/boris/keywarden">Repository</a>
<span class="text-gray-300"></span>
<a class="font-medium text-blue-700 hover:underline" href="https://ntbx.io">George Wilkinson</a>
</div>
</div> </div>
</footer> </footer>
{% endif %} {% endif %}
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@4.0.1/dist/flowbite.min.js"></script>
</body> </body>
</html> </html>