Compare commits

...

6 Commits

57 changed files with 2212 additions and 273 deletions

31
.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Django settings
KEYWARDEN_SECRET_KEY=supersecret
KEYWARDEN_DEBUG=True
KEYWARDEN_ALLOWED_HOSTS=*
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
KEYWARDEN_DOMAIN=https://example.domain.xyz
# Database
KEYWARDEN_POSTGRES_DB=keywarden
KEYWARDEN_POSTGRES_USER=keywarden
KEYWARDEN_POSTGRES_PASSWORD=postgres
KEYWARDEN_POSTGRES_HOST=keywarden-db
KEYWARDEN_POSTGRES_PORT=5432
# Admin
KEYWARDEN_ADMIN_USERNAME=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
KEYWARDEN_ADMIN_PASSWORD=password
# Auth mode: native | oidc | hybrid
KEYWARDEN_AUTH_MODE=native
# OIDC (optional)
# KEYWARDEN_OIDC_CLIENT_ID=
# KEYWARDEN_OIDC_CLIENT_SECRET=
# KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT=
# KEYWARDEN_OIDC_TOKEN_ENDPOINT=
# KEYWARDEN_OIDC_USER_ENDPOINT=
# KEYWARDEN_OIDC_JWKS_ENDPOINT=

3
.gitignore vendored
View File

