Compare commits
2 Commits
962ba27679
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d0e808f8 | |||
| bebaaf1367 |
@@ -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 {
|
||||
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 apiClient.UpdateHost(ctx, cfg.ServerID, client.HeartbeatRequest{
|
||||
Host: info.Hostname,
|
||||
IPv4: info.IPv4,
|
||||
IPv6: info.IPv6,
|
||||
PingMs: pingPtr,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
|
||||
"server_id": "4",
|
||||
"server_ca_path": "",
|
||||
"sync_interval_seconds": 30,
|
||||
"sync_interval_seconds": 5,
|
||||
"log_batch_size": 500,
|
||||
"state_dir": "/var/lib/keywarden-agent",
|
||||
"account_policy": {
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,6 +24,10 @@ const defaultTimeout = 15 * time.Second
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
tlsCfg *tls.Config
|
||||
scheme string
|
||||
host string
|
||||
addr string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*Client, error) {
|
||||
@@ -62,7 +68,36 @@ func New(cfg *config.Config) (*Client, error) {
|
||||
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 {
|
||||
@@ -296,6 +331,7 @@ type HeartbeatRequest struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
PingMs *int `json:"ping_ms,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.
@@ -8,7 +8,7 @@ class ErasureRequestForm(forms.Form):
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"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,
|
||||
@@ -18,22 +18,22 @@ class ErasureRequestForm(forms.Form):
|
||||
|
||||
class SSHKeyForm(forms.Form):
|
||||
name = forms.CharField(
|
||||
label="Key name",
|
||||
label="Key Name",
|
||||
max_length=128,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "MacBook Pro",
|
||||
"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",
|
||||
"placeholder": "Device Name",
|
||||
"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(
|
||||
label="SSH public key",
|
||||
label="SSH Public Key",
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"placeholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB... you@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",
|
||||
"placeholder": "ssh-ed25519 AAAaBBbBcCcc111122223333... user@host",
|
||||
"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",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,32 +4,53 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl 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>
|
||||
<form method="post" class="space-y-4">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div class="space-y-2">
|
||||
<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 %}
|
||||
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-gray-700">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">
|
||||
<div>
|
||||
<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-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 class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-gray-700">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">
|
||||
<div>
|
||||
<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-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>
|
||||
{% 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 %}
|
||||
<div class="pt-2">
|
||||
<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">
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||
<p class="text-sm text-gray-600">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,77 +3,80 @@
|
||||
{% block title %}Profile • Keywarden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<div class="rounded-xl 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">Your Profile</h1>
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Username</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
|
||||
<p class="text-sm text-gray-500">Account details and contact information.</p>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Email</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.email }}</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>
|
||||
<dt class="text-sm font-medium text-gray-500">First name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.first_name|default:"—" }}</dd>
|
||||
<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">Email</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Last name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.last_name|default:"—" }}</dd>
|
||||
<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">First name</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.first_name|default:"—" }}</dd>
|
||||
</div>
|
||||
<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">Last name</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.last_name|default:"—" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
|
||||
<p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
|
||||
</div>
|
||||
</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>
|
||||
<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" %}
|
||||
<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>
|
||||
<a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a>
|
||||
{% elif auth_mode == "oidc" %}
|
||||
<p class="text-sm text-gray-600">OIDC is required. Sign-in is managed by your identity provider.</p>
|
||||
OIDC is required. Sign-in is managed by your identity provider.
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-600">OIDC is disabled. You are using native authentication.</p>
|
||||
OIDC is disabled. You are using native authentication.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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">SSH certificates</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<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">SSH certificates</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Upload your SSH public key to receive a signed certificate for server access.
|
||||
</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-4 space-y-3">
|
||||
<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="block text-sm font-medium text-gray-700">
|
||||
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
|
||||
Key name
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ key_form.name }}
|
||||
</div>
|
||||
{% if key_form.name.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ key_form.public_key.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
<div class="lg:col-span-2">
|
||||
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
|
||||
SSH public key
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ key_form.public_key }}
|
||||
</div>
|
||||
{% if key_form.public_key.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
@@ -81,35 +84,51 @@
|
||||
{% if key_form.non_field_errors %}
|
||||
<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">
|
||||
<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 divide-y divide-gray-200">
|
||||
<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 %}
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ key.name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ key.fingerprint }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<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 %}
|
||||
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
|
||||
<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-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/{{ 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"
|
||||
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
|
||||
@@ -117,22 +136,92 @@
|
||||
</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"
|
||||
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="text-xs font-semibold text-gray-500">Revoked</span>
|
||||
<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>
|
||||
</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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not erasure_request or erasure_request.status != "pending" %}
|
||||
<form method="post" class="mt-6 grid gap-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="form_type" value="erasure">
|
||||
<div>
|
||||
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
|
||||
Reason for request
|
||||
</label>
|
||||
{{ erasure_form.reason }}
|
||||
{% 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 %}
|
||||
<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"
|
||||
>
|
||||
Submit erasure request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function getCookie(name) {
|
||||
@@ -191,61 +280,4 @@
|
||||
}
|
||||
})();
|
||||
</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 %}
|
||||
|
||||
21
app/apps/servers/migrations/0009_server_heartbeat_fields.py
Normal file
21
app/apps/servers/migrations/0009_server_heartbeat_fields.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -28,6 +28,8 @@ class Server(models.Model):
|
||||
agent_enrolled_at = models.DateTimeField(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)
|
||||
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)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -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">
|
||||
<nav class="flex" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm text-gray-500">
|
||||
<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-xl bg-purple-600 text-white text-xl font-semibold">
|
||||
<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-600">
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'servers:dashboard' %}" class="text-sm font-semibold text-purple-700 hover:text-purple-800">Back to servers</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
<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
|
||||
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
|
||||
</a>
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
{% if can_shell %}
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
{% endif %}
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
|
||||
@@ -3,26 +3,43 @@
|
||||
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
{% include "servers/_header.html" %}
|
||||
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<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-lg font-semibold text-gray-900">Audit logs</h2>
|
||||
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
||||
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
|
||||
</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">
|
||||
Logs will appear here once collection is enabled for this server.
|
||||
<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 class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<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-lg font-semibold text-gray-900">Metrics</h2>
|
||||
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
||||
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
|
||||
</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">
|
||||
Metrics will appear here once collection is enabled for this server.
|
||||
<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 class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
{% block title %}Servers • Keywarden{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<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-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 }}
|
||||
</div>
|
||||
<div>
|
||||
@@ -21,11 +30,56 @@
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<dl class="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>Access until</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if item.expires_at %}
|
||||
@@ -35,7 +89,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>Last accessed</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if item.last_accessed %}
|
||||
@@ -47,16 +101,23 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 border-t border-gray-100 pt-3 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>
|
||||
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
|
||||
<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>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center">
|
||||
<h2 class="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 here.</p>
|
||||
<div class="rounded-2xl border border-dashed border-gray-200 bg-white p-10 text-center">
|
||||
<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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -3,32 +3,39 @@
|
||||
{% block title %}{{ server.display_name }} • Keywarden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
{% include "servers/_header.html" %}
|
||||
|
||||
<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>
|
||||
<dl class="mt-4 space-y-3 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>Hostname</dt>
|
||||
<dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>IPv4</dt>
|
||||
<dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>IPv6</dt>
|
||||
<dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</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">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Account & certificate</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if system_username %}
|
||||
@@ -38,7 +45,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>Account status</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if account_present is None %}
|
||||
@@ -50,22 +57,22 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
</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>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if certificate_key_id %}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="inline-flex rounded-lg shadow-sm" role="group">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
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
|
||||
@@ -73,7 +80,7 @@
|
||||
</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"
|
||||
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 }}"
|
||||
>
|
||||
Regenerate
|
||||
@@ -87,10 +94,16 @@
|
||||
</dl>
|
||||
</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">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Access</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if expires_at %}
|
||||
@@ -100,7 +113,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>Last accessed</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if last_accessed %}
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
{% include "servers/_header.html" %}
|
||||
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<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-lg font-semibold text-gray-900">Settings</h2>
|
||||
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
|
||||
</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">
|
||||
Settings will appear here as server options are added.
|
||||
<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 class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -18,25 +18,25 @@
|
||||
{% block content %}
|
||||
{% if is_popout %}
|
||||
<div class="w-screen">
|
||||
<div id="shell-popout-shell" class="w-full border border-gray-200 bg-gray-900 shadow-sm">
|
||||
<div id="shell-terminal" class="h-full w-full p-2"></div>
|
||||
<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-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
{% 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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
data-popout-url="{% url 'servers:shell' server.id %}?popout=1"
|
||||
>
|
||||
@@ -44,7 +44,7 @@
|
||||
</button>
|
||||
</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">
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Account name</dt>
|
||||
@@ -69,32 +69,31 @@
|
||||
</dl>
|
||||
|
||||
{% 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">
|
||||
<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
|
||||
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"
|
||||
>
|
||||
Copy command
|
||||
</button>
|
||||
</div>
|
||||
<code class="mt-2 block break-all text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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 class="mt-4 flex flex-wrap items-center gap-2">
|
||||
{% if certificate_key_id %}
|
||||
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
|
||||
<div class="inline-flex rounded-lg shadow-sm" role="group">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
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
|
||||
@@ -102,7 +101,7 @@
|
||||
</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"
|
||||
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 }}"
|
||||
>
|
||||
Regenerate
|
||||
@@ -110,32 +109,33 @@
|
||||
{% endif %}
|
||||
<span class="text-xs text-gray-500">Use the command above for local SSH.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
Start terminal
|
||||
</button>
|
||||
</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>
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
@@ -279,8 +279,8 @@
|
||||
startButton.textContent = isRunning ? "Stop terminal" : "Start terminal";
|
||||
startButton.classList.toggle("bg-red-600", isRunning);
|
||||
startButton.classList.toggle("hover:bg-red-700", isRunning);
|
||||
startButton.classList.toggle("bg-purple-600", !isRunning);
|
||||
startButton.classList.toggle("hover:bg-purple-700", !isRunning);
|
||||
startButton.classList.toggle("bg-blue-700", !isRunning);
|
||||
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
|
||||
}
|
||||
|
||||
function stopTerminal() {
|
||||
@@ -313,9 +313,9 @@
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
fontSize: 13,
|
||||
theme: {
|
||||
background: "#0b0f12",
|
||||
foreground: "#d1d5db",
|
||||
cursor: "#a855f7"
|
||||
background: "#0b1120",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#38bdf8"
|
||||
}
|
||||
});
|
||||
term.open(termContainer);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
@@ -44,14 +47,16 @@ def dashboard(request):
|
||||
if expires_at is None or expires_at > current:
|
||||
expires_map[access.server_id] = expires_at
|
||||
|
||||
servers = [
|
||||
servers = []
|
||||
for server in server_qs:
|
||||
servers.append(
|
||||
{
|
||||
"server": server,
|
||||
"expires_at": expires_map.get(server.id),
|
||||
"last_accessed": None,
|
||||
"status": _build_server_status(server, now),
|
||||
}
|
||||
for server in server_qs
|
||||
]
|
||||
)
|
||||
|
||||
context = {
|
||||
"servers": servers,
|
||||
@@ -89,12 +94,14 @@ def detail(request, server_id: int):
|
||||
"certificate_key_id": certificate_key_id,
|
||||
"active_tab": "details",
|
||||
"can_shell": can_shell,
|
||||
"server_status": _build_server_status(server, now),
|
||||
}
|
||||
return render(request, "servers/detail.html", context)
|
||||
|
||||
|
||||
@login_required(login_url="/accounts/login/")
|
||||
def shell(request, server_id: int):
|
||||
now = timezone.now()
|
||||
server = _get_server_or_404(request, server_id)
|
||||
# We intentionally return a 404 on denied shell access to avoid
|
||||
# disclosing that the server exists but is restricted.
|
||||
@@ -122,28 +129,33 @@ def shell(request, server_id: int):
|
||||
"active_tab": "shell",
|
||||
"is_popout": request.GET.get("popout") == "1",
|
||||
"can_shell": True,
|
||||
"server_status": _build_server_status(server, now),
|
||||
}
|
||||
return render(request, "servers/shell.html", context)
|
||||
|
||||
|
||||
@login_required(login_url="/accounts/login/")
|
||||
def audit(request, server_id: int):
|
||||
now = timezone.now()
|
||||
server = _get_server_or_404(request, server_id)
|
||||
context = {
|
||||
"server": server,
|
||||
"active_tab": "audit",
|
||||
"can_shell": user_can_shell(request.user, server),
|
||||
"server_status": _build_server_status(server, now),
|
||||
}
|
||||
return render(request, "servers/audit.html", context)
|
||||
|
||||
|
||||
@login_required(login_url="/accounts/login/")
|
||||
def settings(request, server_id: int):
|
||||
now = timezone.now()
|
||||
server = _get_server_or_404(request, server_id)
|
||||
context = {
|
||||
"server": server,
|
||||
"active_tab": "settings",
|
||||
"can_shell": user_can_shell(request.user, server),
|
||||
"server_status": _build_server_status(server, now),
|
||||
}
|
||||
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()
|
||||
certificate_key_id = active_key.id if active_key else None
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ class AgentHeartbeatIn(Schema):
|
||||
host: Optional[str] = None
|
||||
ipv4: Optional[str] = None
|
||||
ipv6: Optional[str] = None
|
||||
ping_ms: Optional[int] = None
|
||||
|
||||
|
||||
def build_router() -> Router:
|
||||
@@ -304,7 +305,7 @@ def build_router() -> Router:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Server not found")
|
||||
updates: dict[str, str] = {}
|
||||
updates: dict[str, str | int | datetime] = {}
|
||||
host = (payload.host or "").strip()[:253]
|
||||
if host:
|
||||
try:
|
||||
@@ -319,6 +320,10 @@ def build_router() -> Router:
|
||||
ipv6 = _normalize_ip(payload.ipv6, 6)
|
||||
if ipv6 and server.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:
|
||||
for field, value in updates.items():
|
||||
setattr(server, field, value)
|
||||
|
||||
@@ -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", "{{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_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,47 +8,107 @@
|
||||
<title>{% block title %}Keywarden{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}">
|
||||
<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 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 %}
|
||||
{% block extra_head %}{% endblock %}
|
||||
</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">
|
||||
{% if not is_popout %}
|
||||
<header class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-20 items-center justify-between">
|
||||
<a href="/" class="inline-flex items-center gap-2">
|
||||
<img src="{% static 'branding/keywarden-favicon.svg' %}" alt="Keywarden logo" class="h-10 w-10">
|
||||
<span class="text-2xl font-semibold tracking-tight">Keywarden</span>
|
||||
<header class="border-b border-gray-200 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/75">
|
||||
<nav class="mx-auto max-w-screen-xl px-4 py-3 lg:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3">
|
||||
<span class="flex h-11 w-11 items-center justify-center rounded-2xl bg-white p-1 shadow-sm ring-1 ring-blue-100">
|
||||
<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>
|
||||
<nav class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-3 md:order-2">
|
||||
{% 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 href="{% url 'accounts:profile' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Profile</a>
|
||||
<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>
|
||||
<a
|
||||
href="{% url 'accounts:logout' %}"
|
||||
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 %}
|
||||
<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 %}
|
||||
<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>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{% 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 %}
|
||||
</main>
|
||||
|
||||
{% if not is_popout %}
|
||||
<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">
|
||||
<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="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">
|
||||
<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>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@4.0.1/dist/flowbite.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user