@@ -218,9 +218,6 @@ __marimo__/
# Certificates
*.pem
# Docker
*compose.yml
nginx/logs/*
nginx/certs/*.pem

View File

@@ -13,12 +13,17 @@ WORKDIR /app
# System deps for psycopg2, node (for Tailwind), etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
libpq-dev \
curl \
openssl \
nginx \
nodejs \
npm \
supervisor \
mkcert \
libnss3-tools \
valkey-server \
&& rm -rf /var/lib/apt/lists/*
# =============================================
@@ -44,7 +49,7 @@ RUN pip install --upgrade pip \
WORKDIR /app
COPY ./app .
COPY nginx/configs/nginx.conf /etc/nginx/nginx.conf
COPY nginx/configs/nginx.conf.template /etc/nginx/nginx.conf.template
COPY nginx/configs/options-* /etc/nginx/
#COPY nginx/configs/sites/ /etc/nginx/conf.d/
COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2024, Valkey contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

22
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,22 @@
# Third-party notices
This project is licensed under the GNU AGPL v3. It includes third-party components that
are distributed under their own licenses. When redistributing Keywarden (source or binary),
ensure you comply with each component's license terms and include required notices.
## Valkey
Valkey is included in the container image and used as the cache backend.
License: BSD 3-Clause. See `LICENSES/valkey.BSD-3-Clause.txt`.
## Other third-party components
This repository and container image include additional dependencies (Python packages and
system packages). Their licenses typically require you to retain copyright notices and
license texts when redistributing binaries. Review the following sources to determine
exact obligations:
- `requirements.txt` for Python dependencies.
- `Dockerfile` for system packages installed into the image.
- `app/static/` and `app/theme/` for bundled frontend assets.
If you need a full license inventory, generate it from your build environment and add
corresponding license texts under `LICENSES/`.

23
agent/README.md Normal file
View File

@@ -0,0 +1,23 @@
# keywarden-agent
Minimal Go agent scaffold for Keywarden.
## Build
```
go build -o keywarden-agent ./cmd/keywarden-agent
```
## Run
```
./keywarden-agent -config /etc/keywarden/agent.json -server-url https://keywarden.example.com -enroll-token <token>
```
You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environment variables.
## Config
On first boot, the agent will create a config file if it does not exist. Only `server_url` is required for bootstrapping.
See `config.example.json`.

View File

@@ -0,0 +1,223 @@
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"keywarden/agent/internal/client"
"keywarden/agent/internal/config"
"keywarden/agent/internal/logs"
"keywarden/agent/internal/version"
)
func main() {
configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON")
serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)")
enrollToken := flag.String("enroll-token", "", "Enrollment token (first boot)")
showVersion := flag.Bool("version", false, "Print version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("keywarden-agent %s (commit %s, built %s)\n", version.Version, version.Commit, version.BuildDate)
return
}
cfg, err := config.LoadOrInit(*configPath, pickServerURL(*serverURL))
if err != nil {
log.Fatalf("config error: %v", err)
}
if err := ensureDirs(cfg); err != nil {
log.Fatalf("state dir error: %v", err)
}
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken)); err != nil {
log.Fatalf("bootstrap error: %v", err)
}
apiClient, err := client.New(cfg)
if err != nil {
log.Fatalf("client error: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
interval := time.Duration(cfg.SyncIntervalSeconds) * time.Second
log.Printf("keywarden-agent started: server_id=%s interval=%s", cfg.ServerID, interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
runOnce(ctx, apiClient, cfg)
for {
select {
case <-ctx.Done():
log.Printf("shutdown requested")
return
case <-ticker.C:
runOnce(ctx, apiClient, cfg)
}
}
}
func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config) {
if err := apiClient.SyncAccounts(ctx, cfg.ServerID); err != nil {
log.Printf("sync accounts error: %v", err)
}
if err := shipLogs(ctx, apiClient, cfg); err != nil {
log.Printf("log shipping error: %v", err)
}
}
func ensureDirs(cfg *config.Config) error {
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
return err
}
if err := os.MkdirAll(cfg.LogSpoolDir(), 0o700); err != nil {
return err
}
return nil
}
func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config) error {
send := func(payload []byte) error {
return apiClient.SendLogBatch(ctx, cfg.ServerID, payload)
}
if err := logs.DrainSpool(cfg.LogSpoolDir(), send); err != nil {
return err
}
cursor, err := logs.ReadCursor(cfg.LogCursorPath())
if err != nil {
return err
}
collector := logs.NewCollector()
events, nextCursor, err := collector.Collect(ctx, cursor, cfg.LogBatchSize)
if err != nil {
return err
}
if len(events) == 0 {
return nil
}
payload, err := json.Marshal(events)
if err != nil {
return err
}
if err := send(payload); err != nil {
if spoolErr := logs.SaveSpool(cfg.LogSpoolDir(), payload); spoolErr != nil {
return spoolErr
}
return err
}
if err := logs.WriteCursor(cfg.LogCursorPath(), nextCursor); err != nil {
return err
}
return nil
}
func pickServerURL(flagValue string) string {
if flagValue != "" {
return flagValue
}
return os.Getenv("KEYWARDEN_SERVER_URL")
}
func pickEnrollToken(flagValue string) string {
if flagValue != "" {
return flagValue
}
return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
}
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string) error {
if cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
return nil
}
if enrollToken == "" {
return fmt.Errorf("missing enrollment token; set KEYWARDEN_ENROLL_TOKEN or -enroll-token")
}
keyPath := cfg.ClientKeyPath()
if !fileExists(keyPath) {
if err := generateKey(keyPath); err != nil {
return err
}
}
csrPEM, err := buildCSR(keyPath)
if err != nil {
return err
}
hostname, _ := os.Hostname()
resp, err := client.Enroll(context.Background(), cfg.ServerURL, client.EnrollRequest{
Token: enrollToken,
CSRPEM: csrPEM,
Host: hostname,
})
if err != nil {
return err
}
if err := os.WriteFile(cfg.ClientCertPath(), []byte(resp.ClientCert), 0o600); err != nil {
return err
}
if err := os.WriteFile(cfg.CACertPath(), []byte(resp.CACert), 0o600); err != nil {
return err
}
cfg.ServerID = resp.ServerID
if err := config.Save(configPath, cfg); err != nil {
return err
}
return nil
}
func generateKey(path string) error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
keyDER := x509.MarshalPKCS1PrivateKey(key)
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}
data := pem.EncodeToMemory(block)
return os.WriteFile(path, data, 0o600)
}
func buildCSR(keyPath string) (string, error) {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return "", err
}
block, _ := pem.Decode(keyData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return "", fmt.Errorf("invalid private key")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", err
}
csrTemplate := &x509.CertificateRequest{Subject: pkix.Name{CommonName: "keywarden-agent"}}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, key)
if err != nil {
return "", err
}
csrBlock := &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}
return string(pem.EncodeToMemory(csrBlock)), nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}

14
agent/config.example.json Normal file
View File

@@ -0,0 +1,14 @@
{
"server_url": "https://keywarden.example.com",
"server_id": "",
"sync_interval_seconds": 30,
"log_batch_size": 500,
"state_dir": "/var/lib/keywarden-agent",
"account_policy": {
"username_template": "{{username}}_{{user_id}}",
"default_shell": "/bin/bash",
"admin_group": "sudo",
"create_home": true,
"lock_on_revoke": true
}
}

7
agent/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module keywarden/agent
go 1.22
require (
github.com/coreos/go-systemd/v22 v22.5.0
)

3
agent/go.sum Normal file
View File

@@ -0,0 +1,3 @@
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=

View File

@@ -0,0 +1,132 @@
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"keywarden/agent/internal/config"
)
const defaultTimeout = 15 * time.Second
type Client struct {
baseURL string
http *http.Client
}
func New(cfg *config.Config) (*Client, error) {
baseURL := strings.TrimRight(cfg.ServerURL, "/")
if baseURL == "" {
return nil, errors.New("server url is required")
}
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
if err != nil {
return nil, fmt.Errorf("load client cert: %w", err)
}
caData, err := os.ReadFile(cfg.CACertPath())
if err != nil {
return nil, fmt.Errorf("read ca cert: %w", err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caData) {
return nil, errors.New("parse ca cert")
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
MinVersion: tls.VersionTLS12,
}
transport := &http.Transport{
TLSClientConfig: tlsConfig,
}
httpClient := &http.Client{
Timeout: defaultTimeout,
Transport: transport,
}
return &Client{baseURL: baseURL, http: httpClient}, nil
}
type EnrollRequest struct {
Token string `json:"token"`
CSRPEM string `json:"csr_pem"`
Host string `json:"host"`
AgentID string `json:"agent_id,omitempty"`
}
type EnrollResponse struct {
ServerID string `json:"server_id"`
ClientCert string `json:"client_cert_pem"`
CACert string `json:"ca_cert_pem"`
SyncProfile string `json:"sync_profile,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
baseURL := strings.TrimRight(serverURL, "/")
if baseURL == "" {
return nil, errors.New("server url is required")
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode enroll request: %w", err)
}
httpClient := &http.Client{Timeout: defaultTimeout}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/agent/enroll", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build enroll request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("enroll request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("enroll failed: status %s", resp.Status)
}
var out EnrollResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode enroll response: %w", err)
}
if out.ServerID == "" || out.ClientCert == "" || out.CACert == "" {
return nil, errors.New("enroll response missing required fields")
}
return &out, nil
}
func (c *Client) SyncAccounts(ctx context.Context, serverID string) error {
_ = ctx
_ = serverID
// TODO: call API to fetch account policy + approved access list.
return nil
}
func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("build log request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("send log batch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("log batch failed: status %s", resp.Status)
}
return nil
}

View File

@@ -0,0 +1,148 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
)
const (
DefaultConfigPath = "/etc/keywarden/agent.json"
DefaultStateDir = "/var/lib/keywarden-agent"
DefaultSyncIntervalSeconds = 30
DefaultLogBatchSize = 500
DefaultUsernameTemplate = "{{username}}_{{user_id}}"
DefaultShell = "/bin/bash"
DefaultAdminGroup = "sudo"
)
type AccountPolicy struct {
UsernameTemplate string `json:"username_template"`
DefaultShell string `json:"default_shell"`
AdminGroup string `json:"admin_group"`
CreateHome bool `json:"create_home"`
LockOnRevoke bool `json:"lock_on_revoke"`
}
type Config struct {
ServerURL string `json:"server_url"`
ServerID string `json:"server_id,omitempty"`
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
LogBatchSize int `json:"log_batch_size,omitempty"`
StateDir string `json:"state_dir,omitempty"`
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
}
func LoadOrInit(path string, serverURL string) (*Config, error) {
if path == "" {
path = DefaultConfigPath
}
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read config: %w", err)
}
if serverURL == "" {
return nil, errors.New("server url required for first boot")
}
cfg := &Config{ServerURL: serverURL}
applyDefaults(cfg)
if err := validate(cfg, false); err != nil {
return nil, err
}
if err := Save(path, cfg); err != nil {
return nil, err
}
return cfg, nil
}
cfg := &Config{}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyDefaults(cfg)
if err := validate(cfg, false); err != nil {
return nil, err
}
return cfg, nil
}
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config: %w", err)
}
if err := os.MkdirAll(dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func applyDefaults(cfg *Config) {
if cfg.SyncIntervalSeconds <= 0 {
cfg.SyncIntervalSeconds = DefaultSyncIntervalSeconds
}
if cfg.LogBatchSize <= 0 {
cfg.LogBatchSize = DefaultLogBatchSize
}
if cfg.StateDir == "" {
cfg.StateDir = DefaultStateDir
}
if cfg.AccountPolicy.UsernameTemplate == "" {
cfg.AccountPolicy.UsernameTemplate = DefaultUsernameTemplate
}
if cfg.AccountPolicy.DefaultShell == "" {
cfg.AccountPolicy.DefaultShell = DefaultShell
}
if cfg.AccountPolicy.AdminGroup == "" {
cfg.AccountPolicy.AdminGroup = DefaultAdminGroup
}
}
func validate(cfg *Config, requireServerID bool) error {
var missing []string
if cfg.ServerURL == "" {
missing = append(missing, "server_url")
}
if requireServerID && cfg.ServerID == "" {
missing = append(missing, "server_id")
}
if len(missing) > 0 {
return fmt.Errorf("missing required config fields: %v", missing)
}
if cfg.SyncIntervalSeconds < 5 {
return errors.New("sync_interval_seconds must be >= 5")
}
return nil
}
func (c *Config) ClientCertPath() string {
return c.StateDir + "/agent.crt"
}
func (c *Config) ClientKeyPath() string {
return c.StateDir + "/agent.key"
}
func (c *Config) CACertPath() string {
return c.StateDir + "/ca.crt"
}
func (c *Config) LogCursorPath() string {
return c.StateDir + "/journal.cursor"
}
func (c *Config) LogSpoolDir() string {
return c.StateDir + "/spool"
}
func dir(path string) string {
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
return path[:idx]
}
return "."
}

View File

@@ -0,0 +1,177 @@
package logs
import (
"context"
"strings"
"time"
"github.com/coreos/go-systemd/v22/sdjournal"
)
const defaultLimit = 500
type Collector struct {
matches []string
}
func NewCollector() *Collector {
return &Collector{matches: defaultMatches()}
}
func (c *Collector) Collect(ctx context.Context, cursor string, limit int) ([]Event, string, error) {
if limit <= 0 {
limit = defaultLimit
}
j, err := sdjournal.NewJournal()
if err != nil {
return nil, "", err
}
defer j.Close()
for i, match := range c.matches {
if i > 0 {
if err := j.AddDisjunction(); err != nil {
return nil, "", err
}
}
if err := j.AddMatch(match); err != nil {
return nil, "", err
}
}
if cursor != "" {
if err := j.SeekCursor(cursor); err == nil {
_, _ = j.Next()
}
} else {
_ = j.SeekTail()
_, _ = j.Next()
}
var events []Event
var nextCursor string
for len(events) < limit {
select {
case <-ctx.Done():
return events, nextCursor, ctx.Err()
default:
}
n, err := j.Next()
if err != nil {
return events, nextCursor, err
}
if n == 0 {
break
}
entry, err := j.GetEntry()
if err != nil {
return events, nextCursor, err
}
event := fromEntry(entry)
events = append(events, event)
nextCursor = entry.Cursor
}
return events, nextCursor, nil
}
func defaultMatches() []string {
return []string{
"_SYSTEMD_UNIT=sshd.service",
"_SYSTEMD_UNIT=sudo.service",
"_SYSTEMD_UNIT=systemd-networkd.service",
"_SYSTEMD_UNIT=NetworkManager.service",
"_SYSTEMD_UNIT=systemd-logind.service",
"_TRANSPORT=kernel",
}
}
func fromEntry(entry *sdjournal.JournalEntry) Event {
ts := time.Unix(0, int64(entry.RealtimeTimestamp)*int64(time.Microsecond))
event := NewEvent(ts)
fields := entry.Fields
unit := fields["_SYSTEMD_UNIT"]
message := fields["MESSAGE"]
identifier := fields["SYSLOG_IDENTIFIER"]
event.Unit = unit
event.Message = message
event.Priority = fields["PRIORITY"]
event.Hostname = fields["_HOSTNAME"]
event.Fields = fields
event.Category = categorize(unit, identifier, fields)
event.EventType, event.Username, event.SourceIP, event.SessionID = parseMessage(event.Category, message)
if event.EventType == "" {
event.EventType = defaultEventType(event.Category)
}
return event
}
func categorize(unit string, identifier string, fields map[string]string) string {
switch {
case unit == "sshd.service" || identifier == "sshd":
return "access"
case unit == "sudo.service" || identifier == "sudo":
return "auth"
case unit == "systemd-networkd.service" || identifier == "NetworkManager":
return "network"
case fields["_TRANSPORT"] == "kernel":
return "system"
default:
return "system"
}
}
func defaultEventType(category string) string {
switch category {
case "access":
return "ssh"
case "auth":
return "auth"
case "network":
return "network"
default:
return "system"
}
}
func parseMessage(category string, msg string) (eventType string, username string, sourceIP string, sessionID string) {
if msg == "" {
return "", "", "", ""
}
lower := strings.ToLower(msg)
if category == "access" {
switch {
case strings.Contains(lower, "accepted"):
eventType = "ssh.login.success"
username = extractBetween(msg, "for ", " from")
sourceIP = extractBetween(msg, "from ", " port")
case strings.Contains(lower, "failed password"):
eventType = "ssh.login.fail"
username = extractBetween(msg, "for ", " from")
sourceIP = extractBetween(msg, "from ", " port")
case strings.Contains(lower, "session opened"):
eventType = "ssh.session.open"
username = extractBetween(msg, "for user ", " by")
case strings.Contains(lower, "session closed"):
eventType = "ssh.session.close"
username = extractBetween(msg, "for user ", " by")
}
}
return eventType, strings.TrimSpace(username), strings.TrimSpace(sourceIP), strings.TrimSpace(sessionID)
}
func extractBetween(msg string, start string, end string) string {
startIdx := strings.Index(msg, start)
if startIdx == -1 {
return ""
}
startIdx += len(start)
rest := msg[startIdx:]
endIdx := strings.Index(rest, end)
if endIdx == -1 {
return strings.TrimSpace(rest)
}
return strings.TrimSpace(rest[:endIdx])
}

View File

@@ -0,0 +1,24 @@
package logs
import (
"os"
"strings"
)
func ReadCursor(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func WriteCursor(path string, cursor string) error {
if cursor == "" {
return nil
}
return os.WriteFile(path, []byte(cursor+"\n"), 0o600)
}

View File

@@ -0,0 +1,53 @@
package logs
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
func SaveSpool(dir string, payload []byte) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
name := fmt.Sprintf("%d.json", time.Now().UnixNano())
tmp := filepath.Join(dir, name+".tmp")
final := filepath.Join(dir, name)
if err := os.WriteFile(tmp, payload, 0o600); err != nil {
return err
}
return os.Rename(tmp, final)
}
func DrainSpool(dir string, send func([]byte) error) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var files []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
files = append(files, filepath.Join(dir, entry.Name()))
}
sort.Strings(files)
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := send(data); err != nil {
return err
}
if err := os.Remove(path); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,23 @@
package logs
import "time"
type Event struct {
Timestamp string `json:"timestamp"`
Category string `json:"category"`
EventType string `json:"event_type"`
Unit string `json:"unit,omitempty"`
Priority string `json:"priority,omitempty"`
Hostname string `json:"hostname,omitempty"`
Username string `json:"username,omitempty"`
Principal string `json:"principal,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message string `json:"message,omitempty"`
Raw string `json:"raw,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
}
func NewEvent(ts time.Time) Event {
return Event{Timestamp: ts.UTC().Format(time.RFC3339Nano)}
}

View File

@@ -0,0 +1,7 @@
package version
var (
Version = "0.0.1-dev"
Commit = ""
BuildDate = ""
)

BIN
agent/keywarden-agent Executable file

Binary file not shown.

View File

@@ -1,10 +1,11 @@
from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from .models import AccessRequest
@admin.register(AccessRequest)
class AccessRequestAdmin(admin.ModelAdmin):
class AccessRequestAdmin(GuardedModelAdmin):
list_display = (
"id",
"requester",

View File

@@ -5,3 +5,7 @@ class AccessConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.access"
verbose_name = "Access Requests"
def ready(self) -> None:
from . import signals # noqa: F401
return super().ready()

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from django.db.models.signals import post_save
from django.dispatch import receiver
from guardian.shortcuts import assign_perm
from apps.core.rbac import assign_default_object_permissions
from .models import AccessRequest
@receiver(post_save, sender=AccessRequest)
def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None:
if not created:
return
if instance.requester_id:
user = instance.requester
for perm in (
"access.view_accessrequest",
"access.change_accessrequest",
"access.delete_accessrequest",
):
assign_perm(perm, user, instance)
assign_default_object_permissions(instance)

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import hashlib
import time
from django.utils import timezone
from django.utils.text import slugify
from .models import AuditEventType, AuditLog
from .utils import get_client_ip, get_request_id
_EVENT_CACHE: dict[str, AuditEventType] = {}
_SKIP_PREFIXES = ("/api/v1/audit", "/api/v1/user")
_SKIP_SUFFIXES = ("/health", "/health/")
def _is_api_request(path: str) -> bool:
return path == "/api" or path.startswith("/api/")
def _should_log_request(path: str) -> bool:
if not _is_api_request(path):
return False
if path in _SKIP_PREFIXES:
return False
if any(path.startswith(prefix + "/") for prefix in _SKIP_PREFIXES):
return False
if any(path.endswith(suffix) for suffix in _SKIP_SUFFIXES):
return False
return True
def _resolve_route(request, fallback: str) -> str:
match = getattr(request, "resolver_match", None)
route = getattr(match, "route", None) if match else None
if route:
return route if route.startswith("/") else f"/{route}"
return fallback
def _event_key_for(method: str, route: str) -> str:
base = f"api_{method.lower()}_{route}"
slug = slugify(base)
if not slug:
return "api_request"
if len(slug) <= 64:
return slug
digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:8]
prefix_len = 64 - len(digest) - 1
return f"{slug[:prefix_len]}-{digest}"
def _event_title_for(method: str, route: str) -> str:
title = f"API {method.upper()} {route}"
if len(title) <= 128:
return title
return f"{title[:125]}..."
def _get_endpoint_event(method: str, route: str) -> AuditEventType:
key = _event_key_for(method, route)
cached = _EVENT_CACHE.get(key)
if cached is not None:
return cached
event, _ = AuditEventType.objects.get_or_create(
key=key,
defaults={
"title": _event_title_for(method, route),
"default_severity": AuditEventType.Severity.INFO,
},
)
_EVENT_CACHE[key] = event
return event
class ApiAuditLogMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
path = request.path_info or request.path
if not _should_log_request(path):
return self.get_response(request)
start = time.monotonic()
try:
response = self.get_response(request)
except Exception as exc:
duration_ms = int((time.monotonic() - start) * 1000)
self._write_log(request, path, 500, duration_ms, error=type(exc).__name__)
raise
duration_ms = int((time.monotonic() - start) * 1000)
self._write_log(request, path, response.status_code, duration_ms)
return response
def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None:
try:
route = _resolve_route(request, path)
user = getattr(request, "user", None)
actor = user if getattr(user, "is_authenticated", False) else None
metadata = {
"method": request.method,
"path": path,
"route": route,
"status_code": status_code,
"duration_ms": duration_ms,
"query_string": request.META.get("QUERY_STRING", ""),
}
if error:
metadata["error"] = error
AuditLog.objects.create(
created_at=timezone.now(),
actor=actor,
event_type=_get_endpoint_event(request.method, route),
message=f"API request {request.method} {route} -> {status_code}",
severity=AuditEventType.Severity.INFO,
source=AuditLog.Source.API,
ip_address=get_client_ip(request),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
request_id=get_request_id(request),
metadata=metadata,
)
except Exception:
return

View File

@@ -6,6 +6,7 @@ from django.dispatch import receiver
from django.utils import timezone
from .models import AuditEventType, AuditLog
from .utils import get_client_ip
User = get_user_model()
@@ -28,7 +29,7 @@ def on_user_logged_in(sender, request, user: User, **kwargs):
message=f"User {user} logged in",
severity=event.default_severity,
source=AuditLog.Source.UI,
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
ip_address=get_client_ip(request),
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
metadata={"path": request.path} if request else {},
)
@@ -44,9 +45,7 @@ def on_user_logged_out(sender, request, user: User, **kwargs):
message=f"User {user} logged out",
severity=event.default_severity,
source=AuditLog.Source.UI,
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
ip_address=get_client_ip(request),
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
metadata={"path": request.path} if request else {},
)

44
app/apps/audit/utils.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import ipaddress
def _normalize_ip(value: str | None) -> str | None:
if not value:
return None
candidate = value.strip()
if not candidate:
return None
if candidate.startswith("[") and "]" in candidate:
candidate = candidate[1 : candidate.index("]")]
elif candidate.count(":") == 1 and candidate.rsplit(":", 1)[1].isdigit():
candidate = candidate.rsplit(":", 1)[0]
try:
return str(ipaddress.ip_address(candidate))
except ValueError:
return None
def get_client_ip(request) -> str | None:
if not request:
return None
x_real_ip = _normalize_ip(request.META.get("HTTP_X_REAL_IP"))
if x_real_ip:
return x_real_ip
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded_for:
for part in forwarded_for.split(","):
ip = _normalize_ip(part)
if ip:
return ip
return _normalize_ip(request.META.get("REMOTE_ADDR"))
def get_request_id(request) -> str:
if not request:
return ""
return (
request.META.get("HTTP_X_REQUEST_ID")
or request.META.get("HTTP_X_CORRELATION_ID")
or ""
)

21
app/apps/core/apps.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from django.apps import AppConfig
from django.db.models.signals import post_migrate
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
label = "core"
verbose_name = "Core"
def ready(self) -> None:
from .rbac import assign_role_permissions, ensure_role_groups
def _ensure_roles(**_kwargs) -> None:
ensure_role_groups()
assign_role_permissions()
post_migrate.connect(_ensure_roles, dispatch_uid="core_rbac")
return super().ready()

View File

@@ -3,6 +3,8 @@ import os
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from apps.core.rbac import ROLE_ADMIN, set_user_role
class Command(BaseCommand):
help = "Ensure a Django superuser exists using environment variables"
@@ -41,6 +43,7 @@ class Command(BaseCommand):
if created:
user.set_password(password)
set_user_role(user, ROLE_ADMIN)
user.save()
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created."))
return
@@ -59,10 +62,11 @@ class Command(BaseCommand):
user.is_superuser = True
changed = True
set_user_role(user, ROLE_ADMIN)
if changed:
user.save()
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated."))
else:
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already present."))

View File

@@ -0,0 +1,51 @@
from django.core.management.base import BaseCommand
from guardian.shortcuts import assign_perm
from apps.access.models import AccessRequest
from apps.core.rbac import assign_default_object_permissions
from apps.keys.models import SSHKey
from apps.servers.models import Server
class Command(BaseCommand):
help = "Backfill guardian object permissions for access requests and SSH keys."
def handle(self, *args, **options):
access_count = 0
for access_request in AccessRequest.objects.select_related("requester"):
if not access_request.requester_id:
assign_default_object_permissions(access_request)
else:
for perm in (
"access.view_accessrequest",
"access.change_accessrequest",
"access.delete_accessrequest",
):
assign_perm(perm, access_request.requester, access_request)
assign_default_object_permissions(access_request)
access_count += 1
key_count = 0
for key in SSHKey.objects.select_related("user"):
if not key.user_id:
assign_default_object_permissions(key)
else:
for perm in ("keys.view_sshkey", "keys.change_sshkey", "keys.delete_sshkey"):
assign_perm(perm, key.user, key)
assign_default_object_permissions(key)
key_count += 1
server_count = 0
for server in Server.objects.all():
assign_default_object_permissions(server)
server_count += 1
self.stdout.write(
self.style.SUCCESS(
"Synced object permissions for "
f"{access_count} access requests, "
f"{key_count} SSH keys, "
f"and {server_count} servers."
)
)

155
app/apps/core/rbac.py Normal file
View File

@@ -0,0 +1,155 @@
from __future__ import annotations
from django.contrib.auth.models import Group, Permission
from guardian.shortcuts import assign_perm
from ninja.errors import HttpError
ROLE_ADMIN = "administrator"
ROLE_OPERATOR = "operator"
ROLE_AUDITOR = "auditor"
ROLE_USER = "user"
ROLE_ORDER = (ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR, ROLE_USER)
ROLE_ALL = ROLE_ORDER
ROLE_ALIASES = {"admin": ROLE_ADMIN}
ROLE_INPUTS = tuple(sorted(set(ROLE_ORDER) | set(ROLE_ALIASES.keys())))
def _model_perms(app_label: str, model: str, actions: list[str]) -> list[str]:
return [f"{app_label}.{action}_{model}" for action in actions]
ROLE_PERMISSIONS = {
ROLE_ADMIN: [],
ROLE_OPERATOR: [
*_model_perms("servers", "server", ["view"]),
*_model_perms("access", "accessrequest", ["add", "view", "change", "delete"]),
*_model_perms("keys", "sshkey", ["add", "view", "change", "delete"]),
*_model_perms("telemetry", "telemetryevent", ["add", "view"]),
*_model_perms("audit", "auditlog", ["view"]),
*_model_perms("audit", "auditeventtype", ["view"]),
*_model_perms("auth", "user", ["add", "view"]),
],
ROLE_AUDITOR: [
*_model_perms("audit", "auditlog", ["view"]),
*_model_perms("audit", "auditeventtype", ["view"]),
],
ROLE_USER: [
*_model_perms("servers", "server", ["view"]),
*_model_perms("access", "accessrequest", ["add"]),
*_model_perms("keys", "sshkey", ["add"]),
],
}
OBJECT_PERMISSION_MODELS = {
("servers", "server"),
("access", "accessrequest"),
("keys", "sshkey"),
}
def normalize_role(role: str) -> str:
normalized = (role or "").strip().lower()
return ROLE_ALIASES.get(normalized, normalized)
def ensure_role_groups() -> None:
for role in ROLE_ORDER:
Group.objects.get_or_create(name=role)
def assign_role_permissions() -> None:
ensure_role_groups()
for role, perm_codes in ROLE_PERMISSIONS.items():
group = Group.objects.get(name=role)
if role == ROLE_ADMIN:
group.permissions.set(Permission.objects.all())
continue
perms = []
for code in perm_codes:
if "." not in code:
continue
app_label, codename = code.split(".", 1)
try:
perms.append(
Permission.objects.get(
content_type__app_label=app_label,
codename=codename,
)
)
except Permission.DoesNotExist:
continue
group.permissions.set(perms)
def assign_default_object_permissions(instance) -> None:
app_label = instance._meta.app_label
model_name = instance._meta.model_name
if (app_label, model_name) not in OBJECT_PERMISSION_MODELS:
return
ensure_role_groups()
groups = {group.name: group for group in Group.objects.filter(name__in=ROLE_ORDER)}
for role, perm_codes in ROLE_PERMISSIONS.items():
if role == ROLE_ADMIN:
continue
group = groups.get(role)
if not group:
continue
for code in perm_codes:
if "." not in code:
continue
perm_app, codename = code.split(".", 1)
if perm_app != app_label:
continue
if not codename.endswith(f"_{model_name}"):
continue
if codename.startswith("add_"):
continue
assign_perm(code, group, instance)
def get_user_role(user, default: str = ROLE_USER) -> str | None:
if not user or not getattr(user, "is_authenticated", False):
return None
if getattr(user, "is_superuser", False):
return ROLE_ADMIN
group_names = set(user.groups.values_list("name", flat=True))
for role in ROLE_ORDER:
if role in group_names:
return role
return default
def set_user_role(user, role: str) -> str:
canonical = normalize_role(role)
if canonical not in ROLE_ORDER:
raise ValueError(f"Invalid role: {role}")
ensure_role_groups()
role_groups = list(Group.objects.filter(name__in=ROLE_ORDER))
if role_groups:
user.groups.remove(*role_groups)
target_group = Group.objects.get(name=canonical)
user.groups.add(target_group)
if canonical == ROLE_ADMIN:
user.is_staff = True
user.is_superuser = True
elif canonical in {ROLE_OPERATOR, ROLE_AUDITOR}:
user.is_staff = True
user.is_superuser = False
else:
user.is_staff = False
user.is_superuser = False
return canonical
def require_authenticated(request) -> None:
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
def require_perms(request, *perms: str) -> None:
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not user.has_perms(perms):
raise HttpError(403, "Forbidden")

View File

@@ -1,10 +1,11 @@
from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from .models import SSHKey
@admin.register(SSHKey)
class SSHKeyAdmin(admin.ModelAdmin):
class SSHKeyAdmin(GuardedModelAdmin):
list_display = ("id", "user", "name", "key_type", "fingerprint", "is_active", "created_at")
list_filter = ("is_active", "key_type")
search_fields = ("name", "user__username", "user__email", "fingerprint")

View File

@@ -5,3 +5,7 @@ class KeysConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.keys"
verbose_name = "SSH Keys"
def ready(self) -> None:
from . import signals # noqa: F401
return super().ready()

19
app/apps/keys/signals.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from django.db.models.signals import post_save
from django.dispatch import receiver
from guardian.shortcuts import assign_perm
from apps.core.rbac import assign_default_object_permissions
from .models import SSHKey
@receiver(post_save, sender=SSHKey)
def assign_ssh_key_perms(sender, instance: SSHKey, created: bool, **kwargs) -> None:
if not created:
return
if instance.user_id:
user = instance.user
for perm in ("keys.view_sshkey", "keys.change_sshkey", "keys.delete_sshkey"):
assign_perm(perm, user, instance)
assign_default_object_permissions(instance)

View File

@@ -1,16 +1,27 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import Server
from guardian.admin import GuardedModelAdmin
from .models import AgentCertificateAuthority, EnrollmentToken, Server
@admin.register(Server)
class ServerAdmin(admin.ModelAdmin):
list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "created_at")
class ServerAdmin(GuardedModelAdmin):
list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "agent_enrolled_at", "created_at")
list_display_links = ("display_name",)
search_fields = ("display_name", "hostname", "ipv4", "ipv6")
list_filter = ("created_at",)
readonly_fields = ("created_at", "updated_at")
fields = ("display_name", "hostname", "ipv4", "ipv6", "image", "created_at", "updated_at")
readonly_fields = ("created_at", "updated_at", "agent_enrolled_at")
fields = (
"display_name",
"hostname",
"ipv4",
"ipv6",
"image",
"agent_enrolled_at",
"created_at",
"updated_at",
)
def avatar(self, obj: Server):
if obj.image_url:
@@ -27,3 +38,51 @@ class ServerAdmin(admin.ModelAdmin):
avatar.short_description = ""
@admin.register(EnrollmentToken)
class EnrollmentTokenAdmin(admin.ModelAdmin):
list_display = ("token", "created_at", "expires_at", "used_at", "server")
list_filter = ("created_at", "used_at")
search_fields = ("token", "server__display_name", "server__hostname")
readonly_fields = ("token", "created_at", "used_at", "server", "created_by")
fields = ("token", "expires_at", "created_by", "created_at", "used_at", "server")
def save_model(self, request, obj, form, change) -> None:
if not obj.pk:
obj.ensure_token()
if request.user and request.user.is_authenticated and not obj.created_by_id:
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.register(AgentCertificateAuthority)
class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "is_active", "created_at", "revoked_at")
list_filter = ("is_active", "created_at", "revoked_at")
search_fields = ("name", "fingerprint")
readonly_fields = ("fingerprint", "serial", "created_at", "revoked_at", "created_by")
fields = (
"name",
"is_active",
"cert_pem",
"key_pem",
"fingerprint",
"serial",
"created_by",
"created_at",
"revoked_at",
)
actions = ["revoke_selected"]
def save_model(self, request, obj, form, change) -> None:
if request.user and request.user.is_authenticated and not obj.created_by_id:
obj.created_by = request.user
obj.ensure_material()
if obj.is_active:
AgentCertificateAuthority.objects.exclude(pk=obj.pk).update(is_active=False)
super().save_model(request, obj, form, change)
@admin.action(description="Revoke selected CAs")
def revoke_selected(self, request, queryset):
for ca in queryset:
ca.revoke()
ca.save(update_fields=["is_active", "revoked_at"])

View File

@@ -6,4 +6,7 @@ class ServersConfig(AppConfig):
name = "apps.servers"
verbose_name = "Servers"
def ready(self) -> None:
from . import signals # noqa: F401
return super().ready()

View File

@@ -0,0 +1,73 @@
from django.conf import settings
from django.db import migrations, models
import django.utils.timezone
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("servers", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="server",
name="agent_cert_fingerprint",
field=models.CharField(blank=True, max_length=128, null=True),
),
migrations.AddField(
model_name="server",
name="agent_cert_serial",
field=models.CharField(blank=True, max_length=64, null=True),
),
migrations.AddField(
model_name="server",
name="agent_enrolled_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name="EnrollmentToken",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("token", models.CharField(max_length=128, unique=True)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("expires_at", models.DateTimeField(blank=True, null=True)),
("used_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="server_enrollment_tokens",
to=settings.AUTH_USER_MODEL,
),
),
(
"server",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="enrollment_tokens",
to="servers.server",
),
),
],
options={
"verbose_name": "Enrollment token",
"verbose_name_plural": "Enrollment tokens",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="enrollmenttoken",
index=models.Index(fields=["created_at"], name="servers_enroll_created_idx"),
),
migrations.AddIndex(
model_name="enrollmenttoken",
index=models.Index(fields=["used_at"], name="servers_enroll_used_idx"),
),
]

View File

@@ -0,0 +1,44 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0002_agent_enrollment"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AgentCertificateAuthority",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(default="Keywarden Agent CA", max_length=128)),
("cert_pem", models.TextField()),
("key_pem", models.TextField()),
("fingerprint", models.CharField(blank=True, max_length=128)),
("serial", models.CharField(blank=True, max_length=64)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="agent_certificate_authorities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Agent certificate authority",
"verbose_name_plural": "Agent certificate authorities",
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,8 +1,16 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import models
from django.utils.text import slugify
from django.utils import timezone
hostname_validator = RegexValidator(
@@ -17,6 +25,9 @@ class Server(models.Model):
ipv4 = models.GenericIPAddressField(null=True, blank=True, protocol="IPv4", unique=True)
ipv6 = models.GenericIPAddressField(null=True, blank=True, protocol="IPv6", unique=True)
image = models.ImageField(upload_to="servers/", 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_serial = models.CharField(max_length=64, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -41,3 +52,108 @@ class Server(models.Model):
return (self.display_name or "?").strip()[:1].upper() or "?"
class EnrollmentToken(models.Model):
token = models.CharField(max_length=128, unique=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
expires_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="server_enrollment_tokens",
)
used_at = models.DateTimeField(null=True, blank=True)
server = models.ForeignKey(
Server, null=True, blank=True, on_delete=models.SET_NULL, related_name="enrollment_tokens"
)
class Meta:
verbose_name = "Enrollment token"
verbose_name_plural = "Enrollment tokens"
indexes = [
models.Index(fields=["created_at"], name="servers_enroll_created_idx"),
models.Index(fields=["used_at"], name="servers_enroll_used_idx"),
]
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.token[:8]}... ({'used' if self.used_at else 'unused'})"
def ensure_token(self) -> None:
if not self.token:
self.token = secrets.token_urlsafe(32)
def is_valid(self) -> bool:
if self.used_at:
return False
if self.expires_at and self.expires_at <= timezone.now():
return False
return True
def mark_used(self, server: Server) -> None:
self.used_at = timezone.now()
self.server = server
def save(self, *args, **kwargs):
self.ensure_token()
super().save(*args, **kwargs)
class AgentCertificateAuthority(models.Model):
name = models.CharField(max_length=128, default="Keywarden Agent CA")
cert_pem = models.TextField()
key_pem = models.TextField()
fingerprint = models.CharField(max_length=128, blank=True)
serial = models.CharField(max_length=64, blank=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="agent_certificate_authorities",
)
class Meta:
verbose_name = "Agent certificate authority"
verbose_name_plural = "Agent certificate authorities"
ordering = ["-created_at"]
def __str__(self) -> str:
status = "active" if self.is_active and not self.revoked_at else "revoked"
return f"{self.name} ({status})"
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def ensure_material(self) -> None:
if self.cert_pem and self.key_pem:
return
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.name)])
now = datetime.utcnow()
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
self.cert_pem = cert_pem
self.key_pem = key_pem
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
self.serial = format(cert.serial_number, "x")

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.core.rbac import assign_default_object_permissions
from .models import Server
@receiver(post_save, sender=Server)
def assign_server_perms(sender, instance: Server, created: bool, **kwargs) -> None:
if not created:
return
assign_default_object_permissions(instance)

View File

@@ -1,6 +1,31 @@
#!/bin/sh
set -eu
DOMAIN="${KEYWARDEN_DOMAIN:-localhost}"
CERT_DIR="/etc/nginx/certs"
NGINX_TEMPLATE="/etc/nginx/nginx.conf.template"
NGINX_CONF="/etc/nginx/nginx.conf"
# Replaces server_name in nginx.conf with $KEYWARDEN_DOMAIN
if [ -f "$NGINX_TEMPLATE" ]; then
ESCAPED_DOMAIN=$(printf '%s' "$DOMAIN" | sed 's/[&/]/\\&/g')
sed "s/__SERVER_NAME__/${ESCAPED_DOMAIN}/g" "$NGINX_TEMPLATE" > "$NGINX_CONF"
fi
# Creates self-signed certs using mkcert $KEYWARDEN_DOMAIN, and renaming them.
if [ ! -f "$CERT_DIR/certificate.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then
mkdir -p "$CERT_DIR"
if command -v mkcert >/dev/null 2>&1; then
mkcert -install >/dev/null 2>&1 || true
mkcert -cert-file "$CERT_DIR/certificate.pem" -key-file "$CERT_DIR/key.pem" "$DOMAIN"
else
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-subj "/CN=$DOMAIN" \
-keyout "$CERT_DIR/key.pem" \
-out "$CERT_DIR/certificate.pem"
fi
fi
# Build Tailwind CSS (best-effort; skip if not configured)
python manage.py tailwind install || true
python manage.py tailwind build || true
@@ -12,4 +37,3 @@ python manage.py migrate --noinput
python manage.py ensure_admin
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf

View File

@@ -5,11 +5,13 @@ from typing import List, Optional
from django.http import HttpRequest
from django.utils import timezone
from guardian.shortcuts import get_objects_for_user
from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.access.models import AccessRequest
from apps.core.rbac import require_authenticated
from apps.servers.models import Server
@@ -44,16 +46,6 @@ class AccessQuery(Schema):
requester_id: Optional[int] = None
def _require_authenticated(request: HttpRequest) -> None:
if not getattr(request.user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
def _is_admin(request: HttpRequest) -> bool:
user = request.user
return bool(getattr(user, "is_staff", False) or getattr(user, "is_superuser", False))
def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
return AccessRequestOut(
id=access_request.id,
@@ -68,19 +60,38 @@ def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
)
def _has_global_perm(request: HttpRequest, perm: str) -> bool:
user = request.user
return bool(user and user.has_perm(perm))
def build_router() -> Router:
router = Router()
@router.get("/", response=List[AccessRequestOut])
def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)):
"""List access requests for the user, or all if admin."""
_require_authenticated(request)
qs = AccessRequest.objects.order_by("-requested_at")
if _is_admin(request):
if filters.requester_id:
qs = qs.filter(requester_id=filters.requester_id)
"""List access requests with pagination and filters.
Auth: required.
Permissions:
- If user has global `access.view_accessrequest`, returns all requests.
- Otherwise, returns only objects with `access.view_accessrequest` object permission.
Filters: status, server_id, requester_id (requester_id is honored only with global view).
"""
require_authenticated(request)
user = request.user
if _has_global_perm(request, "access.view_accessrequest"):
qs = AccessRequest.objects.all()
else:
qs = qs.filter(requester=request.user)
qs = get_objects_for_user(
user,
"access.view_accessrequest",
klass=AccessRequest,
accept_global_perms=False,
)
qs = qs.order_by("-requested_at")
if filters.requester_id and _has_global_perm(request, "access.view_accessrequest"):
qs = qs.filter(requester_id=filters.requester_id)
if filters.status:
qs = qs.filter(status=filters.status)
if filters.server_id:
@@ -90,8 +101,15 @@ def build_router() -> Router:
@router.post("/", response=AccessRequestOut)
def create_request(request: HttpRequest, payload: AccessRequestCreateIn):
"""Create a new access request for a server."""
_require_authenticated(request)
"""Create a new access request for the current user.
Auth: required.
Permissions: requires global `access.add_accessrequest`.
Side effects: grants owner object perms on the new request.
"""
require_authenticated(request)
if not request.user.has_perm("access.add_accessrequest"):
raise HttpError(403, "Forbidden")
try:
server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist:
@@ -110,28 +128,39 @@ def build_router() -> Router:
@router.get("/{request_id}", response=AccessRequestOut)
def get_request(request: HttpRequest, request_id: int):
"""Get an access request if permitted."""
_require_authenticated(request)
"""Get a single access request by id.
Auth: required.
Permissions: requires `access.view_accessrequest` on the object.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and access_request.requester_id != request.user.id:
if not request.user.has_perm("access.view_accessrequest", access_request):
raise HttpError(403, "Forbidden")
return _request_to_out(access_request)
@router.patch("/{request_id}", response=AccessRequestOut)
def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn):
"""Update request status or expiry (admin or owner with restrictions)."""
_require_authenticated(request)
"""Update request status or expiry.
Auth: required.
Permissions: requires `access.change_accessrequest` on the object.
Rules:
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
update expires_at.
- Non-admin can only set status to cancelled, and only while pending.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
is_admin = _is_admin(request)
is_owner = access_request.requester_id == request.user.id
if not is_admin and not is_owner:
if not request.user.has_perm("access.change_accessrequest", access_request):
raise HttpError(403, "Forbidden")
is_admin = _has_global_perm(request, "access.change_accessrequest")
if payload.status is None and payload.expires_at is None:
raise HttpError(422, {"detail": "No fields provided."})
if payload.expires_at is not None:
@@ -166,13 +195,17 @@ def build_router() -> Router:
@router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request if permitted."""
_require_authenticated(request)
"""Delete an access request.
Auth: required.
Permissions: requires `access.delete_accessrequest` on the object.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and access_request.requester_id != request.user.id:
if not request.user.has_perm("access.delete_accessrequest", access_request):
raise HttpError(403, "Forbidden")
access_request.delete()
return 204, None

View File

@@ -3,6 +3,7 @@ from typing import Optional
from django.http import HttpRequest
from ninja import Router, Schema
from apps.core.rbac import require_authenticated
class UserSchema(Schema):
id: int
@@ -20,6 +21,7 @@ def build_router() -> Router:
@router.get("/me", response=UserSchema)
def me(request: HttpRequest):
"""Return the current authenticated user's profile."""
require_authenticated(request)
user = request.user
return {
"id": user.id,

View File

@@ -1,7 +1,13 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List, Optional
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.http import HttpRequest
from django.utils import timezone
@@ -9,9 +15,10 @@ from ninja import Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import require_perms
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.servers.models import Server
from apps.servers.models import AgentCertificateAuthority, EnrollmentToken, Server, hostname_validator
from apps.telemetry.models import TelemetryEvent
@@ -34,21 +41,91 @@ class SyncReportOut(Schema):
status: str
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
class AgentEnrollIn(Schema):
token: str
csr_pem: str
host: Optional[str] = None
class AgentEnrollOut(Schema):
server_id: str
client_cert_pem: str
ca_cert_pem: str
class LogEventIn(Schema):
timestamp: str
category: str
event_type: str
unit: Optional[str] = None
priority: Optional[str] = None
hostname: Optional[str] = None
username: Optional[str] = None
principal: Optional[str] = None
source_ip: Optional[str] = None
session_id: Optional[str] = None
message: Optional[str] = None
raw: Optional[str] = None
fields: Optional[dict] = None
class LogIngestOut(Schema):
status: str
accepted: int
def build_router() -> Router:
router = Router()
@router.post("/enroll", response=AgentEnrollOut, auth=None)
def enroll_agent(request: HttpRequest, payload: AgentEnrollIn):
"""Enroll a server agent using a one-time token."""
token_value = (payload.token or "").strip()
if not token_value:
raise HttpError(422, "Token required")
try:
token = EnrollmentToken.objects.get(token=token_value)
except EnrollmentToken.DoesNotExist:
raise HttpError(403, "Invalid token")
if not token.is_valid():
raise HttpError(403, "Token expired or already used")
host = (payload.host or "").strip()[:253]
display_name = host or "server"
hostname = None
if host:
try:
hostname_validator(host)
hostname = host
except ValidationError:
hostname = None
server = Server.objects.create(display_name=display_name, hostname=hostname)
token.mark_used(server)
token.save(update_fields=["used_at", "server"])
csr = _load_csr((payload.csr_pem or "").strip())
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
server.agent_enrolled_at = timezone.now()
server.agent_cert_fingerprint = fingerprint
server.agent_cert_serial = serial
server.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"])
return AgentEnrollOut(
server_id=str(server.id),
client_cert_pem=cert_pem,
ca_cert_pem=ca_pem,
)
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int):
"""Return authorized public keys for a server (admin only)."""
_require_admin(request)
"""Return authorized public keys for a server (admin or operator)."""
require_perms(
request,
"servers.view_server",
"keys.view_sshkey",
"access.view_accessrequest",
)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -78,8 +155,8 @@ def build_router() -> Router:
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut)
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn):
"""Record an agent sync report for a server (admin only)."""
_require_admin(request)
"""Record an agent sync report for a server (admin or operator)."""
require_perms(request, "servers.view_server", "telemetry.add_telemetryevent")
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -98,7 +175,75 @@ def build_router() -> Router:
)
return SyncReportOut(status="ok")
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None)
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn]):
"""Accept log batches from agents (mTLS required at the edge)."""
try:
Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
# TODO: enqueue to Valkey and persist to SQLite slices.
return LogIngestOut(status="accepted", accepted=len(payload))
return router
def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
ca = (
AgentCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
.order_by("-created_at")
.first()
)
if not ca:
raise HttpError(500, "Agent CA not configured")
try:
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
ca_key = serialization.load_pem_private_key(ca.key_pem.encode("utf-8"), password=None)
except (ValueError, TypeError):
raise HttpError(500, "Invalid agent CA material")
return ca_cert, ca_key, ca.cert_pem
def _load_csr(csr_pem: str) -> x509.CertificateSigningRequest:
try:
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
except ValueError:
raise HttpError(422, "Invalid CSR")
if not csr.is_signature_valid:
raise HttpError(422, "Invalid CSR signature")
return csr
def _issue_client_cert(
csr: x509.CertificateSigningRequest, host: str | None, server_id: int
) -> tuple[str, str, str, str]:
ca_cert, ca_key, ca_pem = _load_agent_ca()
now = datetime.utcnow()
subject = csr.subject
if len(subject) == 0:
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"keywarden-agent-{server_id}")])
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(csr.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=settings.KEYWARDEN_AGENT_CERT_VALIDITY_DAYS))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False)
)
if host:
try:
hostname_validator(host)
builder = builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(host)]), critical=False)
except ValidationError:
pass
cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA256())
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
serial = format(cert.serial_number, "x")
return cert_pem, ca_pem, fingerprint, serial
router = build_router()

View File

@@ -8,6 +8,7 @@ from django.http import HttpRequest
from ninja import Query, Router, Schema
from apps.audit.models import AuditEventType, AuditLog
from apps.core.rbac import require_perms
class AuditEventTypeSchema(Schema):
id: int
@@ -47,6 +48,7 @@ def build_router() -> Router:
@router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest):
"""List audit event types and their default severity."""
require_perms(request, "audit.view_auditeventtype")
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [
{
@@ -62,6 +64,7 @@ def build_router() -> Router:
@router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination."""
require_perms(request, "audit.view_auditlog")
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity:
qs = qs.filter(severity=filters.severity)

View File

@@ -7,10 +7,12 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.http import HttpRequest
from django.utils import timezone
from guardian.shortcuts import get_objects_for_user
from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import require_authenticated
from apps.keys.models import SSHKey
@@ -43,16 +45,6 @@ class KeysQuery(Schema):
user_id: Optional[int] = None
def _require_authenticated(request: HttpRequest) -> None:
if not getattr(request.user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
def _is_admin(request: HttpRequest) -> bool:
user = request.user
return bool(getattr(user, "is_staff", False) or getattr(user, "is_superuser", False))
def _key_to_out(key: SSHKey) -> KeyOut:
return KeyOut(
id=key.id,
@@ -67,33 +59,67 @@ def _key_to_out(key: SSHKey) -> KeyOut:
)
def _has_global_perm(request: HttpRequest, perm: str) -> bool:
user = request.user
return bool(user and user.has_perm(perm))
def build_router() -> Router:
router = Router()
@router.get("/", response=List[KeyOut])
def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)):
"""List SSH keys for the current user, or any user if admin."""
_require_authenticated(request)
qs = SSHKey.objects.order_by("-created_at")
if _is_admin(request):
if filters.user_id:
qs = qs.filter(user_id=filters.user_id)
"""List SSH keys with pagination and filters.
Auth: required.
Permissions:
- If user has global `keys.view_sshkey`, returns all keys.
- Otherwise, returns only objects with `keys.view_sshkey` object permission.
Filter: user_id (honored only with global view).
"""
require_authenticated(request)
user = request.user
if _has_global_perm(request, "keys.view_sshkey"):
qs = SSHKey.objects.all()
else:
qs = qs.filter(user=request.user)
qs = get_objects_for_user(
user,
"keys.view_sshkey",
klass=SSHKey,
accept_global_perms=False,
)
qs = qs.order_by("-created_at")
if filters.user_id and _has_global_perm(request, "keys.view_sshkey"):
qs = qs.filter(user_id=filters.user_id)
qs = qs[filters.offset : filters.offset + filters.limit]
return [_key_to_out(key) for key in qs]
@router.post("/", response=KeyOut)
def create_key(request: HttpRequest, payload: KeyCreateIn):
"""Create an SSH public key for the current user (admin can specify user_id)."""
_require_authenticated(request)
"""Create an SSH public key.
Auth: required.
Permissions: requires global `keys.add_sshkey`.
Rules:
- Default owner is the current user.
- If caller has global `keys.add_sshkey` and `keys.view_sshkey`, they may specify user_id.
Side effects: grants owner object perms on the new key.
"""
require_authenticated(request)
if not request.user.has_perm("keys.add_sshkey"):
raise HttpError(403, "Forbidden")
is_admin = _has_global_perm(request, "keys.add_sshkey") and _has_global_perm(
request, "keys.view_sshkey"
)
owner = request.user
if _is_admin(request) and payload.user_id:
if is_admin and payload.user_id:
User = get_user_model()
try:
owner = User.objects.get(id=payload.user_id)
except User.DoesNotExist:
raise HttpError(404, "User not found")
elif payload.user_id and payload.user_id != request.user.id:
raise HttpError(403, "Forbidden")
name = (payload.name or "").strip()
if not name:
raise HttpError(422, {"name": ["Name cannot be empty."]})
@@ -110,25 +136,33 @@ def build_router() -> Router:
@router.get("/{key_id}", response=KeyOut)
def get_key(request: HttpRequest, key_id: int):
"""Get a specific SSH key if permitted."""
_require_authenticated(request)
"""Get a specific SSH key by id.
Auth: required.
Permissions: requires `keys.view_sshkey` on the object.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and key.user_id != request.user.id:
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
return _key_to_out(key)
@router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state if permitted."""
_require_authenticated(request)
"""Update key name or active state.
Auth: required.
Permissions: requires `keys.change_sshkey` on the object.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and key.user_id != request.user.id:
if not request.user.has_perm("keys.change_sshkey", key):
raise HttpError(403, "Forbidden")
if payload.name is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."})
@@ -148,13 +182,18 @@ def build_router() -> Router:
@router.delete("/{key_id}", response={204: None})
def delete_key(request: HttpRequest, key_id: int):
"""Revoke an SSH key if permitted (soft delete)."""
_require_authenticated(request)
"""Revoke (soft delete) an SSH key.
Auth: required.
Permissions: requires `keys.delete_sshkey` on the object.
Behavior: sets is_active false and revoked_at if key is active.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and key.user_id != request.user.id:
if not request.user.has_perm("keys.delete_sshkey", key):
raise HttpError(403, "Forbidden")
if key.is_active:
key.is_active = False

View File

@@ -7,6 +7,7 @@ from django.http import HttpRequest
from ninja import File, Form, Router, Schema
from ninja.files import UploadedFile
from ninja.errors import HttpError
from apps.core.rbac import require_perms
from apps.servers.models import Server
@@ -34,20 +35,13 @@ class ServerUpdate(Schema):
ipv6: Optional[str] = None
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def build_router() -> Router:
router = Router()
@router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest):
"""List servers visible to authenticated users."""
require_perms(request, "servers.view_server")
servers = Server.objects.all()
return [
{
@@ -65,6 +59,7 @@ def build_router() -> Router:
@router.get("/{server_id}", response=ServerOut)
def get_server(request: HttpRequest, server_id: int):
"""Get server details by id."""
require_perms(request, "servers.view_server")
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -82,22 +77,8 @@ def build_router() -> Router:
@router.post("/", response=ServerOut)
def create_server_json(request: HttpRequest, payload: ServerCreate):
"""Create a server using JSON payload (admin only)."""
_require_admin(request)
server = Server.objects.create(
display_name=payload.display_name.strip(),
hostname=(payload.hostname or "").strip() or None,
ipv4=(payload.ipv4 or "").strip() or None,
ipv6=(payload.ipv6 or "").strip() or None,
)
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
require_perms(request, "servers.add_server")
raise HttpError(403, "Servers are created via agent enrollment tokens.")
@router.post("/upload", response=ServerOut)
def create_server_multipart(
@@ -109,30 +90,13 @@ def build_router() -> Router:
image: Optional[UploadedFile] = File(None),
):
"""Create a server with optional image upload (admin only)."""
_require_admin(request)
server = Server(
display_name=display_name.strip(),
hostname=(hostname or "").strip() or None,
ipv4=(ipv4 or "").strip() or None,
ipv6=(ipv6 or "").strip() or None,
)
if image:
server.image.save(image.name, image) # type: ignore[arg-type]
server.save()
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
require_perms(request, "servers.add_server")
raise HttpError(403, "Servers are created via agent enrollment tokens.")
@router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
"""Update server fields (admin only)."""
_require_admin(request)
require_perms(request, "servers.change_server")
if (
payload.display_name is None
and payload.hostname is None
@@ -172,7 +136,7 @@ def build_router() -> Router:
@router.delete("/{server_id}", response={204: None})
def delete_server(request: HttpRequest, server_id: int):
"""Delete a server by id (admin only)."""
_require_admin(request)
require_perms(request, "servers.delete_server")
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:

View File

@@ -2,6 +2,8 @@ from typing import Literal, TypedDict
from ninja import Router
from apps.core.rbac import require_authenticated
class HealthResponse(TypedDict):
status: Literal["ok"]
@@ -11,8 +13,9 @@ def build_router() -> Router:
router = Router()
@router.get("/health", response=HealthResponse)
def health() -> HealthResponse:
def health(request) -> HealthResponse:
"""Health check endpoint for service monitoring."""
require_authenticated(request)
return {"status": "ok"}
return router

View File

@@ -10,6 +10,7 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import require_perms
from apps.servers.models import Server
from apps.telemetry.models import TelemetryEvent
@@ -51,14 +52,6 @@ class TelemetryQuery(Schema):
success: Optional[bool] = None
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def _event_to_out(event: TelemetryEvent) -> TelemetryOut:
return TelemetryOut(
id=event.id,
@@ -78,8 +71,8 @@ def build_router() -> Router:
@router.get("/", response=List[TelemetryOut])
def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)):
"""List telemetry events with filters (admin only)."""
_require_admin(request)
"""List telemetry events with filters (admin or operator)."""
require_perms(request, "telemetry.view_telemetryevent")
qs = TelemetryEvent.objects.order_by("-created_at")
if filters.event_type:
qs = qs.filter(event_type=filters.event_type)
@@ -94,8 +87,8 @@ def build_router() -> Router:
@router.post("/", response=TelemetryOut)
def create_event(request: HttpRequest, payload: TelemetryCreateIn):
"""Create a telemetry event entry (admin only)."""
_require_admin(request)
"""Create a telemetry event entry (admin or operator)."""
require_perms(request, "telemetry.add_telemetryevent")
server = None
if payload.server_id:
try:
@@ -122,8 +115,8 @@ def build_router() -> Router:
@router.get("/summary", response=TelemetrySummaryOut)
def summary(request: HttpRequest):
"""Return a high-level telemetry summary (admin only)."""
_require_admin(request)
"""Return a high-level telemetry summary (admin or operator)."""
require_perms(request, "telemetry.view_telemetryevent")
totals = TelemetryEvent.objects.aggregate(
total=Count("id"),
success=Count("id", filter=models.Q(success=True)),

View File

@@ -9,11 +9,13 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import EmailStr, Field
from apps.core.rbac import ROLE_USER, get_user_role, require_perms, set_user_role
class UserCreateIn(Schema):
email: EmailStr
password: str = Field(min_length=8)
role: Literal["admin", "user"]
role: Literal["administrator", "operator", "auditor", "user", "admin"]
class UserListOut(Schema):
@@ -33,7 +35,7 @@ class UserDetailOut(Schema):
class UserUpdateIn(Schema):
email: EmailStr | None = None
password: str | None = Field(default=None, min_length=8)
role: Literal["admin", "user"] | None = None
role: Literal["administrator", "operator", "auditor", "user", "admin"] | None = None
is_active: bool | None = None
@@ -42,25 +44,8 @@ class UsersQuery(Schema):
offset: int = Field(default=0, ge=0)
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def _role_from_user(user) -> str:
return "admin" if (user.is_staff or user.is_superuser) else "user"
def _apply_role(user, role: str) -> None:
if role == "admin":
user.is_staff = True
user.is_superuser = True
else:
user.is_staff = False
user.is_superuser = False
return get_user_role(user) or ROLE_USER
def build_router() -> Router:
@@ -68,19 +53,23 @@ def build_router() -> Router:
@router.post("/", response=UserDetailOut)
def create_user(request: HttpRequest, payload: UserCreateIn):
"""Create a user with role and password (admin only)."""
_require_admin(request)
"""Create a user with role and password (admin or operator)."""
require_perms(request, "auth.add_user")
User = get_user_model()
email = payload.email.strip().lower()
if User.objects.filter(email__iexact=email).exists():
raise HttpError(422, {"email": ["Email already exists."]})
user = User(username=email, email=email, is_active=True)
_apply_role(user, payload.role)
user.set_password(payload.password)
try:
user.save()
except IntegrityError:
raise HttpError(422, {"email": ["Email already exists."]})
try:
set_user_role(user, payload.role)
except ValueError:
raise HttpError(422, {"role": ["Invalid role."]})
user.save()
return {
"id": user.id,
"email": user.email,
@@ -90,8 +79,8 @@ def build_router() -> Router:
@router.get("/", response=List[UserListOut])
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
"""List users with pagination (admin only)."""
_require_admin(request)
"""List users with pagination (admin or operator)."""
require_perms(request, "auth.view_user")
User = get_user_model()
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
return [
@@ -106,8 +95,8 @@ def build_router() -> Router:
@router.get("/{user_id}", response=UserDetailOut)
def get_user(request: HttpRequest, user_id: int):
"""Get user details by id (admin only)."""
_require_admin(request)
"""Get user details by id (admin or operator)."""
require_perms(request, "auth.view_user")
User = get_user_model()
try:
user = User.objects.get(id=user_id)
@@ -123,7 +112,7 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user fields such as role, email, or status (admin only)."""
_require_admin(request)
require_perms(request, "auth.change_user")
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."})
User = get_user_model()
@@ -140,7 +129,10 @@ def build_router() -> Router:
if payload.password is not None:
user.set_password(payload.password)
if payload.role is not None:
_apply_role(user, payload.role)
try:
set_user_role(user, payload.role)
except ValueError:
raise HttpError(422, {"role": ["Invalid role."]})
if payload.is_active is not None:
user.is_active = payload.is_active
user.save()
@@ -154,7 +146,7 @@ def build_router() -> Router:
@router.delete("/{user_id}", response={204: None})
def delete_user(request: HttpRequest, user_id: int):
"""Delete a user by id (admin only)."""
_require_admin(request)
require_perms(request, "auth.delete_user")
User = get_user_model()
try:
user = User.objects.get(id=user_id)

View File

@@ -24,6 +24,7 @@ CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
INSTALLED_APPS = [
"unfold.contrib.guardian",
"unfold", # Admin UI
"unfold.contrib.filters",
"django.contrib.admin",
@@ -32,14 +33,15 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"guardian",
"rest_framework",
"apps.audit",
"apps.accounts",
"apps.core",
"apps.core.apps.CoreConfig",
"apps.dashboard",
"apps.servers",
"apps.keys",
"apps.access",
"apps.servers.apps.ServersConfig",
"apps.keys.apps.KeysConfig",
"apps.access.apps.AccessConfig",
"apps.telemetry",
"ninja", # Django Ninja API
"mozilla_django_oidc", # OIDC Client
@@ -54,6 +56,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"apps.audit.middleware.ApiAuditLogMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@@ -78,10 +81,12 @@ DATABASES = {
}
}
REDIS_URL = os.getenv("KEYWARDEN_REDIS_URL", "redis://127.0.0.1:6379/1")
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://keywarden-valkey:6379/1",
"LOCATION": REDIS_URL,
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
}
@@ -89,6 +94,8 @@ CACHES = {
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
@@ -205,6 +212,8 @@ KEYWARDEN_AUTH_MODE = AUTH_MODE
if AUTH_MODE == "oidc":
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
]
LOGIN_URL = "/oidc/authenticate/"
@@ -212,6 +221,7 @@ else:
# native or hybrid -> allow both, native first for precedence
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
]
LOGIN_URL = "/accounts/login/"
@@ -219,5 +229,7 @@ LOGOUT_URL = "/oidc/logout/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
ANONYMOUS_USER_NAME = None
def permission_callback(request):
return request.user.has_perm("keywarden.change_model")

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 512 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!-- https://fontawesome.com/icons/circle-xmark?f=classic&s=solid -->
<!-- SPDX-License-Identifier: CC-BY-4.0 -->
<path fill="#dd4646" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 512 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!-- https://fontawesome.com/icons/circle-check?f=classic&s=solid -->
<!-- SPDX-License-Identifier: CC-BY-4.0 -->
<path fill="#70bf2b" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
keywarden-db:
image: postgres:17-alpine
container_name: keywarden-db
environment:
POSTGRES_PASSWORD: ${KEYWARDEN_POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${KEYWARDEN_POSTGRES_DB:-keywarden}
POSTGRES_USER: ${KEYWARDEN_POSTGRES_USER:-keywarden}
POSTGRES_PORT: ${KEYWARDEN_POSTGRES_PORT:-5432}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keywarden -d keywarden"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- "pgdata:/var/lib/postgresql/data"
keywarden:
build: .
container_name: keywarden
volumes:
- ./app:/app
- ./nginx/certs:/etc/nginx/certs
- ./nginx/logs:/var/log/nginx
ports:
- "443:443"
depends_on:
- keywarden-db
environment:
- DJANGO_SETTINGS_MODULE=keywarden.settings.dev
- PYTHONPATH=/app
env_file:
- .env
volumes:
pgdata:

View File

@@ -1,58 +0,0 @@
services:
keywarden-nginx:
image: nginx:alpine
container_name: keywarden-nginx
restart: unless-stopped
volumes:
- ${DOCKERDIR}/nginx/configs/nginx.conf:/etc/nginx/nginx.conf:ro
- ${DOCKERDIR}/nginx/configs/sites:/etc/nginx/conf.d/
- ${DOCKERDIR}/nginx/certs/:/certs/
- ${DOCKERDIR}/nginx/webdir/:/var/www/
- ${DOCKERDIR}/nginx/logs:/var/log/nginx/
ports:
- "443:443"
keywarden-valkey:
image: valkey/valkey:latest
restart: unless-stopped
container_name: keywarden-valkey
environment:
- ALLOW_EMPTY_PASSWORD=yes
keywarden-db:
image: postgres:17-alpine
container_name: keywarden-db
environment:
POSTGRES_PASSWORD: ${KEYWARDEN_POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${KEYWARDEN_POSTGRES_DB:-keywarden}
POSTGRES_USER: ${KEYWARDEN_POSTGRES_USER:-keywarden}
POSTGRES_PORT: ${KEYWARDEN_POSTGRES_PORT:-5432}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keywarden -d keywarden"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- "pgdata:/var/lib/postgresql/data"
keywarden:
image: git.ntbx.io/boris/keywarden:latest
container_name: keywarden
command: sh ./entrypoint.sh
ports:
- "8000:80"
depends_on:
- keywarden-db
- keywarden-valkey
environment:
- DJANGO_SETTINGS_MODULE=keywarden.settings.dev
- PYTHONPATH=/app
- DJANGO_SUPERUSER_USERNAME=${DJANGO_SUPERUSER_USERNAME}
- DJANGO_SUPERUSER_EMAIL=${DJANGO_SUPERUSER_EMAIL}
- DJANGO_SUPERUSER_PASSWORD=${DJANGO_SUPERUSER_PASSWORD}
- KEYWARDEN_AUTH_MODE=${KEYWARDEN_AUTH_MODE:-hybrid}
env_file:
- .env
volumes:
pgdata:

View File

@@ -1,30 +0,0 @@
# Default NGINX Config
server {
listen 8008;
listen [::]:8008;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate /certs/certificate.pem;
ssl_certificate_key /certs/key.pem;
include /certs/options-ssl-nginx.conf;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -13,6 +13,12 @@ events {
http {
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
include /etc/nginx/mime.types;
include options-ssl.conf;
include options-http-headers.conf;
@@ -22,6 +28,11 @@ http {
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
map $http_x_forwarded_for $forwarded_for {
"" $remote_addr;
default $http_x_forwarded_for;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
@@ -46,7 +57,7 @@ http {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
include options-https-headers.conf;
}
@@ -55,4 +66,3 @@ http {
access_log /var/log/nginx/access.log main;
types_hash_bucket_size 128;
}

View File

@@ -0,0 +1,71 @@
# This file should be put under /etc/nginx/conf.d/
# Or place as /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
include /etc/nginx/mime.types;
include options-ssl.conf;
include options-http-headers.conf;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
map $http_x_forwarded_for $forwarded_for {
"" $remote_addr;
default $http_x_forwarded_for;
}
server {
listen 80;
listen [::]:80;
server_name __SERVER_NAME__;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name __SERVER_NAME__;
error_log /var/log/nginx/error.log warn;
access_log /var/log/nginx/access.log main;
ssl_certificate /etc/nginx/certs/certificate.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
include options-ssl.conf;
include options-https-headers.conf;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:8000;
include options-https-headers.conf;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
access_log /var/log/nginx/access.log main;
types_hash_bucket_size 128;
}

View File

@@ -7,6 +7,7 @@ Pillow>=10.0.0
mozilla-django-oidc>=4.0.0
django-unfold>=0.70.0
django-tailwind==4.4.0
django-guardian>=2.4.0
argon2-cffi>=23.1.0
psycopg2-binary>=2.9.9
gunicorn==23.0.0

View File

@@ -26,3 +26,13 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopsignal=QUIT
[program:valkey]
command=/usr/bin/valkey-server --bind 127.0.0.1 --port 6379 --save "" --appendonly no
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopsignal=TERM