commit ba1f7c4c03a3b9a7060daa1aef6fc5f9bd4c74b5 Author: Owen Rummage Date: Mon Aug 17 10:28:59 2026 -0500 Initial vibeproxy implementation Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51bfb6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vibeproxy +/data/ +*.db +*.db-wal +*.db-shm +/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b42e3d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.25-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" -o /out/vibeproxy ./cmd/vibeproxy + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/vibeproxy /usr/local/bin/vibeproxy +VOLUME ["/data"] +# The nonroot container user can't bind ports <1024, so vibeproxy listens +# on 8080/8443 inside the container; map host 80/443 to these in +# docker-compose.yml or `docker run -p`. +EXPOSE 8080 8443 8081 +ENV VIBEPROXY_DATA_DIR=/data \ + VIBEPROXY_DB_PATH=/data/vibeproxy.db \ + VIBEPROXY_HTTP_ADDR=:8080 \ + VIBEPROXY_HTTPS_ADDR=:8443 \ + VIBEPROXY_ADMIN_ADDR=:8081 +ENTRYPOINT ["/usr/local/bin/vibeproxy"] +CMD ["serve"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..06959e8 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# vibeproxy + +A small, self-contained reverse proxy manager, written in Go. Think +"Nginx Proxy Manager, but simpler and with no external dependencies" — +`vibeproxy` handles HTTP/HTTPS reverse proxying, TCP/UDP streams, TLS +termination, and Let's Encrypt certificates itself, using Go's standard +networking stack (`net/http`, `httputil.ReverseProxy`, `crypto/tls`, +`net`). It never shells out to nginx, HAProxy, or Caddy. + +The admin UI is server-rendered Go templates + Bootstrap 5 + a little +vanilla JavaScript — no React/Vue/Node build step. Everything (templates, +CSS, JS, SQL migrations) is embedded into a single binary via `go:embed`, +so deployment is just: + +``` +./vibeproxy serve +``` + +## Features + +- HTTP and HTTPS reverse proxying, routed by Host header / SNI +- Redirect hosts (301/302/307/308, with path & query preservation) +- TCP and UDP stream proxying, with optional PROXY protocol v1 to backends +- Multiple backends per proxy host: round robin, least-connections, or + weighted load balancing, with TCP/HTTP health checks and configurable + failure/recovery thresholds — unhealthy backends never receive traffic +- TLS termination, HTTP/2, WebSockets, keep-alive, streaming responses +- Let's Encrypt (HTTP-01 and DNS-01, via the [lego](https://github.com/go-acme/lego) + ACME client library) plus custom/uploaded certificates, with automatic + renewal +- Reusable Access Lists (CIDR allow/deny + optional HTTP Basic Auth) +- Per-host custom request/response headers +- Configurable trusted proxy networks — `X-Forwarded-For` is only trusted + from networks you explicitly configure +- SQLite storage (WAL mode, foreign keys, migrations) with the active + configuration cached in memory — the hot request path never touches the + database, and configuration changes apply live without a restart +- REST API under `/api/v1`, authenticated with revocable API tokens +- Single administrator account (HTTP Basic Auth, Argon2id password + hashing, CSRF-protected forms) +- Request logs (with configurable retention) and an audit log of every + configuration change +- Prometheus metrics, `/healthz`, `/readyz`, graceful shutdown, automatic + and on-demand SQLite backups + +## Quick start + +``` +go build -o vibeproxy ./cmd/vibeproxy +sudo setcap 'cap_net_bind_service=+ep' ./vibeproxy # to bind :80/:443 without root +./vibeproxy serve +``` + +On first run, `vibeproxy` creates the `admin` account and prints a random +temporary password to stdout — save it, it's not shown again. Change it +under **Settings → Administrator Account** after logging in. + +By default: + +- `:80` and `:443` — proxy traffic (HTTP/HTTPS reverse proxy + redirect + hosts + Let's Encrypt HTTP-01 challenges) +- `:8081` — admin web UI, REST API, `/healthz`, `/readyz`, `/metrics`. + **Put this behind a firewall or VPN** — Basic Auth protects it, but it + shouldn't be exposed to the internet. + +All of this is configurable via flags or environment variables: + +| Flag | Env var | Default | Purpose | +|---|---|---|---| +| `--data-dir` | `VIBEPROXY_DATA_DIR` | `./data` | database, certs, backups | +| `--db-path` | `VIBEPROXY_DB_PATH` | `./data/vibeproxy.db` | SQLite file | +| `--http-addr` | `VIBEPROXY_HTTP_ADDR` | `:80` | HTTP proxy listener | +| `--https-addr` | `VIBEPROXY_HTTPS_ADDR` | `:443` | HTTPS proxy listener | +| `--admin-addr` | `VIBEPROXY_ADMIN_ADDR` | `:8081` | admin UI + API | + +Everything else (ACME email, renewal window, log retention, trusted proxy +CIDRs, backup schedule, ...) is runtime configuration stored in SQLite and +editable from **Settings**, so it takes effect without a restart. + +## CLI + +``` +vibeproxy serve # run the proxy, admin UI, and API +vibeproxy version # print the version +vibeproxy config check # validate config + run migrations, then exit +vibeproxy admin reset-password # generate a new admin password +vibeproxy database backup # write an on-demand SQLite backup +vibeproxy database migrate # apply pending migrations +vibeproxy certificates renew # renew any due Let's Encrypt certs +``` + +## systemd + +``` +sudo useradd --system --home /var/lib/vibeproxy --shell /usr/sbin/nologin vibeproxy +sudo cp vibeproxy /usr/local/bin/vibeproxy +sudo cp systemd/vibeproxy.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now vibeproxy +sudo journalctl -u vibeproxy -f # first run prints the admin password here +``` + +The unit grants `CAP_NET_BIND_SERVICE` so `vibeproxy` can bind `:80`/`:443` +without running as root, and sandboxes the process with `ProtectSystem`, +`ProtectHome`, `PrivateDevices`, etc. It stores everything under +`/var/lib/vibeproxy` (mode `0700`). + +## Docker + +``` +docker compose up -d +``` + +See `docker-compose.yml` / `Dockerfile`. The container runs as a +non-root user, so it listens on `8080`/`8443` internally — `docker-compose.yml` +maps host `80`/`443` to those. + +## REST API + +All endpoints are under `/api/v1` on the admin listener, authenticated +with a bearer token (create one from **API** in the UI — it's shown once): + +``` +curl -H "Authorization: Bearer vp_xxxxxxxx" \ + http://localhost:8081/api/v1/proxy-hosts +``` + +Resources: `proxy-hosts`, `streams`, `certificates`, `access-lists`, +`settings`, `logs/requests`, `logs/audit`, `stats`, `tokens`. See +`internal/api` for the full route list. + +## Project layout + +``` +cmd/vibeproxy/ CLI entry point (cobra commands) +internal/ + api/ REST API (/api/v1) + app/ wires every subsystem together for `serve` + auth/ password hashing, Basic Auth, API tokens, CSRF + access/ CIDR access lists + Basic Auth evaluation + backup/ SQLite backup/restore + certificates/ ACME (via lego) + custom cert storage & renewal + config/ process bootstrap flags/env + database/ SQLite connection, migrations, repositories + health/ backend health checking + loadbalancer/ backend pools: round robin / least-conn / weighted + logging/ buffered request logging + audit log + retention + metrics/ Prometheus collectors + models/ domain types shared across packages + proxy/ the HTTP/HTTPS reverse proxy engine + runtime/ in-memory active-configuration snapshot + stats/ dashboard/API statistics + streams/ TCP/UDP proxying + web/ server-rendered admin UI (Go templates + embedded assets) + templates/, static/ +systemd/ vibeproxy.service +``` + +## Design notes + +- **Hot path stays out of SQLite.** `internal/runtime` holds an + immutable, atomically-swapped snapshot of proxy hosts, backends, access + lists, and settings. Every proxied request/connection reads that + snapshot; database writes only happen when an admin changes + configuration, at which point `Store.Reload` rebuilds the snapshot. +- **`X-Forwarded-For` is not trusted by default.** The real client IP is + taken from the TCP peer address unless the peer is inside a configured + "trusted proxy" CIDR (Settings → Trusted Proxy Networks), in which case + the right-most untrusted entry in `X-Forwarded-For` is used instead. +- **Certificates never touch logs.** Private keys are written to disk + with `0600` permissions (directories `0700`) and are never logged or + returned by the API. +- **Reloads don't drop connections.** Stream (TCP/UDP) reconciliation + diffs the desired vs. running listener set and only restarts streams + whose configuration actually changed; unaffected streams, and all + in-flight HTTP requests on the previous configuration snapshot, are + left alone. + +## Testing + +``` +go test ./... +``` + +Covers load-balancing strategies and health-check thresholds, access-list +CIDR evaluation, password/API-token hashing, SQLite migrations and +proxy-host CRUD round-trips, an end-to-end TCP stream proxy test, and a +render-smoke-test for every admin UI page. diff --git a/cmd/vibeproxy/admin.go b/cmd/vibeproxy/admin.go new file mode 100644 index 0000000..3ce5308 --- /dev/null +++ b/cmd/vibeproxy/admin.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/spf13/cobra" + + "github.com/owen/vibeproxy/internal/app" + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/config" + "github.com/owen/vibeproxy/internal/database" +) + +func newAdminCmd() *cobra.Command { + root := &cobra.Command{Use: "admin", Short: "Administrator account utilities"} + root.AddCommand(newAdminResetPasswordCmd()) + return root +} + +func newAdminResetPasswordCmd() *cobra.Command { + cfg := config.Default() + cmd := &cobra.Command{ + Use: "reset-password", + Short: "Generate a new random password for the admin account", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := app.Open(cfg, logger) + if err != nil { + return err + } + defer a.Close() + + password, err := auth.GenerateRandomPassword() + if err != nil { + return err + } + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + if err := database.UpdateUserPassword(a.DB, "admin", hash, true); err != nil { + return err + } + database.InsertAuditLog(a.DB, "cli", "password_changed", "admin", "password reset via CLI") + fmt.Println("New admin password:") + fmt.Println(password) + return nil + }, + } + fs := flag.NewFlagSet("admin-reset-password", flag.ContinueOnError) + config.RegisterFlags(fs, &cfg) + cmd.Flags().AddGoFlagSet(fs) + return cmd +} diff --git a/cmd/vibeproxy/certificates_cmd.go b/cmd/vibeproxy/certificates_cmd.go new file mode 100644 index 0000000..56a582e --- /dev/null +++ b/cmd/vibeproxy/certificates_cmd.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "flag" + "fmt" + + "github.com/spf13/cobra" + + "github.com/owen/vibeproxy/internal/app" + "github.com/owen/vibeproxy/internal/config" +) + +func newCertificatesCmd() *cobra.Command { + root := &cobra.Command{Use: "certificates", Short: "Certificate utilities"} + root.AddCommand(newCertificatesRenewCmd()) + return root +} + +func newCertificatesRenewCmd() *cobra.Command { + cfg := config.Default() + cmd := &cobra.Command{ + Use: "renew", + Short: "Renew any Let's Encrypt certificates within their renewal window", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := app.Open(cfg, logger) + if err != nil { + return err + } + defer a.Close() + + settings := a.Store.Current().Settings + n, err := a.Certs.RenewDue(context.Background(), settings.ACMERenewalWindowDays, settings.ACMEEmail, settings.ACMEStaging) + if err != nil { + return err + } + fmt.Printf("renewed %d certificate(s)\n", n) + return nil + }, + } + fs := flag.NewFlagSet("certificates-renew", flag.ContinueOnError) + config.RegisterFlags(fs, &cfg) + cmd.Flags().AddGoFlagSet(fs) + return cmd +} diff --git a/cmd/vibeproxy/config.go b/cmd/vibeproxy/config.go new file mode 100644 index 0000000..e09ee66 --- /dev/null +++ b/cmd/vibeproxy/config.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/spf13/cobra" + + "github.com/owen/vibeproxy/internal/app" + "github.com/owen/vibeproxy/internal/config" +) + +func newConfigCmd() *cobra.Command { + root := &cobra.Command{Use: "config", Short: "Configuration utilities"} + root.AddCommand(newConfigCheckCmd()) + return root +} + +func newConfigCheckCmd() *cobra.Command { + cfg := config.Default() + cmd := &cobra.Command{ + Use: "check", + Short: "Validate configuration and database migrations without starting listeners", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := app.Open(cfg, logger) + if err != nil { + return fmt.Errorf("configuration check failed: %w", err) + } + defer a.Close() + fmt.Println("OK: database opens, migrations applied, and initial configuration loaded successfully.") + return nil + }, + } + fs := flag.NewFlagSet("config-check", flag.ContinueOnError) + config.RegisterFlags(fs, &cfg) + cmd.Flags().AddGoFlagSet(fs) + return cmd +} diff --git a/cmd/vibeproxy/database_cmd.go b/cmd/vibeproxy/database_cmd.go new file mode 100644 index 0000000..279e46e --- /dev/null +++ b/cmd/vibeproxy/database_cmd.go @@ -0,0 +1,69 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/spf13/cobra" + + "github.com/owen/vibeproxy/internal/app" + "github.com/owen/vibeproxy/internal/backup" + "github.com/owen/vibeproxy/internal/config" +) + +func newDatabaseCmd() *cobra.Command { + root := &cobra.Command{Use: "database", Short: "Database utilities"} + root.AddCommand(newDatabaseBackupCmd(), newDatabaseMigrateCmd()) + return root +} + +func newDatabaseBackupCmd() *cobra.Command { + cfg := config.Default() + var outDir string + cmd := &cobra.Command{ + Use: "backup", + Short: "Create an on-demand SQLite backup", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := app.Open(cfg, logger) + if err != nil { + return err + } + defer a.Close() + if outDir == "" { + outDir = cfg.DataDir + "/backups" + } + path, err := backup.Run(a.DB, outDir, 0) + if err != nil { + return err + } + fmt.Println("backup written to " + path) + return nil + }, + } + fs := flag.NewFlagSet("database-backup", flag.ContinueOnError) + config.RegisterFlags(fs, &cfg) + cmd.Flags().AddGoFlagSet(fs) + cmd.Flags().StringVar(&outDir, "out", "", "backup output directory (default: /backups)") + return cmd +} + +func newDatabaseMigrateCmd() *cobra.Command { + cfg := config.Default() + cmd := &cobra.Command{ + Use: "migrate", + Short: "Apply any pending database migrations", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := app.Open(cfg, logger) // Open() runs migrations + if err != nil { + return err + } + defer a.Close() + fmt.Println("database is up to date") + return nil + }, + } + fs := flag.NewFlagSet("database-migrate", flag.ContinueOnError) + config.RegisterFlags(fs, &cfg) + cmd.Flags().AddGoFlagSet(fs) + return cmd +} diff --git a/cmd/vibeproxy/main.go b/cmd/vibeproxy/main.go new file mode 100644 index 0000000..ed9138c --- /dev/null +++ b/cmd/vibeproxy/main.go @@ -0,0 +1,32 @@ +// Command vibeproxy is a self-contained HTTP/HTTPS/TCP/UDP reverse proxy +// manager with a Bootstrap 5 web UI, SQLite storage, and a REST API. +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/spf13/cobra" +) + +var logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + +func main() { + root := &cobra.Command{ + Use: "vibeproxy", + Short: "vibeproxy is a self-contained reverse proxy manager", + } + root.AddCommand( + newServeCmd(), + newVersionCmd(), + newConfigCmd(), + newAdminCmd(), + newDatabaseCmd(), + newCertificatesCmd(), + ) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/vibeproxy/serve.go b/cmd/vibeproxy/serve.go new file mode 100644 index 0000000..3199d78 --- /dev/null +++ b/cmd/vibeproxy/serve.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "flag" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/owen/vibeproxy/internal/app" + "github.com/owen/vibeproxy/internal/config" +) + +func newServeCmd() *cobra.Command { + cfg := config.Default() + cmd := &cobra.Command{ + Use: "serve", + Short: "Run vibeproxy: HTTP/HTTPS/TCP/UDP proxying, web UI, and REST API", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := app.Open(cfg, logger) + if err != nil { + return err + } + defer a.Close() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + logger.Info("vibeproxy starting", + "http", cfg.HTTPAddr, "https", cfg.HTTPSAddr, "admin", cfg.AdminAddr) + return a.Run(ctx) + }, + } + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + config.RegisterFlags(fs, &cfg) + cmd.Flags().AddGoFlagSet(fs) + return cmd +} diff --git a/cmd/vibeproxy/version.go b/cmd/vibeproxy/version.go new file mode 100644 index 0000000..c11b996 --- /dev/null +++ b/cmd/vibeproxy/version.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Version is set via -ldflags "-X main.Version=..." at release build time. +var Version = "dev" + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the vibeproxy version", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("vibeproxy " + Version) + return nil + }, + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1605372 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + vibeproxy: + build: + context: . + args: + VERSION: dev + # Or, once published: image: ghcr.io/owen/vibeproxy:latest + container_name: vibeproxy + restart: unless-stopped + ports: + - "80:8080" # HTTP proxy traffic + - "443:8443" # HTTPS proxy traffic + - "8081:8081" # Admin web UI + REST API (keep behind a firewall / VPN) + volumes: + - vibeproxy-data:/data + environment: + VIBEPROXY_DATA_DIR: /data + VIBEPROXY_DB_PATH: /data/vibeproxy.db + VIBEPROXY_HTTP_ADDR: ":8080" + VIBEPROXY_HTTPS_ADDR: ":8443" + VIBEPROXY_ADMIN_ADDR: ":8081" + healthcheck: + test: ["CMD", "/usr/local/bin/vibeproxy", "version"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + vibeproxy-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..707893c --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/owen/vibeproxy + +go 1.25.0 + +require ( + github.com/go-acme/lego/v4 v4.35.2 + github.com/prometheus/client_golang v1.24.1 + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.55.0 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8defc71 --- /dev/null +++ b/go.sum @@ -0,0 +1,108 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/access/access.go b/internal/access/access.go new file mode 100644 index 0000000..6d1a9d6 --- /dev/null +++ b/internal/access/access.go @@ -0,0 +1,57 @@ +// Package access evaluates reusable access lists: CIDR allow/deny rules +// plus an optional HTTP Basic Auth user set. +package access + +import ( + "net" + "sort" + + "github.com/owen/vibeproxy/internal/models" +) + +type rule struct { + allow bool + net *net.IPNet + prio int +} + +// List is the runtime-evaluatable form of a models.AccessList. +type List struct { + ID int64 + Name string + DefaultAllow bool + rules []rule + Users map[string]string // username -> argon2id hash +} + +func Compile(m models.AccessList) *List { + l := &List{ID: m.ID, Name: m.Name, DefaultAllow: m.DefaultAllow, Users: map[string]string{}} + for _, r := range m.Rules { + _, ipnet, err := net.ParseCIDR(r.CIDR) + if err != nil { + continue + } + l.rules = append(l.rules, rule{allow: r.Action == models.AccessAllow, net: ipnet, prio: r.Priority}) + } + sort.SliceStable(l.rules, func(i, j int) bool { return l.rules[i].prio < l.rules[j].prio }) + for _, u := range m.BasicAuthUsers { + l.Users[u.Username] = u.PasswordHash + } + return l +} + +// Allowed reports whether ip is permitted by this list's CIDR rules. The +// first matching rule (in priority order) wins; otherwise the default +// action applies. +func (l *List) Allowed(ip net.IP) bool { + for _, r := range l.rules { + if r.net.Contains(ip) { + return r.allow + } + } + return l.DefaultAllow +} + +// RequiresAuth reports whether this list has HTTP Basic Auth users +// configured, meaning IP-allowed requests must still authenticate. +func (l *List) RequiresAuth() bool { return len(l.Users) > 0 } diff --git a/internal/access/access_test.go b/internal/access/access_test.go new file mode 100644 index 0000000..5153206 --- /dev/null +++ b/internal/access/access_test.go @@ -0,0 +1,49 @@ +package access + +import ( + "net" + "testing" + + "github.com/owen/vibeproxy/internal/models" +) + +func TestAccessListAllowDeny(t *testing.T) { + m := models.AccessList{ + DefaultAllow: false, + Rules: []models.AccessRule{ + {Action: models.AccessAllow, CIDR: "10.0.0.0/8", Priority: 0}, + {Action: models.AccessDeny, CIDR: "10.1.0.0/16", Priority: 1}, + }, + } + l := Compile(m) + + cases := []struct { + ip string + want bool + }{ + {"10.2.3.4", true}, // matches allow 10.0.0.0/8 first + {"192.168.1.1", false}, // no match, default deny + } + for _, c := range cases { + got := l.Allowed(net.ParseIP(c.ip)) + if got != c.want { + t.Errorf("Allowed(%s) = %v, want %v", c.ip, got, c.want) + } + } +} + +func TestAccessListDefaultAllow(t *testing.T) { + m := models.AccessList{DefaultAllow: true} + l := Compile(m) + if !l.Allowed(net.ParseIP("8.8.8.8")) { + t.Fatal("expected default allow to permit unmatched IP") + } +} + +func TestAccessListRequiresAuth(t *testing.T) { + m := models.AccessList{BasicAuthUsers: []models.AccessListUser{{Username: "bob", PasswordHash: "x"}}} + l := Compile(m) + if !l.RequiresAuth() { + t.Fatal("expected RequiresAuth to be true when users are configured") + } +} diff --git a/internal/api/accesslists.go b/internal/api/accesslists.go new file mode 100644 index 0000000..4a04397 --- /dev/null +++ b/internal/api/accesslists.go @@ -0,0 +1,106 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +func (a *API) listAccessLists(w http.ResponseWriter, r *http.Request) { + lists, err := database.ListAccessLists(a.db) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, lists) +} + +func (a *API) getAccessList(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + l, err := database.GetAccessList(a.db, id) + if err != nil { + writeError(w, http.StatusNotFound, "access list not found") + return + } + writeJSON(w, http.StatusOK, l) +} + +func (a *API) createAccessList(w http.ResponseWriter, r *http.Request) { + var l models.AccessList + if !decodeJSON(w, r, &l) { + return + } + if err := validateAccessList(&l); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + l.ID = 0 + id, err := database.SaveAccessList(a.db, &l) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "access_list_changed", l.Name, fmt.Sprintf("id=%d created", id)) + writeJSON(w, http.StatusCreated, l) +} + +func (a *API) updateAccessList(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var l models.AccessList + if !decodeJSON(w, r, &l) { + return + } + l.ID = id + if err := validateAccessList(&l); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := database.SaveAccessList(a.db, &l); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "access_list_changed", l.Name, fmt.Sprintf("id=%d updated", id)) + writeJSON(w, http.StatusOK, l) +} + +func (a *API) deleteAccessList(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.DeleteAccessList(a.db, id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "access_list_changed", fmt.Sprintf("id=%d", id), "deleted") + w.WriteHeader(http.StatusNoContent) +} + +func validateAccessList(l *models.AccessList) error { + if l.Name == "" { + return fmt.Errorf("name is required") + } + for i, rule := range l.Rules { + if rule.Action != models.AccessAllow && rule.Action != models.AccessDeny { + return fmt.Errorf("rule %d: action must be allow or deny", i) + } + if rule.CIDR == "" { + return fmt.Errorf("rule %d: CIDR is required", i) + } + } + return nil +} diff --git a/internal/api/certificates.go b/internal/api/certificates.go new file mode 100644 index 0000000..43591d2 --- /dev/null +++ b/internal/api/certificates.go @@ -0,0 +1,153 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +func (a *API) listCertificates(w http.ResponseWriter, r *http.Request) { + certs, err := database.ListCertificates(a.db) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, certs) +} + +func (a *API) getCertificate(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + c, err := database.GetCertificate(a.db, id) + if err != nil { + writeError(w, http.StatusNotFound, "certificate not found") + return + } + writeJSON(w, http.StatusOK, c) +} + +type issueCertRequest struct { + Name string `json:"name"` + Domains []string `json:"domains"` + Email string `json:"email"` + Staging bool `json:"staging"` + ChallengeType models.ChallengeType `json:"challenge_type"` + DNSProvider string `json:"dns_provider"` +} + +func (a *API) issueCertificate(w http.ResponseWriter, r *http.Request) { + var req issueCertRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Name == "" || len(req.Domains) == 0 { + writeError(w, http.StatusBadRequest, "name and domains are required") + return + } + if req.ChallengeType == "" { + req.ChallengeType = models.ChallengeHTTP01 + } + settings := a.store.Current().Settings + email := req.Email + if email == "" { + email = settings.ACMEEmail + } + cert, err := a.certs.IssueLetsEncrypt(r.Context(), certOpts(req, email)) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + audit(a.db, r, "certificate_issued", cert.Name, fmt.Sprintf("domains=%v challenge=%s", cert.Domains, cert.ChallengeType)) + writeJSON(w, http.StatusCreated, cert) +} + +func certOpts(req issueCertRequest, email string) certificates.IssueLetsEncryptOptions { + return certificates.IssueLetsEncryptOptions{ + Name: req.Name, + Domains: req.Domains, + Email: email, + Staging: req.Staging, + ChallengeType: req.ChallengeType, + DNSProvider: req.DNSProvider, + } +} + +func (a *API) uploadCertificate(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Domains []string `json:"domains"` + CertPEM string `json:"cert_pem"` + KeyPEM string `json:"key_pem"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.Name == "" || req.CertPEM == "" || req.KeyPEM == "" { + writeError(w, http.StatusBadRequest, "name, cert_pem, and key_pem are required") + return + } + cert, err := a.certs.UploadCustom(req.Name, req.Domains, []byte(req.CertPEM), []byte(req.KeyPEM)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + audit(a.db, r, "certificate_issued", cert.Name, "custom certificate uploaded") + writeJSON(w, http.StatusCreated, cert) +} + +func (a *API) renewCertificate(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + c, err := database.GetCertificate(a.db, id) + if err != nil { + writeError(w, http.StatusNotFound, "certificate not found") + return + } + if c.Type != models.CertTypeLetsEncrypt { + writeError(w, http.StatusBadRequest, "only Let's Encrypt certificates can be renewed") + return + } + settings := a.store.Current().Settings + updated, err := a.certs.IssueLetsEncrypt(r.Context(), certOptsFromModel(*c, settings.ACMEEmail, settings.ACMEStaging)) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + audit(a.db, r, "certificate_renewed", updated.Name, "") + writeJSON(w, http.StatusOK, updated) +} + +func certOptsFromModel(c models.Certificate, email string, staging bool) certificates.IssueLetsEncryptOptions { + return certificates.IssueLetsEncryptOptions{ + Name: c.Name, + Domains: c.Domains, + Email: email, + Staging: staging, + ChallengeType: c.ChallengeType, + DNSProvider: c.DNSProvider, + } +} + +func (a *API) deleteCertificate(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.DeleteCertificate(a.db, id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.certs.LoadAll() + audit(a.db, r, "certificate_changed", fmt.Sprintf("id=%d", id), "deleted") + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/json.go b/internal/api/json.go new file mode 100644 index 0000000..e883a81 --- /dev/null +++ b/internal/api/json.go @@ -0,0 +1,63 @@ +// Package api implements the REST API under /api/v1, authenticated with +// bearer API tokens (separate from the web UI's Basic Auth). +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/logging" +) + +const maxJSONBody = 10 << 20 // 10 MiB is plenty for config payloads + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return false + } + return true +} + +func pathID(r *http.Request) (int64, error) { + return strconv.ParseInt(r.PathValue("id"), 10, 64) +} + +func audit(db *sql.DB, r *http.Request, action, target, details string) { + logging.Audit(db, auth.ActorFromContext(r.Context()), action, target, details) +} + +func queryInt(r *http.Request, key string, def int) int { + v := r.URL.Query().Get(key) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func nilIfEmptyID(id int64) *int64 { + if id == 0 { + return nil + } + return &id +} diff --git a/internal/api/logs.go b/internal/api/logs.go new file mode 100644 index 0000000..939a076 --- /dev/null +++ b/internal/api/logs.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" + + "github.com/owen/vibeproxy/internal/database" +) + +func (a *API) listRequestLogs(w http.ResponseWriter, r *http.Request) { + f := database.LogFilter{ + Status: queryInt(r, "status", 0), + Search: r.URL.Query().Get("q"), + Limit: queryInt(r, "limit", 50), + Offset: queryInt(r, "offset", 0), + } + if id := queryInt(r, "proxy_host_id", 0); id > 0 { + id64 := int64(id) + f.ProxyHostID = &id64 + } + logs, total, err := database.QueryRequestLogs(a.db, f) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"logs": logs, "total": total}) +} + +func (a *API) listAuditLogs(w http.ResponseWriter, r *http.Request) { + limit := queryInt(r, "limit", 50) + offset := queryInt(r, "offset", 0) + logs, total, err := database.ListAuditLogs(a.db, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"logs": logs, "total": total}) +} diff --git a/internal/api/proxyhosts.go b/internal/api/proxyhosts.go new file mode 100644 index 0000000..141c5ac --- /dev/null +++ b/internal/api/proxyhosts.go @@ -0,0 +1,201 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +func (a *API) listProxyHosts(w http.ResponseWriter, r *http.Request) { + hosts, err := database.ListProxyHosts(a.db) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, hosts) +} + +func (a *API) getProxyHost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + h, err := database.GetProxyHost(a.db, id) + if err != nil { + writeError(w, http.StatusNotFound, "proxy host not found") + return + } + writeJSON(w, http.StatusOK, h) +} + +func (a *API) createProxyHost(w http.ResponseWriter, r *http.Request) { + var h models.ProxyHost + if !decodeJSON(w, r, &h) { + return + } + if err := validateProxyHost(&h); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + h.ID = 0 + id, err := database.SaveProxyHost(a.db, &h) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "proxy_created", h.Name, fmt.Sprintf("id=%d domains=%v", id, h.Domains)) + writeJSON(w, http.StatusCreated, h) +} + +func (a *API) updateProxyHost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var h models.ProxyHost + if !decodeJSON(w, r, &h) { + return + } + h.ID = id + if err := validateProxyHost(&h); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := database.SaveProxyHost(a.db, &h); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "proxy_changed", h.Name, fmt.Sprintf("id=%d", id)) + writeJSON(w, http.StatusOK, h) +} + +func (a *API) deleteProxyHost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.DeleteProxyHost(a.db, id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "proxy_deleted", fmt.Sprintf("id=%d", id), "") + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) enableProxyHost(w http.ResponseWriter, r *http.Request) { + a.setProxyHostEnabled(w, r, true) +} + +func (a *API) disableProxyHost(w http.ResponseWriter, r *http.Request) { + a.setProxyHostEnabled(w, r, false) +} + +func (a *API) setProxyHostEnabled(w http.ResponseWriter, r *http.Request, enabled bool) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.SetProxyHostEnabled(a.db, id, enabled); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reload(r) + audit(a.db, r, "proxy_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", enabled)) + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) reload(r *http.Request) { + if err := a.store.Reload(r.Context()); err != nil { + a.log.Error("config reload failed", "err", err) + } +} + +func validateProxyHost(h *models.ProxyHost) error { + if h.Name == "" { + return fmt.Errorf("name is required") + } + if h.Type == "" { + h.Type = models.ProxyHostTypeProxy + } + if len(h.Domains) == 0 { + return fmt.Errorf("at least one domain is required") + } + if h.Type == models.ProxyHostTypeProxy { + if len(h.Backends) == 0 { + return fmt.Errorf("at least one backend is required for a proxy host") + } + for i, b := range h.Backends { + if b.Host == "" { + return fmt.Errorf("backend %d: host is required", i) + } + if b.Port <= 0 || b.Port > 65535 { + return fmt.Errorf("backend %d: invalid port", i) + } + if b.Weight <= 0 { + h.Backends[i].Weight = 1 + } + if b.HealthCheckType == "" { + h.Backends[i].HealthCheckType = models.HealthCheckTCP + } + if b.IntervalSeconds <= 0 { + h.Backends[i].IntervalSeconds = 10 + } + if b.FailureThreshold <= 0 { + h.Backends[i].FailureThreshold = 3 + } + if b.RecoveryThreshold <= 0 { + h.Backends[i].RecoveryThreshold = 2 + } + } + if h.ForwardScheme == "" { + h.ForwardScheme = "http" + } + if h.LoadBalancingMethod == "" { + h.LoadBalancingMethod = "round_robin" + } + } else if h.Type == models.ProxyHostTypeRedirect { + if h.RedirectTargetURL == "" { + return fmt.Errorf("redirect target URL is required") + } + switch h.RedirectCode { + case 301, 302, 307, 308: + default: + h.RedirectCode = 301 + } + } else { + return fmt.Errorf("invalid proxy host type %q", h.Type) + } + if h.ConnectTimeoutMS <= 0 { + h.ConnectTimeoutMS = 5000 + } + if h.ReadTimeoutMS <= 0 { + h.ReadTimeoutMS = 60000 + } + if h.WriteTimeoutMS <= 0 { + h.WriteTimeoutMS = 60000 + } + if h.IdleTimeoutMS <= 0 { + h.IdleTimeoutMS = 120000 + } + for _, hd := range h.CustomHeaders { + if hd.Name == "" { + return fmt.Errorf("custom header name is required") + } + if hd.Direction != models.HeaderDirectionRequest && hd.Direction != models.HeaderDirectionResponse { + return fmt.Errorf("custom header direction must be request or response") + } + if hd.Action != models.HeaderActionSet && hd.Action != models.HeaderActionRemove { + return fmt.Errorf("custom header action must be set or remove") + } + } + return nil +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..9bbd646 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,70 @@ +package api + +import ( + "database/sql" + "log/slog" + "net/http" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/runtime" + "github.com/owen/vibeproxy/internal/streams" +) + +type API struct { + db *sql.DB + store *runtime.Store + certs *certificates.Manager + streams *streams.Manager + log *slog.Logger +} + +// NewRouter builds the full /api/v1 handler tree, protected by API token +// bearer auth. +func NewRouter(db *sql.DB, store *runtime.Store, certs *certificates.Manager, streamMgr *streams.Manager, log *slog.Logger) http.Handler { + a := &API{db: db, store: store, certs: certs, streams: streamMgr, log: log} + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/v1/proxy-hosts", a.listProxyHosts) + mux.HandleFunc("POST /api/v1/proxy-hosts", a.createProxyHost) + mux.HandleFunc("GET /api/v1/proxy-hosts/{id}", a.getProxyHost) + mux.HandleFunc("PUT /api/v1/proxy-hosts/{id}", a.updateProxyHost) + mux.HandleFunc("DELETE /api/v1/proxy-hosts/{id}", a.deleteProxyHost) + mux.HandleFunc("POST /api/v1/proxy-hosts/{id}/enable", a.enableProxyHost) + mux.HandleFunc("POST /api/v1/proxy-hosts/{id}/disable", a.disableProxyHost) + + mux.HandleFunc("GET /api/v1/streams", a.listStreams) + mux.HandleFunc("POST /api/v1/streams", a.createStream) + mux.HandleFunc("GET /api/v1/streams/{id}", a.getStream) + mux.HandleFunc("PUT /api/v1/streams/{id}", a.updateStream) + mux.HandleFunc("DELETE /api/v1/streams/{id}", a.deleteStream) + mux.HandleFunc("POST /api/v1/streams/{id}/enable", a.enableStream) + mux.HandleFunc("POST /api/v1/streams/{id}/disable", a.disableStream) + + mux.HandleFunc("GET /api/v1/certificates", a.listCertificates) + mux.HandleFunc("POST /api/v1/certificates/letsencrypt", a.issueCertificate) + mux.HandleFunc("POST /api/v1/certificates/custom", a.uploadCertificate) + mux.HandleFunc("GET /api/v1/certificates/{id}", a.getCertificate) + mux.HandleFunc("DELETE /api/v1/certificates/{id}", a.deleteCertificate) + mux.HandleFunc("POST /api/v1/certificates/{id}/renew", a.renewCertificate) + + mux.HandleFunc("GET /api/v1/access-lists", a.listAccessLists) + mux.HandleFunc("POST /api/v1/access-lists", a.createAccessList) + mux.HandleFunc("GET /api/v1/access-lists/{id}", a.getAccessList) + mux.HandleFunc("PUT /api/v1/access-lists/{id}", a.updateAccessList) + mux.HandleFunc("DELETE /api/v1/access-lists/{id}", a.deleteAccessList) + + mux.HandleFunc("GET /api/v1/settings", a.getSettings) + mux.HandleFunc("PUT /api/v1/settings", a.updateSettings) + + mux.HandleFunc("GET /api/v1/tokens", a.listTokens) + mux.HandleFunc("POST /api/v1/tokens", a.createToken) + mux.HandleFunc("DELETE /api/v1/tokens/{id}", a.revokeToken) + + mux.HandleFunc("GET /api/v1/logs/requests", a.listRequestLogs) + mux.HandleFunc("GET /api/v1/logs/audit", a.listAuditLogs) + + mux.HandleFunc("GET /api/v1/stats", a.getStats) + + return auth.APIToken(db)(mux) +} diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..125862b --- /dev/null +++ b/internal/api/settings.go @@ -0,0 +1,66 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "github.com/owen/vibeproxy/internal/database" +) + +type settingsDTO struct { + ACMEEmail string `json:"acme_email"` + ACMEStaging bool `json:"acme_staging"` + ACMERenewalWindowDays int `json:"acme_renewal_window_days"` + CertStoragePath string `json:"cert_storage_path"` + LogRetentionDays int `json:"log_retention_days"` + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"` + BackupEnabled bool `json:"backup_enabled"` + BackupIntervalHours int `json:"backup_interval_hours"` + BackupRetentionCount int `json:"backup_retention_count"` + BackupPath string `json:"backup_path"` +} + +func (a *API) getSettings(w http.ResponseWriter, r *http.Request) { + s := a.store.Current().Settings + writeJSON(w, http.StatusOK, settingsDTO{ + ACMEEmail: s.ACMEEmail, + ACMEStaging: s.ACMEStaging, + ACMERenewalWindowDays: s.ACMERenewalWindowDays, + CertStoragePath: s.CertStoragePath, + LogRetentionDays: s.LogRetentionDays, + TrustedProxyCIDRs: s.TrustedProxyCIDRs, + BackupEnabled: s.BackupEnabled, + BackupIntervalHours: s.BackupIntervalHours, + BackupRetentionCount: s.BackupRetentionCount, + BackupPath: s.BackupPath, + }) +} + +func (a *API) updateSettings(w http.ResponseWriter, r *http.Request) { + var dto settingsDTO + if !decodeJSON(w, r, &dto) { + return + } + kv := map[string]string{ + "acme_email": dto.ACMEEmail, + "acme_staging": strconv.FormatBool(dto.ACMEStaging), + "acme_renewal_window_days": strconv.Itoa(dto.ACMERenewalWindowDays), + "cert_storage_path": dto.CertStoragePath, + "log_retention_days": strconv.Itoa(dto.LogRetentionDays), + "trusted_proxy_cidrs": strings.Join(dto.TrustedProxyCIDRs, ","), + "backup_enabled": strconv.FormatBool(dto.BackupEnabled), + "backup_interval_hours": strconv.Itoa(dto.BackupIntervalHours), + "backup_retention_count": strconv.Itoa(dto.BackupRetentionCount), + "backup_path": dto.BackupPath, + } + for k, v := range kv { + if err := database.SetSetting(a.db, k, v); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + a.reload(r) + audit(a.db, r, "settings_changed", "settings", "") + writeJSON(w, http.StatusOK, dto) +} diff --git a/internal/api/stats.go b/internal/api/stats.go new file mode 100644 index 0000000..93cb034 --- /dev/null +++ b/internal/api/stats.go @@ -0,0 +1,16 @@ +package api + +import ( + "net/http" + + "github.com/owen/vibeproxy/internal/stats" +) + +func (a *API) getStats(w http.ResponseWriter, r *http.Request) { + d, err := stats.Compute(a.db, a.store.Current()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, d) +} diff --git a/internal/api/streams.go b/internal/api/streams.go new file mode 100644 index 0000000..b37331c --- /dev/null +++ b/internal/api/streams.go @@ -0,0 +1,137 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +func (a *API) listStreams(w http.ResponseWriter, r *http.Request) { + s, err := database.ListStreams(a.db) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, s) +} + +func (a *API) getStream(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + s, err := database.GetStream(a.db, id) + if err != nil { + writeError(w, http.StatusNotFound, "stream not found") + return + } + writeJSON(w, http.StatusOK, s) +} + +func (a *API) createStream(w http.ResponseWriter, r *http.Request) { + var s models.Stream + if !decodeJSON(w, r, &s) { + return + } + if err := validateStream(&s); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.ID = 0 + id, err := database.SaveStream(a.db, &s) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reloadStreams(r) + audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("%s :%d -> %s:%d", s.Protocol, s.ListenPort, s.DestinationHost, s.DestinationPort)) + writeJSON(w, http.StatusCreated, s) +} + +func (a *API) updateStream(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var s models.Stream + if !decodeJSON(w, r, &s) { + return + } + s.ID = id + if err := validateStream(&s); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := database.SaveStream(a.db, &s); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reloadStreams(r) + audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), "") + writeJSON(w, http.StatusOK, s) +} + +func (a *API) deleteStream(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.DeleteStream(a.db, id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reloadStreams(r) + audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), "deleted") + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) enableStream(w http.ResponseWriter, r *http.Request) { a.setStreamEnabled(w, r, true) } +func (a *API) disableStream(w http.ResponseWriter, r *http.Request) { a.setStreamEnabled(w, r, false) } + +func (a *API) setStreamEnabled(w http.ResponseWriter, r *http.Request, enabled bool) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.SetStreamEnabled(a.db, id, enabled); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + a.reloadStreams(r) + audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", enabled)) + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) reloadStreams(r *http.Request) { + if err := a.streams.Reload(r.Context()); err != nil { + a.log.Error("stream reload failed", "err", err) + } +} + +func validateStream(s *models.Stream) error { + if s.Protocol != models.StreamTCP && s.Protocol != models.StreamUDP { + return fmt.Errorf("protocol must be tcp or udp") + } + if s.ListenPort <= 0 || s.ListenPort > 65535 { + return fmt.Errorf("invalid listen port") + } + if s.DestinationHost == "" { + return fmt.Errorf("destination host is required") + } + if s.DestinationPort <= 0 || s.DestinationPort > 65535 { + return fmt.Errorf("invalid destination port") + } + if s.ConnectTimeoutMS <= 0 { + s.ConnectTimeoutMS = 5000 + } + if s.IdleTimeoutMS <= 0 { + s.IdleTimeoutMS = 300000 + } + return nil +} diff --git a/internal/api/tokens.go b/internal/api/tokens.go new file mode 100644 index 0000000..246a723 --- /dev/null +++ b/internal/api/tokens.go @@ -0,0 +1,77 @@ +package api + +import ( + "net/http" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/database" +) + +func (a *API) listTokens(w http.ResponseWriter, r *http.Request) { + tokens, err := database.ListAPITokens(a.db) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Never expose the stored hash. + type dto struct { + ID int64 `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` + Revoked bool `json:"revoked"` + } + out := make([]dto, 0, len(tokens)) + for _, t := range tokens { + d := dto{ID: t.ID, Name: t.Name, Prefix: t.Prefix, CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"), Revoked: t.RevokedAt != nil} + if t.LastUsedAt != nil { + d.LastUsedAt = t.LastUsedAt.Format("2006-01-02T15:04:05Z") + } + out = append(out, d) + } + writeJSON(w, http.StatusOK, out) +} + +func (a *API) createToken(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + plaintext, hash, prefix, err := auth.GenerateAPIToken() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + id, err := database.CreateAPIToken(a.db, req.Name, hash, prefix) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + audit(a.db, r, "token_created", req.Name, "") + writeJSON(w, http.StatusCreated, map[string]any{ + "id": id, + "name": req.Name, + "token": plaintext, // shown once; never retrievable again + }) +} + +func (a *API) revokeToken(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := database.RevokeAPIToken(a.db, id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + audit(a.db, r, "token_revoked", "", "") + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/app/admin_server.go b/internal/app/admin_server.go new file mode 100644 index 0000000..cfd6754 --- /dev/null +++ b/internal/app/admin_server.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "github.com/owen/vibeproxy/internal/metrics" +) + +type adminServer struct { + srv *http.Server +} + +func newAdminServer(addr string, handler http.Handler) *adminServer { + return &adminServer{srv: &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + }} +} + +func (a *adminServer) Start(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + if err := a.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("admin listener: %w", err) + } + }() + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return a.srv.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} + +func healthzHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) +} + +func readyzHandler(db *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := db.PingContext(r.Context()); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("database unavailable")) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ready")) + }) +} + +func metricsHandler() http.Handler { + return metrics.Handler() +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..47e13fc --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,166 @@ +// Package app wires together every subsystem (database, runtime store, +// certificate manager, HTTP/TCP/UDP proxying, web UI, REST API) into a +// single running vibeproxy process, and exposes the same wiring pieces +// individually for CLI subcommands that don't need the full server. +package app + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/owen/vibeproxy/internal/api" + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/backup" + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/config" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/logging" + "github.com/owen/vibeproxy/internal/proxy" + "github.com/owen/vibeproxy/internal/runtime" + "github.com/owen/vibeproxy/internal/streams" + "github.com/owen/vibeproxy/internal/web" +) + +type App struct { + Cfg config.Config + DB *sql.DB + Log *slog.Logger + Store *runtime.Store + Certs *certificates.Manager + Streams *streams.Manager +} + +// Open initializes the database, runtime store, and certificate manager. +// It does not start any listeners — used by both `serve` and one-shot CLI +// commands like `database migrate`. +func Open(cfg config.Config, log *slog.Logger) (*App, error) { + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + db, err := database.Open(cfg.DBPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + if err := ensureAdminUser(db, log); err != nil { + db.Close() + return nil, err + } + + certStoragePath := database.GetSetting(db, "cert_storage_path", filepath.Join(cfg.DataDir, "certs")) + certMgr, err := certificates.NewManager(db, certStoragePath, log) + if err != nil { + db.Close() + return nil, fmt.Errorf("init certificate manager: %w", err) + } + if err := certMgr.LoadAll(); err != nil { + log.Warn("failed loading certificates", "err", err) + } + + store := runtime.NewStore(db) + if err := store.Reload(context.Background()); err != nil { + db.Close() + return nil, fmt.Errorf("initial config load: %w", err) + } + + streamMgr := streams.NewManager(db, log) + + return &App{Cfg: cfg, DB: db, Log: log, Store: store, Certs: certMgr, Streams: streamMgr}, nil +} + +func (a *App) Close() error { + return a.DB.Close() +} + +// ensureAdminUser creates the single `admin` account with a generated +// temporary password on first run, per the "no administrator exists yet" +// requirement. +func ensureAdminUser(db *sql.DB, log *slog.Logger) error { + n, err := database.CountUsers(db) + if err != nil { + return err + } + if n > 0 { + return nil + } + password, err := auth.GenerateRandomPassword() + if err != nil { + return err + } + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + if err := database.CreateUser(db, "admin", hash, true); err != nil { + return err + } + database.InsertAuditLog(db, "system", "admin_created", "admin", "initial administrator account created") + fmt.Println("========================================================") + fmt.Println(" vibeproxy: created initial administrator account") + fmt.Println(" username: admin") + fmt.Printf(" password: %s\n", password) + fmt.Println(" Change this password after logging in (Settings > Admin Account).") + fmt.Println("========================================================") + log.Info("created initial admin account", "username", "admin") + return nil +} + +// Run starts every listener (HTTP/HTTPS proxy, TCP/UDP streams, admin web +// UI + API) and background loops (health checks are started by the +// runtime store on reload; certificate renewal, log retention, backups), +// blocking until ctx is cancelled, then shutting everything down +// gracefully. +func (a *App) Run(ctx context.Context) error { + if err := a.Streams.Reload(ctx); err != nil { + return fmt.Errorf("initial stream load: %w", err) + } + + settings := a.Store.Current().Settings + a.Certs.StartRenewalLoop(ctx, settings.ACMERenewalWindowDays, settings.ACMEEmail, settings.ACMEStaging) + logging.StartRetentionLoop(ctx, a.DB, a.Log, func() int { + return a.Store.Current().Settings.LogRetentionDays + }) + backup.StartScheduledBackups(ctx, a.DB, a.Log, + func() string { return a.Store.Current().Settings.BackupPath }, + func() int { return a.Store.Current().Settings.BackupIntervalHours }, + func() int { return a.Store.Current().Settings.BackupRetentionCount }, + func() bool { return a.Store.Current().Settings.BackupEnabled }, + ) + + proxySrv := proxy.NewServer(proxy.Options{HTTPAddr: a.Cfg.HTTPAddr, HTTPSAddr: a.Cfg.HTTPSAddr}, a.Store, a.Certs, a.DB, a.Log) + + csrf := auth.NewCSRF() + adminMux := web.NewRouter(a.DB, a.Store, a.Certs, a.Streams, csrf, a.Log) + apiMux := api.NewRouter(a.DB, a.Store, a.Certs, a.Streams, a.Log) + adminMux.Handle("/api/v1/", apiMux) + adminMux.Handle("/healthz", healthzHandler()) + adminMux.Handle("/readyz", readyzHandler(a.DB)) + adminMux.Handle("/metrics", metricsHandler()) + + adminSrv := newAdminServer(a.Cfg.AdminAddr, adminMux) + + errCh := make(chan error, 4) + go func() { + if err := proxySrv.Start(ctx); err != nil { + errCh <- err + } + }() + go func() { + if err := adminSrv.Start(ctx); err != nil { + errCh <- err + } + }() + + select { + case <-ctx.Done(): + case err := <-errCh: + a.Log.Error("fatal listener error", "err", err) + } + + a.Streams.StopAll() + return nil +} diff --git a/internal/auth/apitoken.go b/internal/auth/apitoken.go new file mode 100644 index 0000000..edf3ccb --- /dev/null +++ b/internal/auth/apitoken.go @@ -0,0 +1,34 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +// GenerateAPIToken returns a full plaintext token (shown once to the user), +// its sha256 hash (stored in the DB), and a short prefix (stored alongside +// the hash so the UI can display "vp_ab12..." without ever re-showing the +// secret). +func GenerateAPIToken() (plaintext, hash, prefix string, err error) { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return + } + secret := base64.RawURLEncoding.EncodeToString(b) + plaintext = "vp_" + secret + hash = HashAPIToken(plaintext) + prefix = plaintext[:8] + return +} + +func HashAPIToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func FormatTokenPreview(prefix string) string { + return fmt.Sprintf("%s%s", prefix, "…") +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..6362144 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,151 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "net/http" + "time" + + "github.com/owen/vibeproxy/internal/database" +) + +type ctxKey string + +const actorCtxKey ctxKey = "actor" + +func ActorFromContext(ctx context.Context) string { + if v, ok := ctx.Value(actorCtxKey).(string); ok { + return v + } + return "unknown" +} + +// BasicAuthAdmin protects the web UI with HTTP Basic Auth against the +// single stored admin account. +func BasicAuthAdmin(db *sql.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok { + unauthorized(w) + return + } + user, err := database.GetUserByUsername(db, username) + if err != nil { + unauthorized(w) + return + } + valid, err := VerifyPassword(password, user.PasswordHash) + if err != nil || !valid { + unauthorized(w) + return + } + ctx := context.WithValue(r.Context(), actorCtxKey, username) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func unauthorized(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="vibeproxy"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) +} + +// APIToken protects the REST API with a bearer token checked against +// hashed tokens stored in the database. +func APIToken(db *sql.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok := bearerToken(r) + if tok == "" { + http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized) + return + } + hash := HashAPIToken(tok) + rec, err := database.GetAPITokenByHash(db, hash) + if err != nil || rec.RevokedAt != nil { + http.Error(w, `{"error":"invalid or revoked token"}`, http.StatusUnauthorized) + return + } + go database.TouchAPIToken(db, rec.ID) + ctx := context.WithValue(r.Context(), actorCtxKey, "token:"+rec.Name) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + const p = "Bearer " + if len(h) > len(p) && h[:len(p)] == p { + return h[len(p):] + } + return "" +} + +// CSRF implements a double-submit-cookie style CSRF protection for the +// server-rendered web UI (which authenticates via Basic Auth and so has no +// session to bind a per-session token to). +type CSRF struct { + secret []byte +} + +func NewCSRF() *CSRF { + s := make([]byte, 32) + rand.Read(s) + return &CSRF{secret: s} +} + +const csrfCookieName = "vp_csrf" + +func (c *CSRF) sign(nonce string) string { + mac := hmac.New(sha256.New, c.secret) + mac.Write([]byte(nonce)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// Token issues (and sets a cookie for) a fresh CSRF token for rendering +// into a form's hidden field. +func (c *CSRF) Token(w http.ResponseWriter) string { + nonceB := make([]byte, 16) + rand.Read(nonceB) + nonce := base64.RawURLEncoding.EncodeToString(nonceB) + sig := c.sign(nonce) + value := nonce + "." + sig + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: value, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Expires: time.Now().Add(2 * time.Hour), + }) + return value +} + +// Protect rejects unsafe requests whose form value doesn't match the +// double-submitted cookie. +func (c *CSRF) Protect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + cookie, err := r.Cookie(csrfCookieName) + if err != nil { + http.Error(w, "CSRF token missing", http.StatusForbidden) + return + } + formTok := r.FormValue("csrf_token") + if formTok == "" || subtle.ConstantTimeCompare([]byte(formTok), []byte(cookie.Value)) != 1 { + http.Error(w, "CSRF token invalid", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..2146366 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,78 @@ +// Package auth handles admin credential hashing, HTTP Basic Auth, API +// tokens, and CSRF protection for the web UI. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters. Tuned for a low-traffic single-admin login, not a +// high-throughput multi-tenant service. +const ( + argonTime = 1 + argonMemory = 64 * 1024 // KiB + argonThreads = 4 + argonKeyLen = 32 + saltLen = 16 +) + +var ErrInvalidHash = errors.New("invalid password hash format") + +// HashPassword returns an encoded argon2id hash in the standard +// $argon2id$v=19$m=...,t=...,p=...$salt$hash format. +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil +} + +// VerifyPassword checks password against an encoded hash produced by +// HashPassword, using constant-time comparison. +func VerifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, ErrInvalidHash + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return false, ErrInvalidHash + } + var memory uint32 + var time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, ErrInvalidHash + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, ErrInvalidHash + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, ErrInvalidHash + } + got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} + +// GenerateRandomPassword returns a strong, human-typeable temporary +// password (24 base64url characters, no padding). +func GenerateRandomPassword() (string, error) { + b := make([]byte, 18) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..30999c9 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,54 @@ +package auth + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + ok, err := VerifyPassword("correct horse battery staple", hash) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected password to verify") + } + ok, err = VerifyPassword("wrong password", hash) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected wrong password to fail verification") + } +} + +func TestGenerateRandomPassword(t *testing.T) { + p1, err := GenerateRandomPassword() + if err != nil { + t.Fatal(err) + } + p2, err := GenerateRandomPassword() + if err != nil { + t.Fatal(err) + } + if p1 == p2 { + t.Fatal("expected distinct random passwords") + } + if len(p1) < 20 { + t.Fatalf("password too short: %q", p1) + } +} + +func TestAPITokenRoundTrip(t *testing.T) { + plaintext, hash, prefix, err := GenerateAPIToken() + if err != nil { + t.Fatal(err) + } + if HashAPIToken(plaintext) != hash { + t.Fatal("hash mismatch") + } + if len(prefix) != 8 { + t.Fatalf("expected 8-char prefix, got %q", prefix) + } +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..976dd92 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,112 @@ +// Package backup handles SQLite backups: on-demand, scheduled, and +// restore. It uses SQLite's own online backup (via VACUUM INTO), which is +// safe to run against a live WAL-mode database without blocking writers +// for more than the duration of the copy. +package backup + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "time" +) + +// Run performs a single backup of the database at db into dir, named with +// a timestamp, then prunes old backups beyond keep. +func Run(db *sql.DB, dir string, keep int) (string, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + name := fmt.Sprintf("vibeproxy-%s.db", time.Now().UTC().Format("20060102-150405")) + path := filepath.Join(dir, name) + + if _, err := db.Exec(`VACUUM INTO ?`, path); err != nil { + return "", fmt.Errorf("backup failed: %w", err) + } + if err := os.Chmod(path, 0o600); err != nil { + return "", err + } + if keep > 0 { + if err := prune(dir, keep); err != nil { + return path, err + } + } + return path, nil +} + +func prune(dir string, keep int) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + var names []string + for _, e := range entries { + if !e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) // timestamped names sort chronologically + if len(names) <= keep { + return nil + } + for _, n := range names[:len(names)-keep] { + os.Remove(filepath.Join(dir, n)) + } + return nil +} + +// StartScheduledBackups runs Run on the given interval until ctx is done. +func StartScheduledBackups(ctx context.Context, db *sql.DB, log *slog.Logger, getDir func() string, getIntervalHours func() int, getKeep func() int, getEnabled func() bool) { + ticker := time.NewTicker(1 * time.Hour) + lastRun := time.Time{} + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !getEnabled() { + continue + } + interval := time.Duration(getIntervalHours()) * time.Hour + if interval <= 0 { + interval = 24 * time.Hour + } + if time.Since(lastRun) < interval { + continue + } + lastRun = time.Now() + path, err := Run(db, getDir(), getKeep()) + if err != nil { + log.Error("scheduled backup failed", "err", err) + } else { + log.Info("scheduled backup complete", "path", path) + } + } + } + }() +} + +// Restore replaces the live database file at dbPath with backupPath. The +// caller must have closed all connections to dbPath before calling this +// and reopen afterward. +func Restore(backupPath, dbPath string) error { + data, err := os.ReadFile(backupPath) + if err != nil { + return err + } + tmp := dbPath + ".restoring" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + // Remove any stale WAL/SHM files so SQLite doesn't try to replay a + // journal that belongs to the old database. + os.Remove(dbPath + "-wal") + os.Remove(dbPath + "-shm") + return os.Rename(tmp, dbPath) +} diff --git a/internal/certificates/acmeuser.go b/internal/certificates/acmeuser.go new file mode 100644 index 0000000..9cb7471 --- /dev/null +++ b/internal/certificates/acmeuser.go @@ -0,0 +1,56 @@ +package certificates + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "os" + + "github.com/go-acme/lego/v4/registration" +) + +// acmeUser implements lego's registration.User for our single ACME +// account. The private key never leaves this process and is stored on +// disk with 0600 permissions, separate from any issued certificate keys. +type acmeUser struct { + Email string + Registration *registration.Resource + key crypto.PrivateKey +} + +func newACMEUser(email string, key crypto.PrivateKey) *acmeUser { + return &acmeUser{Email: email, key: key} +} + +func (u *acmeUser) GetEmail() string { return u.Email } +func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration } +func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key } + +func newACMEAccountKey() (*ecdsa.PrivateKey, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +} + +func writeECDSAKeyPEM(path string, key *ecdsa.PrivateKey) error { + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: der} + return os.WriteFile(path, pem.EncodeToMemory(block), 0o600) +} + +func readECDSAKeyPEM(path string) (*ecdsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("invalid PEM account key") + } + return x509.ParseECPrivateKey(block.Bytes) +} diff --git a/internal/certificates/httpchallenge.go b/internal/certificates/httpchallenge.go new file mode 100644 index 0000000..fd72d64 --- /dev/null +++ b/internal/certificates/httpchallenge.go @@ -0,0 +1,56 @@ +package certificates + +import ( + "net/http" + "strings" + "sync" +) + +// httpChallengeProvider implements lego's challenge.Provider for HTTP-01, +// backed by an in-memory token map served by our own HTTP listener at +// /.well-known/acme-challenge/. This avoids spinning up a second server on +// port 80 — the main proxy HTTP listener delegates to ServeHTTP below. +type httpChallengeProvider struct { + mu sync.RWMutex + tokens map[string]string // token -> key authorization +} + +func newHTTPChallengeProvider() *httpChallengeProvider { + return &httpChallengeProvider{tokens: map[string]string{}} +} + +func (p *httpChallengeProvider) Present(domain, token, keyAuth string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.tokens[token] = keyAuth + return nil +} + +func (p *httpChallengeProvider) CleanUp(domain, token, keyAuth string) error { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.tokens, token) + return nil +} + +const acmeChallengePrefix = "/.well-known/acme-challenge/" + +// ServeHTTP handles ACME HTTP-01 challenge requests. Returns true if the +// request was an ACME challenge (handled or not found), false if the +// caller should continue normal routing. +func (p *httpChallengeProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.URL.Path, acmeChallengePrefix) { + return false + } + token := strings.TrimPrefix(r.URL.Path, acmeChallengePrefix) + p.mu.RLock() + keyAuth, ok := p.tokens[token] + p.mu.RUnlock() + if !ok { + http.NotFound(w, r) + return true + } + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(keyAuth)) + return true +} diff --git a/internal/certificates/manager.go b/internal/certificates/manager.go new file mode 100644 index 0000000..d91888c --- /dev/null +++ b/internal/certificates/manager.go @@ -0,0 +1,335 @@ +// Package certificates manages TLS certificates: Let's Encrypt issuance +// and renewal via lego (a proper ACME client library — we never speak the +// ACME protocol ourselves), plus custom uploaded certificates. It serves +// SNI lookups for the HTTPS listener from an in-memory cache and persists +// metadata to SQLite via the database package. Private key material only +// ever touches disk (0600) and is never logged. +package certificates + +import ( + "context" + "crypto/ecdsa" + "crypto/tls" + "crypto/x509" + "database/sql" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/dns/cloudflare" + "github.com/go-acme/lego/v4/registration" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +const ( + letsEncryptProdURL = "https://acme-v02.api.letsencrypt.org/directory" + letsEncryptStagingURL = "https://acme-staging-v02.api.letsencrypt.org/directory" +) + +type Manager struct { + db *sql.DB + storage *storage + log *slog.Logger + + httpProvider *httpChallengeProvider + + mu sync.RWMutex + byDom map[string]*tls.Certificate // lowercase domain -> cert + + onChange func() +} + +func NewManager(db *sql.DB, storagePath string, log *slog.Logger) (*Manager, error) { + st, err := newStorage(storagePath) + if err != nil { + return nil, err + } + return &Manager{ + db: db, + storage: st, + log: log, + httpProvider: newHTTPChallengeProvider(), + byDom: map[string]*tls.Certificate{}, + }, nil +} + +// ServeChallenge handles ACME HTTP-01 requests on the plain HTTP listener. +// Returns true if the request was handled (was an ACME challenge path). +func (m *Manager) ServeChallenge(w http.ResponseWriter, r *http.Request) bool { + return m.httpProvider.ServeHTTP(w, r) +} + +func (m *Manager) SetOnChange(f func()) { m.onChange = f } + +// GetCertificateForDomain implements SNI lookup for tls.Config.GetCertificate. +func (m *Manager) GetCertificateForDomain(domain string) (*tls.Certificate, error) { + domain = strings.ToLower(domain) + m.mu.RLock() + defer m.mu.RUnlock() + if c, ok := m.byDom[domain]; ok { + return c, nil + } + if idx := strings.IndexByte(domain, '.'); idx >= 0 { + wildcard := "*" + domain[idx:] + if c, ok := m.byDom[wildcard]; ok { + return c, nil + } + } + return nil, fmt.Errorf("no certificate for domain %q", domain) +} + +// LoadAll reads every stored certificate row, loads its key material from +// disk, and rebuilds the SNI cache. Call at startup and after any +// issuance/upload/deletion. +func (m *Manager) LoadAll() error { + certs, err := database.ListCertificates(m.db) + if err != nil { + return err + } + newMap := map[string]*tls.Certificate{} + for _, c := range certs { + if c.CertPath == "" || c.KeyPath == "" { + continue + } + tlsCert, err := loadTLSCertificate(c.CertPath, c.KeyPath) + if err != nil { + m.log.Warn("failed to load certificate", "name", c.Name, "err", err) + continue + } + for _, d := range c.Domains { + newMap[strings.ToLower(d)] = tlsCert + } + } + m.mu.Lock() + m.byDom = newMap + m.mu.Unlock() + if m.onChange != nil { + m.onChange() + } + return nil +} + +// UploadCustom validates and stores a custom certificate/key pair. +func (m *Manager) UploadCustom(name string, domains []string, certPEM, keyPEM []byte) (*models.Certificate, error) { + leaf, err := ValidateKeyPair(certPEM, keyPEM) + if err != nil { + return nil, err + } + certPath, keyPath, err := m.storage.SavePair(name, certPEM, keyPEM) + if err != nil { + return nil, err + } + notBefore, notAfter := leaf.NotBefore, leaf.NotAfter + rec := &models.Certificate{ + Name: name, + Type: models.CertTypeCustom, + Domains: domains, + CertPath: certPath, + KeyPath: keyPath, + NotBefore: ¬Before, + NotAfter: ¬After, + AutoRenew: false, + } + if _, err := database.SaveCertificate(m.db, rec); err != nil { + return nil, err + } + if err := m.LoadAll(); err != nil { + return nil, err + } + return rec, nil +} + +// IssueLetsEncryptOptions configures a new ACME issuance. +type IssueLetsEncryptOptions struct { + Name string + Domains []string + Email string + Staging bool + ChallengeType models.ChallengeType + DNSProvider string // e.g. "cloudflare"; required for dns-01 +} + +// IssueLetsEncrypt requests a new certificate from Let's Encrypt and +// persists it. HTTP-01 challenges are served via ServeChallenge on our own +// HTTP listener (port 80 must be reachable from the internet); DNS-01 uses +// the configured DNS provider and also supports wildcard domains. +func (m *Manager) IssueLetsEncrypt(ctx context.Context, opts IssueLetsEncryptOptions) (*models.Certificate, error) { + if opts.Email == "" { + return nil, errors.New("an ACME contact email is required (configure it under Settings)") + } + accountKey, err := m.loadOrCreateAccountKey() + if err != nil { + return nil, err + } + user := newACMEUser(opts.Email, accountKey) + + cfg := lego.NewConfig(user) + if opts.Staging { + cfg.CADirURL = letsEncryptStagingURL + } else { + cfg.CADirURL = letsEncryptProdURL + } + cfg.Certificate.KeyType = certcrypto.RSA2048 + + client, err := lego.NewClient(cfg) + if err != nil { + return nil, err + } + + if opts.ChallengeType == models.ChallengeDNS01 { + provider, err := m.dnsProvider(opts.DNSProvider) + if err != nil { + return nil, err + } + if err := client.Challenge.SetDNS01Provider(provider); err != nil { + return nil, err + } + } else { + if err := client.Challenge.SetHTTP01Provider(m.httpProvider); err != nil { + return nil, err + } + } + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + if err != nil { + return nil, fmt.Errorf("acme registration: %w", err) + } + user.Registration = reg + + res, err := client.Certificate.Obtain(certificate.ObtainRequest{ + Domains: opts.Domains, + Bundle: true, + }) + if err != nil { + return nil, fmt.Errorf("acme obtain: %w", err) + } + + certPath, keyPath, err := m.storage.SavePair(opts.Name, res.Certificate, res.PrivateKey) + if err != nil { + return nil, err + } + + var notBefore, notAfter *time.Time + if block, _ := pem.Decode(res.Certificate); block != nil { + if leaf, err := x509.ParseCertificate(block.Bytes); err == nil { + nb, na := leaf.NotBefore, leaf.NotAfter + notBefore, notAfter = &nb, &na + } + } + now := time.Now() + + rec := &models.Certificate{ + Name: opts.Name, + Type: models.CertTypeLetsEncrypt, + Domains: opts.Domains, + ChallengeType: opts.ChallengeType, + DNSProvider: opts.DNSProvider, + CertPath: certPath, + KeyPath: keyPath, + NotBefore: notBefore, + NotAfter: notAfter, + LastRenewedAt: &now, + AutoRenew: true, + } + if _, err := database.SaveCertificate(m.db, rec); err != nil { + return nil, err + } + if err := m.LoadAll(); err != nil { + return nil, err + } + return rec, nil +} + +func (m *Manager) dnsProvider(name string) (challenge.Provider, error) { + switch name { + case "cloudflare": + return cloudflare.NewDNSProvider() + default: + return nil, fmt.Errorf("unsupported DNS provider %q (supported: cloudflare; set its env vars, or use HTTP-01 instead)", name) + } +} + +// RenewDue re-issues any Let's Encrypt certificate whose expiry falls +// inside the configured renewal window. Returns the number renewed. +func (m *Manager) RenewDue(ctx context.Context, renewalWindowDays int, email string, staging bool) (int, error) { + certs, err := database.ListCertificates(m.db) + if err != nil { + return 0, err + } + renewed := 0 + cutoff := time.Now().Add(time.Duration(renewalWindowDays) * 24 * time.Hour) + for _, c := range certs { + if c.Type != models.CertTypeLetsEncrypt || !c.AutoRenew { + continue + } + if c.NotAfter == nil || c.NotAfter.After(cutoff) { + continue + } + if _, err := m.IssueLetsEncrypt(ctx, IssueLetsEncryptOptions{ + Name: c.Name, + Domains: c.Domains, + Email: email, + Staging: staging, + ChallengeType: c.ChallengeType, + DNSProvider: c.DNSProvider, + }); err != nil { + c.LastError = err.Error() + database.SaveCertificate(m.db, &c) + m.log.Error("certificate renewal failed", "name", c.Name, "err", err) + continue + } + renewed++ + } + return renewed, nil +} + +// StartRenewalLoop runs RenewDue once a day until ctx is cancelled. +func (m *Manager) StartRenewalLoop(ctx context.Context, renewalWindowDays int, email string, staging bool) { + ticker := time.NewTicker(24 * time.Hour) + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if n, err := m.RenewDue(ctx, renewalWindowDays, email, staging); err != nil { + m.log.Error("renewal loop error", "err", err) + } else if n > 0 { + m.log.Info("renewed certificates", "count", n) + } + } + } + }() +} + +func (m *Manager) loadOrCreateAccountKey() (*ecdsa.PrivateKey, error) { + path := m.storage.accountKeyPath() + if _, err := os.Stat(path); err == nil { + return readECDSAKeyPEM(path) + } + key, err := newACMEAccountKey() + if err != nil { + return nil, err + } + if err := writeECDSAKeyPEM(path, key); err != nil { + return nil, err + } + return key, nil +} + +func DeleteCertificate(db *sql.DB, id int64) error { + return database.DeleteCertificate(db, id) +} diff --git a/internal/certificates/storage.go b/internal/certificates/storage.go new file mode 100644 index 0000000..bf66b01 --- /dev/null +++ b/internal/certificates/storage.go @@ -0,0 +1,104 @@ +package certificates + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" +) + +// storage manages certificate/key material on disk. Private keys are +// written with 0600 permissions and their directories with 0700; nothing +// under here is ever written to a log. +type storage struct { + root string +} + +func newStorage(root string) (*storage, error) { + if err := os.MkdirAll(filepath.Join(root, "accounts"), 0o700); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Join(root, "certs"), 0o700); err != nil { + return nil, err + } + return &storage{root: root}, nil +} + +func (s *storage) certDir(name string) string { + return filepath.Join(s.root, "certs", safeName(name)) +} + +func safeName(name string) string { + out := make([]rune, 0, len(name)) + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + out = append(out, r) + default: + out = append(out, '_') + } + } + if len(out) == 0 { + return "cert" + } + return string(out) +} + +// SavePair writes fullchain.pem and privkey.pem for the given logical +// certificate name and returns their paths. +func (s *storage) SavePair(name string, certPEM, keyPEM []byte) (certPath, keyPath string, err error) { + dir := s.certDir(name) + if err = os.MkdirAll(dir, 0o700); err != nil { + return + } + certPath = filepath.Join(dir, "fullchain.pem") + keyPath = filepath.Join(dir, "privkey.pem") + if err = os.WriteFile(certPath, certPEM, 0o644); err != nil { + return + } + if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return + } + return +} + +func (s *storage) accountKeyPath() string { + return filepath.Join(s.root, "accounts", "account.key") +} + +func loadTLSCertificate(certPath, keyPath string) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + if len(cert.Certificate) == 0 { + return nil, errors.New("empty certificate chain") + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, err + } + cert.Leaf = leaf + return &cert, nil +} + +// ValidateKeyPair checks that certPEM/keyPEM parse and form a matching +// pair before anything is written to disk. +func ValidateKeyPair(certPEM, keyPEM []byte) (*x509.Certificate, error) { + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("certificate/key do not match: %w", err) + } + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, errors.New("no PEM data found in certificate") + } + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + return nil, err + } + return leaf, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fa9a8ef --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,47 @@ +// Package config holds process bootstrap configuration: everything needed +// before the database is even open. Runtime/admin-tunable settings (ACME +// email, retention, trusted proxies, etc.) live in the database instead — +// see internal/database's settings table — so they can change without a +// restart. +package config + +import ( + "flag" + "os" +) + +type Config struct { + DataDir string + DBPath string + HTTPAddr string + HTTPSAddr string + AdminAddr string +} + +func Default() Config { + return Config{ + DataDir: "./data", + DBPath: "./data/vibeproxy.db", + HTTPAddr: ":80", + HTTPSAddr: ":443", + AdminAddr: ":8081", + } +} + +// RegisterFlags binds cfg's fields to flags on fs, seeded from environment +// variables (VIBEPROXY_*) so the same binary works under systemd or Docker +// without a wrapper script. +func RegisterFlags(fs *flag.FlagSet, cfg *Config) { + fs.StringVar(&cfg.DataDir, "data-dir", envOr("VIBEPROXY_DATA_DIR", cfg.DataDir), "directory for the database, certificates, and backups") + fs.StringVar(&cfg.DBPath, "db-path", envOr("VIBEPROXY_DB_PATH", cfg.DBPath), "path to the SQLite database file") + fs.StringVar(&cfg.HTTPAddr, "http-addr", envOr("VIBEPROXY_HTTP_ADDR", cfg.HTTPAddr), "address for the plain HTTP proxy listener") + fs.StringVar(&cfg.HTTPSAddr, "https-addr", envOr("VIBEPROXY_HTTPS_ADDR", cfg.HTTPSAddr), "address for the HTTPS proxy listener") + fs.StringVar(&cfg.AdminAddr, "admin-addr", envOr("VIBEPROXY_ADMIN_ADDR", cfg.AdminAddr), "address for the admin web UI, REST API, and /metrics, /healthz, /readyz") +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..42f1e02 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,100 @@ +// Package database owns the SQLite connection, pragmas, and migrations. +package database + +import ( + "database/sql" + "embed" + "fmt" + "io/fs" + "sort" + "strings" + + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Open opens (creating if needed) the SQLite database at path, applies +// pragmas suited for a long-running server (WAL, foreign keys, busy +// timeout), and runs any pending migrations. +func Open(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // SQLite only tolerates a single writer; keep the pool modest so + // writers serialize instead of colliding on SQLITE_BUSY. + db.SetMaxOpenConns(8) + + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA foreign_keys=ON", + "PRAGMA busy_timeout=5000", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + db.Close() + return nil, fmt.Errorf("pragma %q: %w", p, err) + } + } + + if err := migrate(db); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + return db, nil +} + +func migrate(db *sql.DB) error { + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + )`); err != nil { + return err + } + + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return err + } + var names []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + + for _, name := range names { + var applied int + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, name).Scan(&applied); err != nil { + return err + } + if applied > 0 { + continue + } + content, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return err + } + tx, err := db.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(string(content)); err != nil { + tx.Rollback() + return fmt.Errorf("applying %s: %w", name, err) + } + if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil { + tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..8017073 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,87 @@ +package database + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/owen/vibeproxy/internal/models" +) + +func openTest(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestMigrateAndProxyHostRoundTrip(t *testing.T) { + db := openTest(t) + + h := models.ProxyHost{ + Name: "test", Type: models.ProxyHostTypeProxy, Domains: []string{"a.example.com", "b.example.com"}, + ForwardScheme: "http", LoadBalancingMethod: "round_robin", Enabled: true, + ConnectTimeoutMS: 5000, ReadTimeoutMS: 60000, WriteTimeoutMS: 60000, IdleTimeoutMS: 120000, + Backends: []models.Backend{ + {Host: "10.0.0.1", Port: 8080, Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckTCP, IntervalSeconds: 10, FailureThreshold: 3, RecoveryThreshold: 2}, + }, + CustomHeaders: []models.CustomHeader{ + {Direction: models.HeaderDirectionResponse, Action: models.HeaderActionSet, Name: "X-Frame-Options", Value: "DENY"}, + }, + } + id, err := SaveProxyHost(db, &h) + if err != nil { + t.Fatal(err) + } + if id == 0 { + t.Fatal("expected non-zero id") + } + + fetched, err := GetProxyHost(db, id) + if err != nil { + t.Fatal(err) + } + if fetched.Name != "test" || len(fetched.Domains) != 2 || len(fetched.Backends) != 1 || len(fetched.CustomHeaders) != 1 { + t.Fatalf("round trip mismatch: %+v", fetched) + } + + // Update should replace domains/backends/headers cleanly. + fetched.Domains = []string{"only.example.com"} + fetched.Backends[0].Port = 9090 + if _, err := SaveProxyHost(db, fetched); err != nil { + t.Fatal(err) + } + updated, err := GetProxyHost(db, id) + if err != nil { + t.Fatal(err) + } + if len(updated.Domains) != 1 || updated.Domains[0] != "only.example.com" { + t.Fatalf("expected domains replaced, got %v", updated.Domains) + } + if updated.Backends[0].Port != 9090 { + t.Fatalf("expected backend port updated, got %d", updated.Backends[0].Port) + } + + if err := DeleteProxyHost(db, id); err != nil { + t.Fatal(err) + } + if _, err := GetProxyHost(db, id); err != ErrNotFound { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestSettingsRoundTrip(t *testing.T) { + db := openTest(t) + if err := SetSetting(db, "acme_email", "ops@example.com"); err != nil { + t.Fatal(err) + } + if got := GetSetting(db, "acme_email", ""); got != "ops@example.com" { + t.Fatalf("expected ops@example.com, got %q", got) + } + if got := GetSetting(db, "missing_key", "fallback"); got != "fallback" { + t.Fatalf("expected fallback default, got %q", got) + } +} diff --git a/internal/database/migrations/0001_init.sql b/internal/database/migrations/0001_init.sql new file mode 100644 index 0000000..9ad8067 --- /dev/null +++ b/internal/database/migrations/0001_init.sql @@ -0,0 +1,177 @@ +-- vibeproxy initial schema + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + must_change_pw INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_used_at TEXT, + revoked_at TEXT +); + +CREATE TABLE IF NOT EXISTS certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, -- letsencrypt|custom + domains TEXT NOT NULL, -- json array + challenge_type TEXT, -- http-01|dns-01 + dns_provider TEXT, + cert_path TEXT, + key_path TEXT, + not_before TEXT, + not_after TEXT, + last_renewed_at TEXT, + last_error TEXT, + auto_renew INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS access_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + default_allow INTEGER NOT NULL DEFAULT 0, + comments TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS access_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + access_list_id INTEGER NOT NULL REFERENCES access_lists(id) ON DELETE CASCADE, + action TEXT NOT NULL, -- allow|deny + cidr TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_access_rules_list ON access_rules(access_list_id); + +CREATE TABLE IF NOT EXISTS access_list_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + access_list_id INTEGER NOT NULL REFERENCES access_lists(id) ON DELETE CASCADE, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + UNIQUE(access_list_id, username) +); + +CREATE TABLE IF NOT EXISTS proxy_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'proxy', -- proxy|redirect + listen_addr TEXT NOT NULL DEFAULT '', + forward_scheme TEXT NOT NULL DEFAULT 'http', + preserve_host_header INTEGER NOT NULL DEFAULT 0, + websocket_support INTEGER NOT NULL DEFAULT 1, + force_https INTEGER NOT NULL DEFAULT 0, + hsts_enabled INTEGER NOT NULL DEFAULT 0, + certificate_id INTEGER REFERENCES certificates(id) ON DELETE SET NULL, + access_list_id INTEGER REFERENCES access_lists(id) ON DELETE SET NULL, + connect_timeout_ms INTEGER NOT NULL DEFAULT 5000, + read_timeout_ms INTEGER NOT NULL DEFAULT 60000, + write_timeout_ms INTEGER NOT NULL DEFAULT 60000, + idle_timeout_ms INTEGER NOT NULL DEFAULT 120000, + load_balancing_method TEXT NOT NULL DEFAULT 'round_robin', + redirect_target_url TEXT NOT NULL DEFAULT '', + redirect_code INTEGER NOT NULL DEFAULT 301, + redirect_preserve_path INTEGER NOT NULL DEFAULT 1, + redirect_preserve_qs INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + comments TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS proxy_host_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy_host_id INTEGER NOT NULL REFERENCES proxy_hosts(id) ON DELETE CASCADE, + domain TEXT NOT NULL COLLATE NOCASE, + UNIQUE(domain) +); +CREATE INDEX IF NOT EXISTS idx_phd_host ON proxy_host_domains(proxy_host_id); +CREATE INDEX IF NOT EXISTS idx_phd_domain ON proxy_host_domains(domain); + +CREATE TABLE IF NOT EXISTS backends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy_host_id INTEGER NOT NULL REFERENCES proxy_hosts(id) ON DELETE CASCADE, + host TEXT NOT NULL, + port INTEGER NOT NULL, + weight INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + health_check_type TEXT NOT NULL DEFAULT 'tcp', -- tcp|http|none + health_check_path TEXT NOT NULL DEFAULT '/', + health_check_status INTEGER NOT NULL DEFAULT 200, + interval_seconds INTEGER NOT NULL DEFAULT 10, + failure_threshold INTEGER NOT NULL DEFAULT 3, + recovery_threshold INTEGER NOT NULL DEFAULT 2 +); +CREATE INDEX IF NOT EXISTS idx_backends_host ON backends(proxy_host_id); + +CREATE TABLE IF NOT EXISTS custom_headers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy_host_id INTEGER NOT NULL REFERENCES proxy_hosts(id) ON DELETE CASCADE, + direction TEXT NOT NULL, -- request|response + action TEXT NOT NULL, -- set|remove + name TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_headers_host ON custom_headers(proxy_host_id); + +CREATE TABLE IF NOT EXISTS streams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + protocol TEXT NOT NULL, -- tcp|udp + listen_ip TEXT NOT NULL DEFAULT '', + listen_port INTEGER NOT NULL, + destination_host TEXT NOT NULL, + destination_port INTEGER NOT NULL, + connect_timeout_ms INTEGER NOT NULL DEFAULT 5000, + idle_timeout_ms INTEGER NOT NULL DEFAULT 300000, + proxy_protocol INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + comments TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + UNIQUE(protocol, listen_ip, listen_port) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + proxy_host_id INTEGER, + proxy_host_name TEXT NOT NULL DEFAULT '', + client_ip TEXT NOT NULL DEFAULT '', + method TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + response_size INTEGER NOT NULL DEFAULT 0, + backend TEXT NOT NULL DEFAULT '', + response_time_ms INTEGER NOT NULL DEFAULT 0, + user_agent TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_logs_ts ON request_logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_logs_host ON request_logs(proxy_host_id); +CREATE INDEX IF NOT EXISTS idx_logs_status ON request_logs(status); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + actor TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + details TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_logs(timestamp); diff --git a/internal/database/repo_access.go b/internal/database/repo_access.go new file mode 100644 index 0000000..81a10a7 --- /dev/null +++ b/internal/database/repo_access.go @@ -0,0 +1,149 @@ +package database + +import ( + "database/sql" + "errors" + + "github.com/owen/vibeproxy/internal/models" +) + +func ListAccessLists(db *sql.DB) ([]models.AccessList, error) { + rows, err := db.Query(`SELECT id, name, default_allow, comments, created_at, updated_at FROM access_lists ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.AccessList + for rows.Next() { + a, err := scanAccessList(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + if err := hydrateAccessList(db, &out[i]); err != nil { + return nil, err + } + } + return out, nil +} + +func scanAccessList(rs rowScanner) (models.AccessList, error) { + var a models.AccessList + var defaultAllow int + var created, updated string + err := rs.Scan(&a.ID, &a.Name, &defaultAllow, &a.Comments, &created, &updated) + if err != nil { + return a, err + } + a.DefaultAllow = defaultAllow == 1 + a.CreatedAt = parseTime(created) + a.UpdatedAt = parseTime(updated) + return a, nil +} + +func hydrateAccessList(db *sql.DB, a *models.AccessList) error { + rrows, err := db.Query(`SELECT id, access_list_id, action, cidr, priority FROM access_rules WHERE access_list_id = ? ORDER BY priority, id`, a.ID) + if err != nil { + return err + } + for rrows.Next() { + var r models.AccessRule + if err := rrows.Scan(&r.ID, &r.AccessListID, &r.Action, &r.CIDR, &r.Priority); err != nil { + rrows.Close() + return err + } + a.Rules = append(a.Rules, r) + } + rrows.Close() + if err := rrows.Err(); err != nil { + return err + } + + urows, err := db.Query(`SELECT id, access_list_id, username, password_hash FROM access_list_users WHERE access_list_id = ? ORDER BY id`, a.ID) + if err != nil { + return err + } + for urows.Next() { + var u models.AccessListUser + if err := urows.Scan(&u.ID, &u.AccessListID, &u.Username, &u.PasswordHash); err != nil { + urows.Close() + return err + } + a.BasicAuthUsers = append(a.BasicAuthUsers, u) + } + urows.Close() + return urows.Err() +} + +func GetAccessList(db *sql.DB, id int64) (*models.AccessList, error) { + row := db.QueryRow(`SELECT id, name, default_allow, comments, created_at, updated_at FROM access_lists WHERE id = ?`, id) + a, err := scanAccessList(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if err := hydrateAccessList(db, &a); err != nil { + return nil, err + } + return &a, nil +} + +func SaveAccessList(db *sql.DB, a *models.AccessList) (int64, error) { + tx, err := db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + if a.ID == 0 { + res, err := tx.Exec(`INSERT INTO access_lists (name, default_allow, comments) VALUES (?, ?, ?)`, a.Name, boolToInt(a.DefaultAllow), a.Comments) + if err != nil { + return 0, err + } + a.ID, err = res.LastInsertId() + if err != nil { + return 0, err + } + } else { + if _, err := tx.Exec(`UPDATE access_lists SET name=?, default_allow=?, comments=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + a.Name, boolToInt(a.DefaultAllow), a.Comments, a.ID); err != nil { + return 0, err + } + if _, err := tx.Exec(`DELETE FROM access_rules WHERE access_list_id = ?`, a.ID); err != nil { + return 0, err + } + if _, err := tx.Exec(`DELETE FROM access_list_users WHERE access_list_id = ?`, a.ID); err != nil { + return 0, err + } + } + + for _, r := range a.Rules { + if _, err := tx.Exec(`INSERT INTO access_rules (access_list_id, action, cidr, priority) VALUES (?,?,?,?)`, + a.ID, r.Action, r.CIDR, r.Priority); err != nil { + return 0, err + } + } + for _, u := range a.BasicAuthUsers { + if _, err := tx.Exec(`INSERT INTO access_list_users (access_list_id, username, password_hash) VALUES (?,?,?)`, + a.ID, u.Username, u.PasswordHash); err != nil { + return 0, err + } + } + + if err := tx.Commit(); err != nil { + return 0, err + } + return a.ID, nil +} + +func DeleteAccessList(db *sql.DB, id int64) error { + _, err := db.Exec(`DELETE FROM access_lists WHERE id = ?`, id) + return err +} diff --git a/internal/database/repo_audit.go b/internal/database/repo_audit.go new file mode 100644 index 0000000..9388ba7 --- /dev/null +++ b/internal/database/repo_audit.go @@ -0,0 +1,38 @@ +package database + +import ( + "database/sql" + + "github.com/owen/vibeproxy/internal/models" +) + +func InsertAuditLog(db *sql.DB, actor, action, target, details string) error { + _, err := db.Exec(`INSERT INTO audit_logs (actor, action, target, details) VALUES (?, ?, ?, ?)`, actor, action, target, details) + return err +} + +func ListAuditLogs(db *sql.DB, limit, offset int) ([]models.AuditLog, int, error) { + var total int + if err := db.QueryRow(`SELECT COUNT(*) FROM audit_logs`).Scan(&total); err != nil { + return nil, 0, err + } + if limit <= 0 || limit > 500 { + limit = 50 + } + rows, err := db.Query(`SELECT id, timestamp, actor, action, target, details FROM audit_logs ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []models.AuditLog + for rows.Next() { + var a models.AuditLog + var ts string + if err := rows.Scan(&a.ID, &ts, &a.Actor, &a.Action, &a.Target, &a.Details); err != nil { + return nil, 0, err + } + a.Timestamp = parseTime(ts) + out = append(out, a) + } + return out, total, rows.Err() +} diff --git a/internal/database/repo_certificates.go b/internal/database/repo_certificates.go new file mode 100644 index 0000000..b77d50f --- /dev/null +++ b/internal/database/repo_certificates.go @@ -0,0 +1,109 @@ +package database + +import ( + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/owen/vibeproxy/internal/models" +) + +func scanCertificate(rs rowScanner) (models.Certificate, error) { + var c models.Certificate + var domainsJSON string + var challengeType, dnsProvider, certPath, keyPath, notBefore, notAfter, lastRenewed, lastErr sql.NullString + var autoRenew int + var created, updated string + err := rs.Scan(&c.ID, &c.Name, &c.Type, &domainsJSON, &challengeType, &dnsProvider, &certPath, &keyPath, + ¬Before, ¬After, &lastRenewed, &lastErr, &autoRenew, &created, &updated) + if err != nil { + return c, err + } + json.Unmarshal([]byte(domainsJSON), &c.Domains) + c.ChallengeType = models.ChallengeType(challengeType.String) + c.DNSProvider = dnsProvider.String + c.CertPath = certPath.String + c.KeyPath = keyPath.String + if notBefore.Valid { + t := parseTime(notBefore.String) + c.NotBefore = &t + } + if notAfter.Valid { + t := parseTime(notAfter.String) + c.NotAfter = &t + } + if lastRenewed.Valid { + t := parseTime(lastRenewed.String) + c.LastRenewedAt = &t + } + c.LastError = lastErr.String + c.AutoRenew = autoRenew == 1 + c.CreatedAt = parseTime(created) + c.UpdatedAt = parseTime(updated) + return c, nil +} + +const certCols = `id, name, type, domains, challenge_type, dns_provider, cert_path, key_path, not_before, not_after, last_renewed_at, last_error, auto_renew, created_at, updated_at` + +func ListCertificates(db *sql.DB) ([]models.Certificate, error) { + rows, err := db.Query(`SELECT ` + certCols + ` FROM certificates ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.Certificate + for rows.Next() { + c, err := scanCertificate(rows) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func GetCertificate(db *sql.DB, id int64) (*models.Certificate, error) { + row := db.QueryRow(`SELECT `+certCols+` FROM certificates WHERE id = ?`, id) + c, err := scanCertificate(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &c, nil +} + +func SaveCertificate(db *sql.DB, c *models.Certificate) (int64, error) { + domainsJSON, _ := json.Marshal(c.Domains) + if c.ID == 0 { + res, err := db.Exec(`INSERT INTO certificates (name, type, domains, challenge_type, dns_provider, cert_path, key_path, + not_before, not_after, last_renewed_at, last_error, auto_renew) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + c.Name, c.Type, string(domainsJSON), string(c.ChallengeType), c.DNSProvider, c.CertPath, c.KeyPath, + timeOrNil(c.NotBefore), timeOrNil(c.NotAfter), timeOrNil(c.LastRenewedAt), c.LastError, boolToInt(c.AutoRenew)) + if err != nil { + return 0, err + } + id, err := res.LastInsertId() + c.ID = id + return id, err + } + _, err := db.Exec(`UPDATE certificates SET name=?, type=?, domains=?, challenge_type=?, dns_provider=?, cert_path=?, + key_path=?, not_before=?, not_after=?, last_renewed_at=?, last_error=?, auto_renew=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id=?`, c.Name, c.Type, string(domainsJSON), string(c.ChallengeType), c.DNSProvider, c.CertPath, c.KeyPath, + timeOrNil(c.NotBefore), timeOrNil(c.NotAfter), timeOrNil(c.LastRenewedAt), c.LastError, boolToInt(c.AutoRenew), c.ID) + return c.ID, err +} + +func DeleteCertificate(db *sql.DB, id int64) error { + _, err := db.Exec(`DELETE FROM certificates WHERE id = ?`, id) + return err +} + +func timeOrNil(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(timeFmt) +} diff --git a/internal/database/repo_logs.go b/internal/database/repo_logs.go new file mode 100644 index 0000000..5304d35 --- /dev/null +++ b/internal/database/repo_logs.go @@ -0,0 +1,220 @@ +package database + +import ( + "database/sql" + + "github.com/owen/vibeproxy/internal/models" +) + +func InsertRequestLog(db *sql.DB, l models.RequestLog) error { + _, err := db.Exec(`INSERT INTO request_logs (timestamp, proxy_host_id, proxy_host_name, client_ip, method, host, + path, status, response_size, backend, response_time_ms, user_agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + l.Timestamp.UTC().Format(timeFmt), nullInt64(l.ProxyHostID), l.ProxyHostName, l.ClientIP, l.Method, l.Host, + l.Path, l.Status, l.ResponseSize, l.Backend, l.ResponseTimeMS, l.UserAgent) + return err +} + +func InsertRequestLogBatch(db *sql.DB, logs []models.RequestLog) error { + if len(logs) == 0 { + return nil + } + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.Prepare(`INSERT INTO request_logs (timestamp, proxy_host_id, proxy_host_name, client_ip, method, host, + path, status, response_size, backend, response_time_ms, user_agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, l := range logs { + if _, err := stmt.Exec(l.Timestamp.UTC().Format(timeFmt), nullInt64(l.ProxyHostID), l.ProxyHostName, l.ClientIP, + l.Method, l.Host, l.Path, l.Status, l.ResponseSize, l.Backend, l.ResponseTimeMS, l.UserAgent); err != nil { + return err + } + } + return tx.Commit() +} + +type LogFilter struct { + ProxyHostID *int64 + Status int + Search string + Limit int + Offset int +} + +func QueryRequestLogs(db *sql.DB, f LogFilter) ([]models.RequestLog, int, error) { + where := "WHERE 1=1" + var args []any + if f.ProxyHostID != nil { + where += " AND proxy_host_id = ?" + args = append(args, *f.ProxyHostID) + } + if f.Status > 0 { + where += " AND status = ?" + args = append(args, f.Status) + } + if f.Search != "" { + where += " AND (host LIKE ? OR path LIKE ? OR client_ip LIKE ?)" + s := "%" + f.Search + "%" + args = append(args, s, s, s) + } + + var total int + if err := db.QueryRow(`SELECT COUNT(*) FROM request_logs `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + + limit := f.Limit + if limit <= 0 || limit > 500 { + limit = 50 + } + q := `SELECT id, timestamp, proxy_host_id, proxy_host_name, client_ip, method, host, path, status, response_size, + backend, response_time_ms, user_agent FROM request_logs ` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, limit, f.Offset) + rows, err := db.Query(q, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []models.RequestLog + for rows.Next() { + var l models.RequestLog + var ts string + var hostID sql.NullInt64 + if err := rows.Scan(&l.ID, &ts, &hostID, &l.ProxyHostName, &l.ClientIP, &l.Method, &l.Host, &l.Path, &l.Status, + &l.ResponseSize, &l.Backend, &l.ResponseTimeMS, &l.UserAgent); err != nil { + return nil, 0, err + } + l.Timestamp = parseTime(ts) + if hostID.Valid { + v := hostID.Int64 + l.ProxyHostID = &v + } + out = append(out, l) + } + return out, total, rows.Err() +} + +func DeleteOldRequestLogs(db *sql.DB, retentionDays int) (int64, error) { + res, err := db.Exec(`DELETE FROM request_logs WHERE timestamp < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`, "-"+itoa(retentionDays)+" days") + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func itoa(i int) string { + if i < 0 { + i = 0 + } + digits := []byte{} + if i == 0 { + return "0" + } + for i > 0 { + digits = append([]byte{byte('0' + i%10)}, digits...) + i /= 10 + } + return string(digits) +} + +// Stats + +func CountRequestLogsSince(db *sql.DB, sinceExpr string) (int, error) { + var n int + err := db.QueryRow(`SELECT COUNT(*) FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`, sinceExpr).Scan(&n) + return n, err +} + +type StatusCount struct { + Status int + Count int +} + +func StatusBreakdownSince(db *sql.DB, sinceExpr string) ([]StatusCount, error) { + rows, err := db.Query(`SELECT status, COUNT(*) FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY status ORDER BY status`, sinceExpr) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StatusCount + for rows.Next() { + var s StatusCount + if err := rows.Scan(&s.Status, &s.Count); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +type HostCount struct { + Host string + Count int +} + +func TopHostsSince(db *sql.DB, sinceExpr string, limit int) ([]HostCount, error) { + rows, err := db.Query(`SELECT host, COUNT(*) c FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY host ORDER BY c DESC LIMIT ?`, sinceExpr, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HostCount + for rows.Next() { + var h HostCount + if err := rows.Scan(&h.Host, &h.Count); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + +func TopClientsSince(db *sql.DB, sinceExpr string, limit int) ([]HostCount, error) { + rows, err := db.Query(`SELECT client_ip, COUNT(*) c FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY client_ip ORDER BY c DESC LIMIT ?`, sinceExpr, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HostCount + for rows.Next() { + var h HostCount + if err := rows.Scan(&h.Host, &h.Count); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + +func RecentErrorsSince(db *sql.DB, sinceExpr string, limit int) ([]models.RequestLog, error) { + rows, err := db.Query(`SELECT id, timestamp, proxy_host_id, proxy_host_name, client_ip, method, host, path, status, + response_size, backend, response_time_ms, user_agent FROM request_logs + WHERE status >= 400 AND timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) ORDER BY id DESC LIMIT ?`, sinceExpr, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.RequestLog + for rows.Next() { + var l models.RequestLog + var ts string + var hostID sql.NullInt64 + if err := rows.Scan(&l.ID, &ts, &hostID, &l.ProxyHostName, &l.ClientIP, &l.Method, &l.Host, &l.Path, &l.Status, + &l.ResponseSize, &l.Backend, &l.ResponseTimeMS, &l.UserAgent); err != nil { + return nil, err + } + l.Timestamp = parseTime(ts) + if hostID.Valid { + v := hostID.Int64 + l.ProxyHostID = &v + } + out = append(out, l) + } + return out, rows.Err() +} diff --git a/internal/database/repo_proxyhosts.go b/internal/database/repo_proxyhosts.go new file mode 100644 index 0000000..6433a4c --- /dev/null +++ b/internal/database/repo_proxyhosts.go @@ -0,0 +1,242 @@ +package database + +import ( + "database/sql" + "errors" + + "github.com/owen/vibeproxy/internal/models" +) + +func ListProxyHosts(db *sql.DB) ([]models.ProxyHost, error) { + rows, err := db.Query(`SELECT id, name, type, listen_addr, forward_scheme, preserve_host_header, websocket_support, + force_https, hsts_enabled, certificate_id, access_list_id, connect_timeout_ms, read_timeout_ms, write_timeout_ms, + idle_timeout_ms, load_balancing_method, redirect_target_url, redirect_code, redirect_preserve_path, + redirect_preserve_qs, enabled, comments, created_at, updated_at FROM proxy_hosts ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.ProxyHost + for rows.Next() { + h, err := scanProxyHost(rows) + if err != nil { + return nil, err + } + out = append(out, h) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + if err := hydrateProxyHost(db, &out[i]); err != nil { + return nil, err + } + } + return out, nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanProxyHost(rs rowScanner) (models.ProxyHost, error) { + var h models.ProxyHost + var certID, aclID sql.NullInt64 + var preserveHost, ws, forceHTTPS, hsts, enabled, preservePath, preserveQS int + var created, updated string + err := rs.Scan(&h.ID, &h.Name, &h.Type, &h.ListenAddr, &h.ForwardScheme, &preserveHost, &ws, + &forceHTTPS, &hsts, &certID, &aclID, &h.ConnectTimeoutMS, &h.ReadTimeoutMS, &h.WriteTimeoutMS, + &h.IdleTimeoutMS, &h.LoadBalancingMethod, &h.RedirectTargetURL, &h.RedirectCode, &preservePath, + &preserveQS, &enabled, &h.Comments, &created, &updated) + if err != nil { + return h, err + } + h.PreserveHostHeader = preserveHost == 1 + h.WebsocketSupport = ws == 1 + h.ForceHTTPS = forceHTTPS == 1 + h.HSTSEnabled = hsts == 1 + h.RedirectPreservePath = preservePath == 1 + h.RedirectPreserveQS = preserveQS == 1 + h.Enabled = enabled == 1 + h.CreatedAt = parseTime(created) + h.UpdatedAt = parseTime(updated) + if certID.Valid { + v := certID.Int64 + h.CertificateID = &v + } + if aclID.Valid { + v := aclID.Int64 + h.AccessListID = &v + } + return h, nil +} + +func hydrateProxyHost(db *sql.DB, h *models.ProxyHost) error { + drows, err := db.Query(`SELECT domain FROM proxy_host_domains WHERE proxy_host_id = ? ORDER BY id`, h.ID) + if err != nil { + return err + } + for drows.Next() { + var d string + if err := drows.Scan(&d); err != nil { + drows.Close() + return err + } + h.Domains = append(h.Domains, d) + } + drows.Close() + if err := drows.Err(); err != nil { + return err + } + + brows, err := db.Query(`SELECT id, proxy_host_id, host, port, weight, enabled, health_check_type, health_check_path, + health_check_status, interval_seconds, failure_threshold, recovery_threshold FROM backends WHERE proxy_host_id = ? ORDER BY id`, h.ID) + if err != nil { + return err + } + for brows.Next() { + var b models.Backend + var enabled int + if err := brows.Scan(&b.ID, &b.ProxyHostID, &b.Host, &b.Port, &b.Weight, &enabled, &b.HealthCheckType, &b.HealthCheckPath, + &b.HealthCheckStatus, &b.IntervalSeconds, &b.FailureThreshold, &b.RecoveryThreshold); err != nil { + brows.Close() + return err + } + b.Enabled = enabled == 1 + b.Healthy = true + h.Backends = append(h.Backends, b) + } + brows.Close() + if err := brows.Err(); err != nil { + return err + } + + hrows, err := db.Query(`SELECT id, proxy_host_id, direction, action, name, value FROM custom_headers WHERE proxy_host_id = ? ORDER BY id`, h.ID) + if err != nil { + return err + } + for hrows.Next() { + var c models.CustomHeader + if err := hrows.Scan(&c.ID, &c.ProxyHostID, &c.Direction, &c.Action, &c.Name, &c.Value); err != nil { + hrows.Close() + return err + } + h.CustomHeaders = append(h.CustomHeaders, c) + } + hrows.Close() + return hrows.Err() +} + +func GetProxyHost(db *sql.DB, id int64) (*models.ProxyHost, error) { + row := db.QueryRow(`SELECT id, name, type, listen_addr, forward_scheme, preserve_host_header, websocket_support, + force_https, hsts_enabled, certificate_id, access_list_id, connect_timeout_ms, read_timeout_ms, write_timeout_ms, + idle_timeout_ms, load_balancing_method, redirect_target_url, redirect_code, redirect_preserve_path, + redirect_preserve_qs, enabled, comments, created_at, updated_at FROM proxy_hosts WHERE id = ?`, id) + h, err := scanProxyHost(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if err := hydrateProxyHost(db, &h); err != nil { + return nil, err + } + return &h, nil +} + +// SaveProxyHost inserts (id==0) or fully replaces (id!=0) a proxy host, +// including its domains, backends and custom headers, in one transaction. +func SaveProxyHost(db *sql.DB, h *models.ProxyHost) (int64, error) { + tx, err := db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + if h.ID == 0 { + res, err := tx.Exec(`INSERT INTO proxy_hosts (name, type, listen_addr, forward_scheme, preserve_host_header, + websocket_support, force_https, hsts_enabled, certificate_id, access_list_id, connect_timeout_ms, + read_timeout_ms, write_timeout_ms, idle_timeout_ms, load_balancing_method, redirect_target_url, + redirect_code, redirect_preserve_path, redirect_preserve_qs, enabled, comments) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + h.Name, h.Type, h.ListenAddr, h.ForwardScheme, boolToInt(h.PreserveHostHeader), + boolToInt(h.WebsocketSupport), boolToInt(h.ForceHTTPS), boolToInt(h.HSTSEnabled), nullInt64(h.CertificateID), + nullInt64(h.AccessListID), h.ConnectTimeoutMS, h.ReadTimeoutMS, h.WriteTimeoutMS, h.IdleTimeoutMS, + h.LoadBalancingMethod, h.RedirectTargetURL, h.RedirectCode, boolToInt(h.RedirectPreservePath), + boolToInt(h.RedirectPreserveQS), boolToInt(h.Enabled), h.Comments) + if err != nil { + return 0, err + } + h.ID, err = res.LastInsertId() + if err != nil { + return 0, err + } + } else { + _, err := tx.Exec(`UPDATE proxy_hosts SET name=?, type=?, listen_addr=?, forward_scheme=?, preserve_host_header=?, + websocket_support=?, force_https=?, hsts_enabled=?, certificate_id=?, access_list_id=?, connect_timeout_ms=?, + read_timeout_ms=?, write_timeout_ms=?, idle_timeout_ms=?, load_balancing_method=?, redirect_target_url=?, + redirect_code=?, redirect_preserve_path=?, redirect_preserve_qs=?, enabled=?, comments=?, + updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + h.Name, h.Type, h.ListenAddr, h.ForwardScheme, boolToInt(h.PreserveHostHeader), + boolToInt(h.WebsocketSupport), boolToInt(h.ForceHTTPS), boolToInt(h.HSTSEnabled), nullInt64(h.CertificateID), + nullInt64(h.AccessListID), h.ConnectTimeoutMS, h.ReadTimeoutMS, h.WriteTimeoutMS, h.IdleTimeoutMS, + h.LoadBalancingMethod, h.RedirectTargetURL, h.RedirectCode, boolToInt(h.RedirectPreservePath), + boolToInt(h.RedirectPreserveQS), boolToInt(h.Enabled), h.Comments, h.ID) + if err != nil { + return 0, err + } + if _, err := tx.Exec(`DELETE FROM proxy_host_domains WHERE proxy_host_id = ?`, h.ID); err != nil { + return 0, err + } + if _, err := tx.Exec(`DELETE FROM backends WHERE proxy_host_id = ?`, h.ID); err != nil { + return 0, err + } + if _, err := tx.Exec(`DELETE FROM custom_headers WHERE proxy_host_id = ?`, h.ID); err != nil { + return 0, err + } + } + + for _, d := range h.Domains { + if _, err := tx.Exec(`INSERT INTO proxy_host_domains (proxy_host_id, domain) VALUES (?, ?)`, h.ID, d); err != nil { + return 0, err + } + } + for _, b := range h.Backends { + if _, err := tx.Exec(`INSERT INTO backends (proxy_host_id, host, port, weight, enabled, health_check_type, + health_check_path, health_check_status, interval_seconds, failure_threshold, recovery_threshold) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + h.ID, b.Host, b.Port, b.Weight, boolToInt(b.Enabled), b.HealthCheckType, b.HealthCheckPath, + b.HealthCheckStatus, b.IntervalSeconds, b.FailureThreshold, b.RecoveryThreshold); err != nil { + return 0, err + } + } + for _, c := range h.CustomHeaders { + if _, err := tx.Exec(`INSERT INTO custom_headers (proxy_host_id, direction, action, name, value) VALUES (?,?,?,?,?)`, + h.ID, c.Direction, c.Action, c.Name, c.Value); err != nil { + return 0, err + } + } + + if err := tx.Commit(); err != nil { + return 0, err + } + return h.ID, nil +} + +func DeleteProxyHost(db *sql.DB, id int64) error { + _, err := db.Exec(`DELETE FROM proxy_hosts WHERE id = ?`, id) + return err +} + +func SetProxyHostEnabled(db *sql.DB, id int64, enabled bool) error { + _, err := db.Exec(`UPDATE proxy_hosts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, boolToInt(enabled), id) + return err +} + +func nullInt64(v *int64) any { + if v == nil { + return nil + } + return *v +} diff --git a/internal/database/repo_settings.go b/internal/database/repo_settings.go new file mode 100644 index 0000000..48cdc72 --- /dev/null +++ b/internal/database/repo_settings.go @@ -0,0 +1,35 @@ +package database + +import "database/sql" + +// GetSetting returns the raw string value for key, or def if unset. +func GetSetting(db *sql.DB, key, def string) string { + var v string + err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + if err != nil { + return def + } + return v +} + +func SetSetting(db *sql.DB, key, value string) error { + _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) + return err +} + +func AllSettings(db *sql.DB) (map[string]string, error) { + rows, err := db.Query(`SELECT key, value FROM settings`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + out[k] = v + } + return out, rows.Err() +} diff --git a/internal/database/repo_streams.go b/internal/database/repo_streams.go new file mode 100644 index 0000000..4500ea1 --- /dev/null +++ b/internal/database/repo_streams.go @@ -0,0 +1,85 @@ +package database + +import ( + "database/sql" + "errors" + + "github.com/owen/vibeproxy/internal/models" +) + +func scanStream(rs rowScanner) (models.Stream, error) { + var s models.Stream + var proxyProto, enabled int + var created, updated string + err := rs.Scan(&s.ID, &s.Protocol, &s.ListenIP, &s.ListenPort, &s.DestinationHost, &s.DestinationPort, + &s.ConnectTimeoutMS, &s.IdleTimeoutMS, &proxyProto, &enabled, &s.Comments, &created, &updated) + if err != nil { + return s, err + } + s.ProxyProtocol = proxyProto == 1 + s.Enabled = enabled == 1 + s.CreatedAt = parseTime(created) + s.UpdatedAt = parseTime(updated) + return s, nil +} + +const streamCols = `id, protocol, listen_ip, listen_port, destination_host, destination_port, connect_timeout_ms, idle_timeout_ms, proxy_protocol, enabled, comments, created_at, updated_at` + +func ListStreams(db *sql.DB) ([]models.Stream, error) { + rows, err := db.Query(`SELECT ` + streamCols + ` FROM streams ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.Stream + for rows.Next() { + s, err := scanStream(rows) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func GetStream(db *sql.DB, id int64) (*models.Stream, error) { + row := db.QueryRow(`SELECT `+streamCols+` FROM streams WHERE id = ?`, id) + s, err := scanStream(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &s, nil +} + +func SaveStream(db *sql.DB, s *models.Stream) (int64, error) { + if s.ID == 0 { + res, err := db.Exec(`INSERT INTO streams (protocol, listen_ip, listen_port, destination_host, destination_port, + connect_timeout_ms, idle_timeout_ms, proxy_protocol, enabled, comments) VALUES (?,?,?,?,?,?,?,?,?,?)`, + s.Protocol, s.ListenIP, s.ListenPort, s.DestinationHost, s.DestinationPort, s.ConnectTimeoutMS, + s.IdleTimeoutMS, boolToInt(s.ProxyProtocol), boolToInt(s.Enabled), s.Comments) + if err != nil { + return 0, err + } + id, err := res.LastInsertId() + s.ID = id + return id, err + } + _, err := db.Exec(`UPDATE streams SET protocol=?, listen_ip=?, listen_port=?, destination_host=?, destination_port=?, + connect_timeout_ms=?, idle_timeout_ms=?, proxy_protocol=?, enabled=?, comments=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id=?`, s.Protocol, s.ListenIP, s.ListenPort, s.DestinationHost, s.DestinationPort, s.ConnectTimeoutMS, + s.IdleTimeoutMS, boolToInt(s.ProxyProtocol), boolToInt(s.Enabled), s.Comments, s.ID) + return s.ID, err +} + +func DeleteStream(db *sql.DB, id int64) error { + _, err := db.Exec(`DELETE FROM streams WHERE id = ?`, id) + return err +} + +func SetStreamEnabled(db *sql.DB, id int64, enabled bool) error { + _, err := db.Exec(`UPDATE streams SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, boolToInt(enabled), id) + return err +} diff --git a/internal/database/repo_tokens.go b/internal/database/repo_tokens.go new file mode 100644 index 0000000..82306c1 --- /dev/null +++ b/internal/database/repo_tokens.go @@ -0,0 +1,77 @@ +package database + +import ( + "database/sql" + "errors" + + "github.com/owen/vibeproxy/internal/models" +) + +func CreateAPIToken(db *sql.DB, name, tokenHash, prefix string) (int64, error) { + res, err := db.Exec(`INSERT INTO api_tokens (name, token_hash, prefix) VALUES (?, ?, ?)`, name, tokenHash, prefix) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func ListAPITokens(db *sql.DB) ([]models.APIToken, error) { + rows, err := db.Query(`SELECT id, name, token_hash, prefix, created_at, last_used_at, revoked_at FROM api_tokens ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.APIToken + for rows.Next() { + var t models.APIToken + var created string + var lastUsed, revoked sql.NullString + if err := rows.Scan(&t.ID, &t.Name, &t.TokenHash, &t.Prefix, &created, &lastUsed, &revoked); err != nil { + return nil, err + } + t.CreatedAt = parseTime(created) + if lastUsed.Valid { + tt := parseTime(lastUsed.String) + t.LastUsedAt = &tt + } + if revoked.Valid { + tt := parseTime(revoked.String) + t.RevokedAt = &tt + } + out = append(out, t) + } + return out, rows.Err() +} + +func GetAPITokenByHash(db *sql.DB, hash string) (*models.APIToken, error) { + row := db.QueryRow(`SELECT id, name, token_hash, prefix, created_at, last_used_at, revoked_at FROM api_tokens WHERE token_hash = ?`, hash) + var t models.APIToken + var created string + var lastUsed, revoked sql.NullString + if err := row.Scan(&t.ID, &t.Name, &t.TokenHash, &t.Prefix, &created, &lastUsed, &revoked); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + t.CreatedAt = parseTime(created) + if lastUsed.Valid { + tt := parseTime(lastUsed.String) + t.LastUsedAt = &tt + } + if revoked.Valid { + tt := parseTime(revoked.String) + t.RevokedAt = &tt + } + return &t, nil +} + +func TouchAPIToken(db *sql.DB, id int64) error { + _, err := db.Exec(`UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id) + return err +} + +func RevokeAPIToken(db *sql.DB, id int64) error { + _, err := db.Exec(`UPDATE api_tokens SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ? AND revoked_at IS NULL`, id) + return err +} diff --git a/internal/database/repo_users.go b/internal/database/repo_users.go new file mode 100644 index 0000000..abfef71 --- /dev/null +++ b/internal/database/repo_users.go @@ -0,0 +1,58 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + "github.com/owen/vibeproxy/internal/models" +) + +var ErrNotFound = errors.New("not found") + +const timeFmt = "2006-01-02T15:04:05.000Z" + +func parseTime(s string) time.Time { + t, _ := time.Parse(timeFmt, s) + return t +} + +func GetUserByUsername(db *sql.DB, username string) (*models.User, error) { + row := db.QueryRow(`SELECT id, username, password_hash, must_change_pw, created_at, updated_at FROM users WHERE username = ?`, username) + u := &models.User{} + var mustChange int + var created, updated string + if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &mustChange, &created, &updated); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + u.MustChangePW = mustChange == 1 + u.CreatedAt = parseTime(created) + u.UpdatedAt = parseTime(updated) + return u, nil +} + +func CountUsers(db *sql.DB) (int, error) { + var n int + err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +func CreateUser(db *sql.DB, username, passwordHash string, mustChange bool) error { + _, err := db.Exec(`INSERT INTO users (username, password_hash, must_change_pw) VALUES (?, ?, ?)`, username, passwordHash, boolToInt(mustChange)) + return err +} + +func UpdateUserPassword(db *sql.DB, username, passwordHash string, mustChange bool) error { + _, err := db.Exec(`UPDATE users SET password_hash = ?, must_change_pw = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE username = ?`, passwordHash, boolToInt(mustChange), username) + return err +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/health/health.go b/internal/health/health.go new file mode 100644 index 0000000..09fe894 --- /dev/null +++ b/internal/health/health.go @@ -0,0 +1,104 @@ +// Package health runs periodic TCP/HTTP checks against backends and keeps +// their healthy state in loadbalancer.Backend up to date. +package health + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/owen/vibeproxy/internal/loadbalancer" + "github.com/owen/vibeproxy/internal/models" +) + +// OnChange is invoked whenever a backend transitions healthy<->unhealthy. +type OnChange func(hostID int64, b *loadbalancer.Backend, healthy bool) + +type Checker struct { + client *http.Client + onChange OnChange + cancels map[int64]context.CancelFunc +} + +func NewChecker(onChange OnChange) *Checker { + return &Checker{ + client: &http.Client{Timeout: 5 * time.Second}, + onChange: onChange, + cancels: map[int64]context.CancelFunc{}, + } +} + +// Watch starts a background goroutine checking b at its configured interval +// until ctx is cancelled. hostID is used only for the OnChange callback. +func (c *Checker) Watch(ctx context.Context, hostID int64, b *loadbalancer.Backend) { + if b.Model.HealthCheckType == models.HealthCheckNone || b.Model.HealthCheckType == "" { + return + } + interval := time.Duration(b.Model.IntervalSeconds) * time.Second + if interval <= 0 { + interval = 10 * time.Second + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + c.check(ctx, hostID, b) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.check(ctx, hostID, b) + } + } + }() +} + +func (c *Checker) check(ctx context.Context, hostID int64, b *loadbalancer.Backend) { + ok := false + switch b.Model.HealthCheckType { + case models.HealthCheckTCP: + ok = c.checkTCP(ctx, b) + case models.HealthCheckHTTP: + ok = c.checkHTTP(ctx, b) + default: + ok = true + } + if changed, healthy := b.RecordCheck(ok); changed && c.onChange != nil { + c.onChange(hostID, b, healthy) + } +} + +func (c *Checker) checkTCP(ctx context.Context, b *loadbalancer.Backend) bool { + d := net.Dialer{Timeout: 3 * time.Second} + addr := net.JoinHostPort(b.Model.Host, fmt.Sprintf("%d", b.Model.Port)) + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return false + } + conn.Close() + return true +} + +func (c *Checker) checkHTTP(ctx context.Context, b *loadbalancer.Backend) bool { + path := b.Model.HealthCheckPath + if path == "" { + path = "/" + } + url := fmt.Sprintf("http://%s:%d%s", b.Model.Host, b.Model.Port, path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false + } + resp, err := c.client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + want := b.Model.HealthCheckStatus + if want == 0 { + want = 200 + } + return resp.StatusCode == want +} diff --git a/internal/loadbalancer/loadbalancer.go b/internal/loadbalancer/loadbalancer.go new file mode 100644 index 0000000..a3693c7 --- /dev/null +++ b/internal/loadbalancer/loadbalancer.go @@ -0,0 +1,122 @@ +// Package loadbalancer picks a healthy backend for a proxy host using +// round-robin, weighted, or least-connections strategies. +package loadbalancer + +import ( + "errors" + "sync/atomic" + + "github.com/owen/vibeproxy/internal/models" +) + +var ErrNoHealthyBackend = errors.New("no healthy backend available") + +// Backend wraps a models.Backend with runtime-only state shared between the +// health checker and the load balancer. +type Backend struct { + Model models.Backend + healthy atomic.Bool + activeConns atomic.Int64 + failCount atomic.Int32 + okCount atomic.Int32 +} + +func NewBackend(m models.Backend) *Backend { + b := &Backend{Model: m} + b.healthy.Store(m.HealthCheckType == models.HealthCheckNone || m.HealthCheckType == "") + return b +} + +func (b *Backend) Healthy() bool { return b.healthy.Load() && b.Model.Enabled } +func (b *Backend) ActiveConns() int64 { return b.activeConns.Load() } +func (b *Backend) Acquire() { b.activeConns.Add(1) } +func (b *Backend) Release() { b.activeConns.Add(-1) } +func (b *Backend) Addr() string { return b.Model.Host } + +// RecordCheck updates the health state given a single check's outcome, +// applying the configured failure/recovery thresholds. +func (b *Backend) RecordCheck(ok bool) (changed bool, nowHealthy bool) { + wasHealthy := b.healthy.Load() + if ok { + b.failCount.Store(0) + n := b.okCount.Add(1) + threshold := int32(b.Model.RecoveryThreshold) + if threshold < 1 { + threshold = 1 + } + if !wasHealthy && n >= threshold { + b.healthy.Store(true) + } + } else { + b.okCount.Store(0) + n := b.failCount.Add(1) + threshold := int32(b.Model.FailureThreshold) + if threshold < 1 { + threshold = 1 + } + if wasHealthy && n >= threshold { + b.healthy.Store(false) + } + } + return wasHealthy != b.healthy.Load(), b.healthy.Load() +} + +// Pool is the set of backends for one proxy host plus a pick strategy. +type Pool struct { + Method string // round_robin | least_conn | weighted + Backends []*Backend + counter atomic.Uint64 +} + +func NewPool(method string, backends []*Backend) *Pool { + return &Pool{Method: method, Backends: backends} +} + +// Pick selects the next backend to use. Callers must call Release() on the +// returned backend (via a deferred call) once the request/connection ends. +func (p *Pool) Pick() (*Backend, error) { + healthy := make([]*Backend, 0, len(p.Backends)) + for _, b := range p.Backends { + if b.Healthy() { + healthy = append(healthy, b) + } + } + if len(healthy) == 0 { + return nil, ErrNoHealthyBackend + } + + switch p.Method { + case "least_conn": + best := healthy[0] + for _, b := range healthy[1:] { + if b.ActiveConns() < best.ActiveConns() { + best = b + } + } + return best, nil + case "weighted": + total := 0 + for _, b := range healthy { + w := b.Model.Weight + if w < 1 { + w = 1 + } + total += w + } + n := int(p.counter.Add(1)-1) % total + for _, b := range healthy { + w := b.Model.Weight + if w < 1 { + w = 1 + } + if n < w { + return b, nil + } + n -= w + } + return healthy[0], nil + default: // round_robin + idx := int(p.counter.Add(1)-1) % len(healthy) + return healthy[idx], nil + } +} diff --git a/internal/loadbalancer/loadbalancer_test.go b/internal/loadbalancer/loadbalancer_test.go new file mode 100644 index 0000000..275e624 --- /dev/null +++ b/internal/loadbalancer/loadbalancer_test.go @@ -0,0 +1,91 @@ +package loadbalancer + +import ( + "testing" + + "github.com/owen/vibeproxy/internal/models" +) + +func TestRoundRobin(t *testing.T) { + b1 := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckNone}) + b2 := NewBackend(models.Backend{Host: "b", Enabled: true, HealthCheckType: models.HealthCheckNone}) + pool := NewPool("round_robin", []*Backend{b1, b2}) + + seen := map[string]int{} + for i := 0; i < 10; i++ { + b, err := pool.Pick() + if err != nil { + t.Fatal(err) + } + seen[b.Model.Host]++ + } + if seen["a"] != 5 || seen["b"] != 5 { + t.Fatalf("expected even split, got %v", seen) + } +} + +func TestLeastConn(t *testing.T) { + b1 := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckNone}) + b2 := NewBackend(models.Backend{Host: "b", Enabled: true, HealthCheckType: models.HealthCheckNone}) + pool := NewPool("least_conn", []*Backend{b1, b2}) + + b1.Acquire() + b1.Acquire() + picked, err := pool.Pick() + if err != nil { + t.Fatal(err) + } + if picked.Model.Host != "b" { + t.Fatalf("expected b (fewer active conns), got %s", picked.Model.Host) + } +} + +func TestWeighted(t *testing.T) { + b1 := NewBackend(models.Backend{Host: "a", Weight: 3, Enabled: true, HealthCheckType: models.HealthCheckNone}) + b2 := NewBackend(models.Backend{Host: "b", Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckNone}) + pool := NewPool("weighted", []*Backend{b1, b2}) + + counts := map[string]int{} + for i := 0; i < 8; i++ { + b, err := pool.Pick() + if err != nil { + t.Fatal(err) + } + counts[b.Model.Host]++ + } + if counts["a"] != 6 || counts["b"] != 2 { + t.Fatalf("expected 3:1 weighted split (6:2 of 8), got %v", counts) + } +} + +func TestNoHealthyBackend(t *testing.T) { + b1 := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckTCP, FailureThreshold: 1}) + pool := NewPool("round_robin", []*Backend{b1}) + if _, err := pool.Pick(); err != ErrNoHealthyBackend { + t.Fatalf("expected ErrNoHealthyBackend, got %v", err) + } +} + +func TestRecordCheckThresholds(t *testing.T) { + b := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckTCP, FailureThreshold: 2, RecoveryThreshold: 2}) + if b.Healthy() { + t.Fatal("expected backend to start unhealthy pending checks") + } + b.RecordCheck(true) + if b.Healthy() { + t.Fatal("should still be unhealthy after 1 of 2 required successes") + } + changed, healthy := b.RecordCheck(true) + if !changed || !healthy { + t.Fatal("expected backend to become healthy after 2 successes") + } + + b.RecordCheck(false) + if !b.Healthy() { + t.Fatal("should still be healthy after 1 of 2 required failures") + } + changed, healthy = b.RecordCheck(false) + if !changed || healthy { + t.Fatal("expected backend to become unhealthy after 2 failures") + } +} diff --git a/internal/logging/audit.go b/internal/logging/audit.go new file mode 100644 index 0000000..430733c --- /dev/null +++ b/internal/logging/audit.go @@ -0,0 +1,13 @@ +package logging + +import ( + "database/sql" + + "github.com/owen/vibeproxy/internal/database" +) + +// Audit records a configuration-change event. Callers must never pass +// passwords, API tokens, or private key material in details. +func Audit(db *sql.DB, actor, action, target, details string) { + _ = database.InsertAuditLog(db, actor, action, target, details) +} diff --git a/internal/logging/requestlog.go b/internal/logging/requestlog.go new file mode 100644 index 0000000..92a5954 --- /dev/null +++ b/internal/logging/requestlog.go @@ -0,0 +1,94 @@ +// Package logging buffers HTTP request log entries and writes them to +// SQLite in batches so the hot request path never blocks on a DB write; it +// also runs the retention cleanup job and provides audit-log helpers. +package logging + +import ( + "context" + "database/sql" + "log/slog" + "time" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +type RequestLogger struct { + db *sql.DB + log *slog.Logger + ch chan models.RequestLog +} + +func NewRequestLogger(db *sql.DB, log *slog.Logger) *RequestLogger { + return &RequestLogger{db: db, log: log, ch: make(chan models.RequestLog, 2048)} +} + +// Log enqueues an entry; if the buffer is full the entry is dropped rather +// than blocking the proxy hot path (a slow logger must never slow down +// traffic). +func (l *RequestLogger) Log(entry models.RequestLog) { + select { + case l.ch <- entry: + default: + l.log.Warn("request log buffer full, dropping entry") + } +} + +// Run drains the buffer, flushing in batches, until ctx is cancelled. +func (l *RequestLogger) Run(ctx context.Context) { + const batchSize = 100 + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + batch := make([]models.RequestLog, 0, batchSize) + + flush := func() { + if len(batch) == 0 { + return + } + if err := database.InsertRequestLogBatch(l.db, batch); err != nil { + l.log.Error("request log flush failed", "err", err) + } + batch = batch[:0] + } + + for { + select { + case <-ctx.Done(): + flush() + return + case e := <-l.ch: + batch = append(batch, e) + if len(batch) >= batchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} + +// StartRetentionLoop periodically deletes request logs older than +// retentionDays so the database does not grow without bound. +func StartRetentionLoop(ctx context.Context, db *sql.DB, log *slog.Logger, getRetentionDays func() int) { + ticker := time.NewTicker(1 * time.Hour) + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + days := getRetentionDays() + if days <= 0 { + continue + } + n, err := database.DeleteOldRequestLogs(db, days) + if err != nil { + log.Error("log retention cleanup failed", "err", err) + } else if n > 0 { + log.Info("log retention cleanup", "deleted", n) + } + } + } + }() +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..beb2e7d --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,69 @@ +// Package metrics exposes Prometheus counters/gauges/histograms for HTTP, +// TCP/UDP, backend, and TLS activity. +package metrics + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "vibeproxy_http_requests_total", + Help: "Total HTTP requests handled by the proxy.", + }, []string{"proxy_host", "method", "status"}) + + HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "vibeproxy_http_request_duration_seconds", + Help: "HTTP request latency observed by the proxy, end to end.", + Buckets: prometheus.DefBuckets, + }, []string{"proxy_host"}) + + BackendRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "vibeproxy_backend_request_duration_seconds", + Help: "Backend response latency as observed by the proxy.", + Buckets: prometheus.DefBuckets, + }, []string{"proxy_host", "backend"}) + + BytesTransferred = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "vibeproxy_bytes_transferred_total", + Help: "Bytes transferred through the proxy.", + }, []string{"direction", "protocol"}) // direction=in|out, protocol=http|tcp|udp + + ActiveConnections = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "vibeproxy_active_connections", + Help: "Currently active connections.", + }, []string{"protocol"}) // http|tcp|udp + + BackendHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "vibeproxy_backend_healthy", + Help: "1 if the backend is currently considered healthy, else 0.", + }, []string{"proxy_host", "backend"}) + + TCPConnectionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "vibeproxy_tcp_connections_total", + Help: "Total TCP stream connections accepted.", + }, []string{"stream"}) + + UDPPacketsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "vibeproxy_udp_packets_total", + Help: "Total UDP packets proxied.", + }, []string{"stream", "direction"}) + + TLSHandshakesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "vibeproxy_tls_handshakes_total", + Help: "Total TLS handshakes, by result.", + }, []string{"result"}) // ok|error + + CertificateExpirySeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "vibeproxy_certificate_expiry_seconds", + Help: "Seconds until certificate expiry (negative if already expired).", + }, []string{"name"}) +) + +func Handler() http.Handler { + return promhttp.Handler() +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..cd2791d --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,237 @@ +// Package models defines the persistent entities stored in SQLite. +package models + +import "time" + +type User struct { + ID int64 + Username string + PasswordHash string + MustChangePW bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type APIToken struct { + ID int64 + Name string + TokenHash string + Prefix string + CreatedAt time.Time + LastUsedAt *time.Time + RevokedAt *time.Time +} + +type ProxyHostType string + +const ( + ProxyHostTypeProxy ProxyHostType = "proxy" + ProxyHostTypeRedirect ProxyHostType = "redirect" +) + +type ProxyHost struct { + ID int64 + Name string + Type ProxyHostType + Domains []string + ListenAddr string // e.g. "0.0.0.0" or "" for all + ForwardScheme string // http or https (upstream scheme) + PreserveHostHeader bool + WebsocketSupport bool + ForceHTTPS bool + HSTSEnabled bool + CertificateID *int64 + AccessListID *int64 + ConnectTimeoutMS int + ReadTimeoutMS int + WriteTimeoutMS int + IdleTimeoutMS int + LoadBalancingMethod string // round_robin, least_conn, weighted + RedirectTargetURL string + RedirectCode int + RedirectPreservePath bool + RedirectPreserveQS bool + Enabled bool + Comments string + CreatedAt time.Time + UpdatedAt time.Time + + Backends []Backend + CustomHeaders []CustomHeader +} + +type CustomHeaderAction string + +const ( + HeaderActionSet CustomHeaderAction = "set" + HeaderActionRemove CustomHeaderAction = "remove" +) + +type CustomHeaderDirection string + +const ( + HeaderDirectionRequest CustomHeaderDirection = "request" + HeaderDirectionResponse CustomHeaderDirection = "response" +) + +type CustomHeader struct { + ID int64 + ProxyHostID int64 + Direction CustomHeaderDirection + Action CustomHeaderAction + Name string + Value string +} + +type HealthCheckType string + +const ( + HealthCheckTCP HealthCheckType = "tcp" + HealthCheckHTTP HealthCheckType = "http" + HealthCheckNone HealthCheckType = "none" +) + +type Backend struct { + ID int64 + ProxyHostID int64 + Host string + Port int + Weight int + Enabled bool + HealthCheckType HealthCheckType + HealthCheckPath string + HealthCheckStatus int + IntervalSeconds int + FailureThreshold int + RecoveryThreshold int + + // Runtime-only (not persisted) + Healthy bool `db:"-"` +} + +type StreamProtocol string + +const ( + StreamTCP StreamProtocol = "tcp" + StreamUDP StreamProtocol = "udp" +) + +type Stream struct { + ID int64 + Protocol StreamProtocol + ListenIP string + ListenPort int + DestinationHost string + DestinationPort int + ConnectTimeoutMS int + IdleTimeoutMS int + ProxyProtocol bool + Enabled bool + Comments string + CreatedAt time.Time + UpdatedAt time.Time +} + +type CertificateType string + +const ( + CertTypeLetsEncrypt CertificateType = "letsencrypt" + CertTypeCustom CertificateType = "custom" +) + +type ChallengeType string + +const ( + ChallengeHTTP01 ChallengeType = "http-01" + ChallengeDNS01 ChallengeType = "dns-01" +) + +type Certificate struct { + ID int64 + Name string + Type CertificateType + Domains []string + ChallengeType ChallengeType + DNSProvider string + CertPath string + KeyPath string + NotBefore *time.Time + NotAfter *time.Time + LastRenewedAt *time.Time + LastError string + AutoRenew bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type AccessList struct { + ID int64 + Name string + DefaultAllow bool + Comments string + CreatedAt time.Time + UpdatedAt time.Time + Rules []AccessRule + BasicAuthUsers []AccessListUser +} + +type AccessRuleAction string + +const ( + AccessAllow AccessRuleAction = "allow" + AccessDeny AccessRuleAction = "deny" +) + +type AccessRule struct { + ID int64 + AccessListID int64 + Action AccessRuleAction + CIDR string + Priority int +} + +type AccessListUser struct { + ID int64 + AccessListID int64 + Username string + PasswordHash string +} + +type RequestLog struct { + ID int64 + Timestamp time.Time + ProxyHostID *int64 + ProxyHostName string + ClientIP string + Method string + Host string + Path string + Status int + ResponseSize int64 + Backend string + ResponseTimeMS int64 + UserAgent string +} + +type AuditLog struct { + ID int64 + Timestamp time.Time + Actor string + Action string + Target string + Details string +} + +type Settings struct { + AdminUsername string + ACMEEmail string + ACMEStaging bool + ACMERenewalWindowDays int + CertStoragePath string + LogRetentionDays int + TrustedProxyCIDRs []string + BackupEnabled bool + BackupIntervalHours int + BackupRetentionCount int + BackupPath string +} diff --git a/internal/proxy/clientip.go b/internal/proxy/clientip.go new file mode 100644 index 0000000..af139ba --- /dev/null +++ b/internal/proxy/clientip.go @@ -0,0 +1,43 @@ +package proxy + +import ( + "net" + "net/http" + "strings" + + "github.com/owen/vibeproxy/internal/runtime" +) + +// ClientIP returns the real client IP for r. If the immediate peer is a +// trusted proxy (per snap.TrustedProxies), the right-most untrusted entry +// in X-Forwarded-For is used; otherwise X-Forwarded-For is ignored +// entirely and the TCP peer address is authoritative. This is the only +// place XFF is trusted, and only for configured networks. +func ClientIP(r *http.Request, snap *runtime.Snapshot) string { + peerIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + peerIP = r.RemoteAddr + } + ip := net.ParseIP(peerIP) + if ip == nil || snap == nil || !snap.IsTrustedProxy(ip) { + return peerIP + } + + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return peerIP + } + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(parts[i]) + cip := net.ParseIP(candidate) + if cip == nil { + continue + } + if snap.IsTrustedProxy(cip) { + continue // still a known proxy hop, keep walking left + } + return candidate + } + return peerIP +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go new file mode 100644 index 0000000..8714d5b --- /dev/null +++ b/internal/proxy/handler.go @@ -0,0 +1,193 @@ +package proxy + +import ( + "database/sql" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/owen/vibeproxy/internal/access" + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/logging" + "github.com/owen/vibeproxy/internal/metrics" + "github.com/owen/vibeproxy/internal/models" + "github.com/owen/vibeproxy/internal/runtime" +) + +// maxRequestBodyBytes bounds request bodies to protect the proxy itself; +// it is generous enough for large uploads while still being a hard limit. +const maxRequestBodyBytes = 5 << 30 // 5 GiB + +// Handler is the single entry point for both the HTTP and HTTPS listeners. +// It resolves the target proxy host, enforces access control and +// force-HTTPS/redirect rules, picks a healthy backend, and proxies the +// request — or serves an ACME HTTP-01 challenge. +type Handler struct { + store *runtime.Store + certMgr *certificates.Manager + reqLog *logging.RequestLogger + db *sql.DB + log *slog.Logger +} + +func NewHandler(store *runtime.Store, certMgr *certificates.Manager, reqLog *logging.RequestLogger, db *sql.DB, log *slog.Logger) *Handler { + return &Handler{store: store, certMgr: certMgr, reqLog: reqLog, db: db, log: log} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.certMgr.ServeChallenge(w, r) { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + + snap := h.store.Current() + rt := snap.LookupHost(r.Host) + if rt == nil { + http.Error(w, "404 - no proxy host configured for this domain", http.StatusNotFound) + return + } + if !rt.Model.Enabled { + http.Error(w, "404 - not found", http.StatusNotFound) + return + } + + clientIP := ClientIP(r, snap) + + if rt.AccessList != nil { + if !h.checkAccess(w, r, rt.AccessList, clientIP) { + return + } + } + + if rt.Model.ForceHTTPS && r.TLS == nil { + target := "https://" + normalizeHostHeader(r.Host) + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + return + } + + if rt.Model.Type == models.ProxyHostTypeRedirect { + h.serveRedirect(w, r, rt) + return + } + + h.serveProxy(w, r, rt, snap, clientIP) +} + +func (h *Handler) checkAccess(w http.ResponseWriter, r *http.Request, list *access.List, clientIP string) bool { + ip := parseIPLoose(clientIP) + if ip == nil || !list.Allowed(ip) { + http.Error(w, "403 - forbidden", http.StatusForbidden) + return false + } + if list.RequiresAuth() { + username, password, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) + http.Error(w, "401 - authentication required", http.StatusUnauthorized) + return false + } + hash, exists := list.Users[username] + if !exists { + w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) + http.Error(w, "401 - authentication required", http.StatusUnauthorized) + return false + } + valid, err := auth.VerifyPassword(password, hash) + if err != nil || !valid { + w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) + http.Error(w, "401 - authentication required", http.StatusUnauthorized) + return false + } + } + return true +} + +func (h *Handler) serveRedirect(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime) { + target := rt.Model.RedirectTargetURL + if target == "" { + http.Error(w, "502 - redirect host has no target configured", http.StatusBadGateway) + return + } + target = strings.TrimRight(target, "/") + if rt.Model.RedirectPreservePath { + target += r.URL.Path + } + if rt.Model.RedirectPreserveQS && r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + code := rt.Model.RedirectCode + switch code { + case 301, 302, 307, 308: + default: + code = http.StatusMovedPermanently + } + http.Redirect(w, r, target, code) +} + +func (h *Handler) serveProxy(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime, snap *runtime.Snapshot, clientIP string) { + if rt.Pool == nil { + http.Error(w, "502 - proxy host has no backends", http.StatusBadGateway) + return + } + backend, err := rt.Pool.Pick() + if err != nil { + h.log.Warn("no healthy backend", "proxy_host", rt.Model.Name, "err", err) + metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "503").Inc() + http.Error(w, "503 - no healthy backend available", http.StatusServiceUnavailable) + return + } + backend.Acquire() + defer backend.Release() + + metrics.ActiveConnections.WithLabelValues("http").Inc() + defer metrics.ActiveConnections.WithLabelValues("http").Dec() + + rp := buildReverseProxy(rt, backend, snap, h.log) + + readTimeout := time.Duration(rt.Model.ReadTimeoutMS) * time.Millisecond + if readTimeout <= 0 { + readTimeout = 60 * time.Second + } + ctx, cancel := withConnectTimeout(r.Context(), readTimeout) + defer cancel() + r = r.WithContext(ctx) + + rec := &statusRecorder{ResponseWriter: w} + start := time.Now() + rp.ServeHTTP(rec, r) + elapsed := time.Since(start) + + backendAddr := backend.Addr() + ":" + strconv.Itoa(backend.Model.Port) + metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, strconv.Itoa(rec.status)).Inc() + metrics.HTTPRequestDuration.WithLabelValues(rt.Model.Name).Observe(elapsed.Seconds()) + metrics.BackendRequestDuration.WithLabelValues(rt.Model.Name, backendAddr).Observe(elapsed.Seconds()) + metrics.BytesTransferred.WithLabelValues("out", "http").Add(float64(rec.bytes)) + + hostID := rt.Model.ID + h.reqLog.Log(models.RequestLog{ + Timestamp: start, + ProxyHostID: &hostID, + ProxyHostName: rt.Model.Name, + ClientIP: clientIP, + Method: r.Method, + Host: r.Host, + Path: r.URL.Path, + Status: rec.status, + ResponseSize: rec.bytes, + Backend: backendAddr, + ResponseTimeMS: elapsed.Milliseconds(), + UserAgent: r.UserAgent(), + }) +} + +func parseIPLoose(s string) net.IP { + if h, _, err := net.SplitHostPort(s); err == nil { + s = h + } + return net.ParseIP(s) +} diff --git a/internal/proxy/responsewriter.go b/internal/proxy/responsewriter.go new file mode 100644 index 0000000..14878c7 --- /dev/null +++ b/internal/proxy/responsewriter.go @@ -0,0 +1,55 @@ +package proxy + +import ( + "bufio" + "fmt" + "net" + "net/http" +) + +// statusRecorder wraps http.ResponseWriter to capture status code and +// bytes written for logging/metrics, while still supporting hijacking +// (required for WebSocket upgrades) and flushing (required for streaming +// responses / SSE). +type statusRecorder struct { + http.ResponseWriter + status int + bytes int64 + wroteHeader bool +} + +func (w *statusRecorder) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.status = code + w.wroteHeader = true + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusRecorder) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + n, err := w.ResponseWriter.Write(b) + w.bytes += int64(n) + return n, err +} + +func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hj, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking") + } + // A hijacked WebSocket connection never goes through WriteHeader, so + // treat it as a successful 101 for logging purposes. + w.status = http.StatusSwitchingProtocols + w.wroteHeader = true + return hj.Hijack() +} + +func (w *statusRecorder) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/internal/proxy/reverseproxy.go b/internal/proxy/reverseproxy.go new file mode 100644 index 0000000..d876fdb --- /dev/null +++ b/internal/proxy/reverseproxy.go @@ -0,0 +1,140 @@ +package proxy + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "strings" + "time" + + "github.com/owen/vibeproxy/internal/loadbalancer" + "github.com/owen/vibeproxy/internal/metrics" + "github.com/owen/vibeproxy/internal/models" + "github.com/owen/vibeproxy/internal/runtime" +) + +// sharedTransport is reused across every proxied request so backend +// connections are pooled (keep-alive) instead of dialed fresh each time. +// It supports HTTP/2 to upstreams that offer it via ALPN. +var sharedTransport = &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 200, + MaxIdleConnsPerHost: 50, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, +} + +// buildReverseProxy constructs a one-shot ReverseProxy for a single +// request/backend pair. Constructing it per request is cheap (no +// goroutines or connections are created here); the expensive parts — +// backend TCP/TLS connections — are pooled by sharedTransport across +// calls. +func buildReverseProxy(rt *runtime.ProxyHostRuntime, backend *loadbalancer.Backend, snap *runtime.Snapshot, log *slog.Logger) *httputil.ReverseProxy { + targetScheme := rt.Model.ForwardScheme + if targetScheme == "" { + targetScheme = "http" + } + targetAddr := net.JoinHostPort(backend.Model.Host, fmt.Sprintf("%d", backend.Model.Port)) + + director := func(req *http.Request) { + clientIP := ClientIP(req, snap) + priorFor := req.Header.Get("X-Forwarded-For") + + req.URL.Scheme = targetScheme + req.URL.Host = targetAddr + if !rt.Model.PreserveHostHeader { + req.Host = targetAddr + } + // else: leave req.Host as the original inbound Host header + + if priorFor == "" { + req.Header.Set("X-Forwarded-For", clientIP) + } else { + req.Header.Set("X-Forwarded-For", priorFor+", "+clientIP) + } + proto := "http" + if req.TLS != nil { + proto = "https" + } + req.Header.Set("X-Forwarded-Proto", proto) + if fh := req.Header.Get("X-Forwarded-Host"); fh == "" { + req.Header.Set("X-Forwarded-Host", req.Host) + } + req.Header.Set("X-Real-IP", clientIP) + + applyCustomHeaders(req.Header, rt.Model.CustomHeaders, models.HeaderDirectionRequest) + + // WebSocket upgrade headers must pass through untouched; Go's + // httputil.ReverseProxy handles the hijack/tunnel automatically + // once Connection: Upgrade is present, which we never strip. + if !rt.Model.WebsocketSupport { + req.Header.Del("Upgrade") + req.Header.Set("Connection", "close") + } + } + + modifyResponse := func(resp *http.Response) error { + applyCustomHeaders(resp.Header, rt.Model.CustomHeaders, models.HeaderDirectionResponse) + if rt.Model.HSTSEnabled && resp.Request.TLS != nil { + resp.Header.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + } + return nil + } + + errorHandler := func(w http.ResponseWriter, r *http.Request, err error) { + log.Warn("backend request failed", "proxy_host", rt.Model.Name, "backend", targetAddr, "err", err) + metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "502").Inc() + http.Error(w, "Bad Gateway", http.StatusBadGateway) + } + + return &httputil.ReverseProxy{ + Director: director, + ModifyResponse: modifyResponse, + ErrorHandler: errorHandler, + Transport: sharedTransport, + FlushInterval: 200 * time.Millisecond, // needed for SSE/streaming responses + } +} + +func applyCustomHeaders(h http.Header, headers []models.CustomHeader, direction models.CustomHeaderDirection) { + for _, c := range headers { + if c.Direction != direction { + continue + } + switch c.Action { + case models.HeaderActionSet: + h.Set(c.Name, c.Value) + case models.HeaderActionRemove: + h.Del(c.Name) + } + } +} + +// withConnectTimeout returns a context that bounds only the dial phase of +// an outbound request via a deadline; read/write timeouts are handled by +// the surrounding http.Server / http.ResponseController. +func withConnectTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if d <= 0 { + d = 5 * time.Second + } + deadline := time.Now().Add(d) + return context.WithDeadline(ctx, deadline) +} + +func normalizeHostHeader(host string) string { + if h, _, err := net.SplitHostPort(host); err == nil { + return strings.ToLower(h) + } + return strings.ToLower(host) +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go new file mode 100644 index 0000000..6fee6d0 --- /dev/null +++ b/internal/proxy/server.go @@ -0,0 +1,120 @@ +package proxy + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/logging" + "github.com/owen/vibeproxy/internal/metrics" + "github.com/owen/vibeproxy/internal/runtime" +) + +// Server owns the plain-HTTP and TLS listeners that serve all proxy hosts. +type Server struct { + httpSrv *http.Server + httpsSrv *http.Server + log *slog.Logger +} + +type Options struct { + HTTPAddr string // e.g. ":80" + HTTPSAddr string // e.g. ":443" +} + +func NewServer(opts Options, store *runtime.Store, certMgr *certificates.Manager, db *sql.DB, log *slog.Logger) *Server { + reqLog := logging.NewRequestLogger(db, log) + go reqLog.Run(context.Background()) + + handler := NewHandler(store, certMgr, reqLog, db, log) + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + NextProtos: []string{"h2", "http/1.1"}, + GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := certMgr.GetCertificateForDomain(hello.ServerName) + if err != nil { + metrics.TLSHandshakesTotal.WithLabelValues("error").Inc() + return nil, err + } + metrics.TLSHandshakesTotal.WithLabelValues("ok").Inc() + return cert, nil + }, + } + + httpAddr := opts.HTTPAddr + if httpAddr == "" { + httpAddr = ":80" + } + httpsAddr := opts.HTTPSAddr + if httpsAddr == "" { + httpsAddr = ":443" + } + + return &Server{ + log: log, + httpSrv: &http.Server{ + Addr: httpAddr, + Handler: handler, + ReadTimeout: 0, // per-request timeouts are enforced inside the handler + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), + }, + httpsSrv: &http.Server{ + Addr: httpsAddr, + Handler: handler, + TLSConfig: tlsConfig, + IdleTimeout: 120 * time.Second, + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), + }, + } +} + +// Start runs both listeners; it blocks until either fails or ctx is done. +func (s *Server) Start(ctx context.Context) error { + errCh := make(chan error, 2) + + go func() { + s.log.Info("http listener starting", "addr", s.httpSrv.Addr) + if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("http listener: %w", err) + } + }() + + go func() { + s.log.Info("https listener starting", "addr", s.httpsSrv.Addr) + // Certificates are served dynamically via GetCertificate, so we + // don't pass cert/key file paths here. + if err := s.httpsSrv.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("https listener: %w", err) + } + }() + + select { + case <-ctx.Done(): + return s.Shutdown(context.Background()) + case err := <-errCh: + s.Shutdown(context.Background()) + return err + } +} + +// Shutdown gracefully drains in-flight requests/connections before +// closing the listeners. +func (s *Server) Shutdown(ctx context.Context) error { + shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + err1 := s.httpSrv.Shutdown(shutdownCtx) + err2 := s.httpsSrv.Shutdown(shutdownCtx) + if err1 != nil { + return err1 + } + return err2 +} diff --git a/internal/runtime/store.go b/internal/runtime/store.go new file mode 100644 index 0000000..b518c67 --- /dev/null +++ b/internal/runtime/store.go @@ -0,0 +1,212 @@ +// Package runtime holds the active, in-memory configuration snapshot that +// the hot proxy/stream paths read. SQLite is only touched when the +// configuration changes; every request/connection reads this snapshot. +package runtime + +import ( + "context" + "crypto/tls" + "database/sql" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/owen/vibeproxy/internal/access" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/health" + "github.com/owen/vibeproxy/internal/loadbalancer" + "github.com/owen/vibeproxy/internal/models" +) + +// ProxyHostRuntime is one HTTP(S) proxy or redirect host, resolved and +// ready to serve traffic. +type ProxyHostRuntime struct { + Model models.ProxyHost + Pool *loadbalancer.Pool + AccessList *access.List // nil if none attached +} + +// StreamRuntime is one TCP/UDP stream, ready to serve traffic. +type StreamRuntime struct { + Model models.Stream +} + +// Snapshot is an immutable view of the whole active configuration. +type Snapshot struct { + HostsByDomain map[string]*ProxyHostRuntime // lowercase domain -> host + AccessLists map[int64]*access.List + Settings models.Settings + TrustedProxies []*net.IPNet +} + +func (s *Snapshot) LookupHost(hostHeader string) *ProxyHostRuntime { + name := hostHeader + if h, _, err := net.SplitHostPort(hostHeader); err == nil { + name = h + } + name = strings.ToLower(strings.TrimSuffix(name, ".")) + return s.HostsByDomain[name] +} + +func (s *Snapshot) IsTrustedProxy(ip net.IP) bool { + for _, n := range s.TrustedProxies { + if n.Contains(ip) { + return true + } + } + return false +} + +// Store owns the current Snapshot (read via atomic pointer for lock-free, +// allocation-free reads on the hot path) and knows how to rebuild it from +// the database. +type Store struct { + db *sql.DB + current atomic.Pointer[Snapshot] + + checker *health.Checker + watchCtx context.Context + watchStop context.CancelFunc + watchMu sync.Mutex + + onHealthChange func(hostID int64, backendAddr string, healthy bool) +} + +func NewStore(db *sql.DB) *Store { + s := &Store{db: db} + s.checker = health.NewChecker(s.handleHealthChange) + s.current.Store(&Snapshot{HostsByDomain: map[string]*ProxyHostRuntime{}, AccessLists: map[int64]*access.List{}}) + return s +} + +func (s *Store) Current() *Snapshot { return s.current.Load() } + +func (s *Store) SetHealthChangeCallback(f func(hostID int64, backendAddr string, healthy bool)) { + s.onHealthChange = f +} + +func (s *Store) handleHealthChange(hostID int64, b *loadbalancer.Backend, healthy bool) { + if s.onHealthChange != nil { + s.onHealthChange(hostID, net.JoinHostPort(b.Model.Host, strconv.Itoa(b.Model.Port)), healthy) + } +} + +// Reload rebuilds the snapshot from the database and atomically swaps it +// in. Existing in-flight requests keep using the previous snapshot object +// until they finish; new requests immediately see the new one. +func (s *Store) Reload(ctx context.Context) error { + hosts, err := database.ListProxyHosts(s.db) + if err != nil { + return err + } + accessLists, err := database.ListAccessLists(s.db) + if err != nil { + return err + } + settingsMap, err := database.AllSettings(s.db) + if err != nil { + return err + } + settings := settingsFromMap(settingsMap) + + compiledACLs := map[int64]*access.List{} + for _, a := range accessLists { + compiledACLs[a.ID] = access.Compile(a) + } + + var trusted []*net.IPNet + for _, c := range settings.TrustedProxyCIDRs { + if _, n, err := net.ParseCIDR(c); err == nil { + trusted = append(trusted, n) + } + } + + snap := &Snapshot{ + HostsByDomain: map[string]*ProxyHostRuntime{}, + AccessLists: compiledACLs, + Settings: settings, + TrustedProxies: trusted, + } + + // Stop the previous health-check watchers before starting new ones so + // we don't leak goroutines across reloads. + s.watchMu.Lock() + if s.watchStop != nil { + s.watchStop() + } + watchCtx, cancel := context.WithCancel(context.Background()) + s.watchCtx, s.watchStop = watchCtx, cancel + s.watchMu.Unlock() + + for _, h := range hosts { + if !h.Enabled { + continue + } + var pool *loadbalancer.Pool + if h.Type == models.ProxyHostTypeProxy { + backends := make([]*loadbalancer.Backend, 0, len(h.Backends)) + for _, bm := range h.Backends { + if !bm.Enabled { + continue + } + b := loadbalancer.NewBackend(bm) + backends = append(backends, b) + s.checker.Watch(watchCtx, h.ID, b) + } + pool = loadbalancer.NewPool(h.LoadBalancingMethod, backends) + } + var acl *access.List + if h.AccessListID != nil { + acl = compiledACLs[*h.AccessListID] + } + rt := &ProxyHostRuntime{Model: h, Pool: pool, AccessList: acl} + for _, d := range h.Domains { + snap.HostsByDomain[strings.ToLower(d)] = rt + } + } + + s.current.Store(snap) + return nil +} + +func settingsFromMap(m map[string]string) models.Settings { + s := models.Settings{ + AdminUsername: get(m, "admin_username", "admin"), + ACMEEmail: get(m, "acme_email", ""), + ACMEStaging: get(m, "acme_staging", "false") == "true", + ACMERenewalWindowDays: atoi(get(m, "acme_renewal_window_days", "30"), 30), + CertStoragePath: get(m, "cert_storage_path", "./data/certs"), + LogRetentionDays: atoi(get(m, "log_retention_days", "30"), 30), + BackupEnabled: get(m, "backup_enabled", "true") == "true", + BackupIntervalHours: atoi(get(m, "backup_interval_hours", "24"), 24), + BackupRetentionCount: atoi(get(m, "backup_retention_count", "7"), 7), + BackupPath: get(m, "backup_path", "./data/backups"), + } + if tp := get(m, "trusted_proxy_cidrs", ""); tp != "" { + s.TrustedProxyCIDRs = strings.Split(tp, ",") + } + return s +} + +func get(m map[string]string, k, def string) string { + if v, ok := m[k]; ok && v != "" { + return v + } + return def +} + +func atoi(s string, def int) int { + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// TLSCertLookup is implemented by the certificate manager and used for +// SNI-based certificate selection in the HTTPS listener. +type TLSCertLookup interface { + GetCertificateForDomain(domain string) (*tls.Certificate, error) +} diff --git a/internal/runtime/store_test.go b/internal/runtime/store_test.go new file mode 100644 index 0000000..44beb3f --- /dev/null +++ b/internal/runtime/store_test.go @@ -0,0 +1,46 @@ +package runtime + +import ( + "net" + "testing" + + "github.com/owen/vibeproxy/internal/models" +) + +func TestSnapshotLookupHost(t *testing.T) { + snap := &Snapshot{ + HostsByDomain: map[string]*ProxyHostRuntime{ + "example.com": {Model: models.ProxyHost{Name: "example"}}, + }, + } + if rt := snap.LookupHost("Example.com:8443"); rt == nil || rt.Model.Name != "example" { + t.Fatalf("expected case-insensitive, port-stripped lookup to match, got %+v", rt) + } + if rt := snap.LookupHost("other.com"); rt != nil { + t.Fatalf("expected no match for unknown host, got %+v", rt) + } +} + +func TestSnapshotTrustedProxy(t *testing.T) { + _, cidr, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + snap := &Snapshot{TrustedProxies: []*net.IPNet{cidr}} + if !snap.IsTrustedProxy(net.ParseIP("10.1.2.3")) { + t.Fatal("expected 10.1.2.3 to be trusted") + } + if snap.IsTrustedProxy(net.ParseIP("8.8.8.8")) { + t.Fatal("expected 8.8.8.8 not to be trusted") + } +} + +func TestSettingsFromMapDefaults(t *testing.T) { + s := settingsFromMap(map[string]string{}) + if s.LogRetentionDays != 30 { + t.Fatalf("expected default retention 30, got %d", s.LogRetentionDays) + } + if s.AdminUsername != "admin" { + t.Fatalf("expected default admin username, got %q", s.AdminUsername) + } +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..16680fe --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,125 @@ +// Package stats computes dashboard/API statistics from request logs and +// the live runtime snapshot. +package stats + +import ( + "database/sql" + "time" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" + "github.com/owen/vibeproxy/internal/runtime" +) + +type Dashboard struct { + ActiveProxyHosts int `json:"active_proxy_hosts"` + ActiveTCPStreams int `json:"active_tcp_streams"` + ActiveUDPStreams int `json:"active_udp_streams"` + RequestsLastHour int `json:"requests_last_hour"` + RequestsPerSecond float64 `json:"requests_per_second"` + TotalRequestsToday int `json:"total_requests_today"` + StatusBreakdown []database.StatusCount `json:"status_breakdown"` + BackendHealth []BackendHealthEntry `json:"backend_health"` + CertsExpiringSoon []models.Certificate `json:"certs_expiring_soon"` + TopHosts []database.HostCount `json:"top_hosts"` + TopClients []database.HostCount `json:"top_clients"` + RecentErrors []models.RequestLog `json:"recent_errors"` +} + +type BackendHealthEntry struct { + ProxyHost string `json:"proxy_host"` + Backend string `json:"backend"` + Healthy bool `json:"healthy"` +} + +func Compute(db *sql.DB, snap *runtime.Snapshot) (*Dashboard, error) { + d := &Dashboard{} + + seen := map[int64]bool{} + for _, rt := range snap.HostsByDomain { + if seen[rt.Model.ID] { + continue + } + seen[rt.Model.ID] = true + if rt.Model.Type == models.ProxyHostTypeProxy { + d.ActiveProxyHosts++ + } + if rt.Pool != nil { + for _, b := range rt.Pool.Backends { + d.BackendHealth = append(d.BackendHealth, BackendHealthEntry{ + ProxyHost: rt.Model.Name, + Backend: b.Model.Host, + Healthy: b.Healthy(), + }) + } + } + } + + streams, err := database.ListStreams(db) + if err != nil { + return nil, err + } + for _, s := range streams { + if !s.Enabled { + continue + } + if s.Protocol == models.StreamTCP { + d.ActiveTCPStreams++ + } else { + d.ActiveUDPStreams++ + } + } + + requestsLastHour, err := database.CountRequestLogsSince(db, "-1 hours") + if err != nil { + return nil, err + } + d.RequestsLastHour = requestsLastHour + d.RequestsPerSecond = float64(requestsLastHour) / 3600.0 + + totalToday, err := database.CountRequestLogsSince(db, "-24 hours") + if err != nil { + return nil, err + } + d.TotalRequestsToday = totalToday + + statuses, err := database.StatusBreakdownSince(db, "-24 hours") + if err != nil { + return nil, err + } + d.StatusBreakdown = statuses + + topHosts, err := database.TopHostsSince(db, "-24 hours", 10) + if err != nil { + return nil, err + } + d.TopHosts = topHosts + + topClients, err := database.TopClientsSince(db, "-24 hours", 10) + if err != nil { + return nil, err + } + d.TopClients = topClients + + recentErrors, err := database.RecentErrorsSince(db, "-24 hours", 20) + if err != nil { + return nil, err + } + d.RecentErrors = recentErrors + + certs, err := database.ListCertificates(db) + if err != nil { + return nil, err + } + for _, c := range certs { + if c.NotAfter == nil { + continue + } + days := int(c.NotAfter.Sub(time.Now()).Hours() / 24) + if days <= 30 { + d.CertsExpiringSoon = append(d.CertsExpiringSoon, c) + } + } + + return d, nil +} diff --git a/internal/streams/manager.go b/internal/streams/manager.go new file mode 100644 index 0000000..f22fea2 --- /dev/null +++ b/internal/streams/manager.go @@ -0,0 +1,113 @@ +package streams + +import ( + "context" + "database/sql" + "log/slog" + "reflect" + "sync" + + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/models" +) + +type running struct { + model models.Stream + cancel context.CancelFunc + tcp *TCPProxy + udp *UDPProxy +} + +// Manager owns the set of currently-listening TCP/UDP streams and can +// reconcile it against the database without disturbing streams that +// haven't changed, so existing connections on unaffected streams are never +// dropped by a reload. +type Manager struct { + db *sql.DB + log *slog.Logger + + mu sync.Mutex + active map[int64]*running // stream ID -> running listener + baseCtx context.Context +} + +func NewManager(db *sql.DB, log *slog.Logger) *Manager { + return &Manager{db: db, log: log, active: map[int64]*running{}, baseCtx: context.Background()} +} + +// Reload fetches the current stream configuration and starts/stops +// listeners to match it. Streams whose configuration is unchanged are left +// running untouched. +func (m *Manager) Reload(ctx context.Context) error { + streams, err := database.ListStreams(m.db) + if err != nil { + return err + } + desired := map[int64]models.Stream{} + for _, s := range streams { + if s.Enabled { + desired[s.ID] = s + } + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Stop streams that were removed or disabled. + for id, r := range m.active { + if _, ok := desired[id]; !ok { + m.stopLocked(id, r) + } + } + + for id, s := range desired { + if r, ok := m.active[id]; ok { + if reflect.DeepEqual(r.model, s) { + continue // unchanged, leave the listener (and its connections) alone + } + m.stopLocked(id, r) + } + m.startLocked(id, s) + } + return nil +} + +func (m *Manager) startLocked(id int64, s models.Stream) { + ctx, cancel := context.WithCancel(m.baseCtx) + r := &running{model: s, cancel: cancel} + var err error + switch s.Protocol { + case models.StreamTCP: + r.tcp = NewTCPProxy(s, m.log) + err = r.tcp.Start(ctx) + case models.StreamUDP: + r.udp = NewUDPProxy(s, m.log) + err = r.udp.Start(ctx) + } + if err != nil { + m.log.Error("failed to start stream", "id", id, "protocol", s.Protocol, "err", err) + cancel() + return + } + m.active[id] = r +} + +func (m *Manager) stopLocked(id int64, r *running) { + r.cancel() + if r.tcp != nil { + r.tcp.Stop() + } + if r.udp != nil { + r.udp.Stop() + } + delete(m.active, id) +} + +// StopAll shuts down every running stream (used on process shutdown). +func (m *Manager) StopAll() { + m.mu.Lock() + defer m.mu.Unlock() + for id, r := range m.active { + m.stopLocked(id, r) + } +} diff --git a/internal/streams/tcp.go b/internal/streams/tcp.go new file mode 100644 index 0000000..9378b1e --- /dev/null +++ b/internal/streams/tcp.go @@ -0,0 +1,162 @@ +// Package streams implements raw TCP and UDP proxying, independent of the +// HTTP reverse proxy, for arbitrary protocols (game servers, VPNs, etc). +package streams + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "strconv" + "sync" + "time" + + "github.com/owen/vibeproxy/internal/metrics" + "github.com/owen/vibeproxy/internal/models" +) + +// TCPProxy listens for TCP connections and forwards each to the +// configured destination, optionally prefixing the outbound connection +// with a PROXY protocol v1 header so the backend can see the real client +// address. +type TCPProxy struct { + model models.Stream + listener net.Listener + log *slog.Logger + wg sync.WaitGroup +} + +func NewTCPProxy(model models.Stream, log *slog.Logger) *TCPProxy { + return &TCPProxy{model: model, log: log.With("stream", streamLabel(model))} +} + +func streamLabel(m models.Stream) string { + return fmt.Sprintf("tcp:%s:%d", m.ListenIP, m.ListenPort) +} + +func (p *TCPProxy) Start(ctx context.Context) error { + addr := net.JoinHostPort(p.model.ListenIP, strconv.Itoa(p.model.ListenPort)) + lc := net.ListenConfig{} + ln, err := lc.Listen(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("listen %s: %w", addr, err) + } + p.listener = ln + p.log.Info("tcp stream listening", "addr", addr, "destination", fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort)) + + go func() { + <-ctx.Done() + ln.Close() + }() + + p.wg.Add(1) + go p.acceptLoop(ctx) + return nil +} + +func (p *TCPProxy) acceptLoop(ctx context.Context) { + defer p.wg.Done() + for { + conn, err := p.listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + p.log.Warn("accept error", "err", err) + return + } + } + p.wg.Add(1) + go func() { + defer p.wg.Done() + p.handleConn(ctx, conn) + }() + } +} + +func (p *TCPProxy) handleConn(ctx context.Context, client net.Conn) { + defer client.Close() + metrics.ActiveConnections.WithLabelValues("tcp").Inc() + defer metrics.ActiveConnections.WithLabelValues("tcp").Dec() + metrics.TCPConnectionsTotal.WithLabelValues(streamLabel(p.model)).Inc() + + dest := net.JoinHostPort(p.model.DestinationHost, strconv.Itoa(p.model.DestinationPort)) + connectTimeout := time.Duration(p.model.ConnectTimeoutMS) * time.Millisecond + if connectTimeout <= 0 { + connectTimeout = 5 * time.Second + } + d := net.Dialer{Timeout: connectTimeout} + backend, err := d.DialContext(ctx, "tcp", dest) + if err != nil { + p.log.Warn("backend dial failed", "dest", dest, "err", err) + return + } + defer backend.Close() + + if p.model.ProxyProtocol { + if err := writeProxyProtocolV1(backend, client.RemoteAddr(), backend.LocalAddr()); err != nil { + p.log.Warn("failed to write PROXY protocol header", "err", err) + return + } + } + + idleTimeout := time.Duration(p.model.IdleTimeoutMS) * time.Millisecond + if idleTimeout <= 0 { + idleTimeout = 5 * time.Minute + } + + var wg sync.WaitGroup + wg.Add(2) + pipe := func(dst, src net.Conn, direction string) { + defer wg.Done() + buf := make([]byte, 32*1024) + for { + src.SetReadDeadline(time.Now().Add(idleTimeout)) + n, err := src.Read(buf) + if n > 0 { + metrics.BytesTransferred.WithLabelValues(direction, "tcp").Add(float64(n)) + if _, werr := dst.Write(buf[:n]); werr != nil { + return + } + } + if err != nil { + if err != io.EOF { + if ne, ok := dst.(interface{ CloseWrite() error }); ok { + ne.CloseWrite() + } + } + return + } + } + } + go pipe(backend, client, "in") + go pipe(client, backend, "out") + wg.Wait() +} + +func (p *TCPProxy) Stop() { + if p.listener != nil { + p.listener.Close() + } + p.wg.Wait() +} + +// writeProxyProtocolV1 writes a human-readable PROXY protocol v1 header +// (as used by nginx/HAProxy) so the backend can recover the real client +// address even though the connection now originates from vibeproxy. +func writeProxyProtocolV1(w io.Writer, clientAddr, proxyAddr net.Addr) error { + ct, ok1 := clientAddr.(*net.TCPAddr) + pt, ok2 := proxyAddr.(*net.TCPAddr) + if !ok1 || !ok2 { + _, err := fmt.Fprintf(w, "PROXY UNKNOWN\r\n") + return err + } + family := "TCP4" + if ct.IP.To4() == nil { + family = "TCP6" + } + _, err := fmt.Fprintf(w, "PROXY %s %s %s %d %d\r\n", family, ct.IP.String(), pt.IP.String(), ct.Port, pt.Port) + return err +} diff --git a/internal/streams/tcp_test.go b/internal/streams/tcp_test.go new file mode 100644 index 0000000..db31dc5 --- /dev/null +++ b/internal/streams/tcp_test.go @@ -0,0 +1,96 @@ +package streams + +import ( + "context" + "io" + "log/slog" + "net" + "testing" + "time" + + "github.com/owen/vibeproxy/internal/models" +) + +func TestTCPProxyEndToEnd(t *testing.T) { + // Echo backend. + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backendLn.Close() + go func() { + for { + c, err := backendLn.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + io.Copy(c, c) + }() + } + }() + + backendAddr := backendLn.Addr().(*net.TCPAddr) + model := models.Stream{ + Protocol: models.StreamTCP, ListenIP: "127.0.0.1", ListenPort: 0, + DestinationHost: "127.0.0.1", DestinationPort: backendAddr.Port, + ConnectTimeoutMS: 2000, IdleTimeoutMS: 5000, + } + + // Bind an ephemeral port ourselves so we know it up front, matching + // what NewTCPProxy will do. + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + model.ListenPort = probe.Addr().(*net.TCPAddr).Port + probe.Close() + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + proxy := NewTCPProxy(model, log) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := proxy.Start(ctx); err != nil { + t.Fatal(err) + } + defer proxy.Stop() + + time.Sleep(50 * time.Millisecond) + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(model.ListenPort))) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 4) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatal(err) + } + if string(buf) != "ping" { + t.Fatalf("expected echo of ping, got %q", buf) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + if neg { + b = append([]byte{'-'}, b...) + } + return string(b) +} diff --git a/internal/streams/udp.go b/internal/streams/udp.go new file mode 100644 index 0000000..a59d705 --- /dev/null +++ b/internal/streams/udp.go @@ -0,0 +1,190 @@ +package streams + +import ( + "context" + "fmt" + "log/slog" + "net" + "sync" + "time" + + "github.com/owen/vibeproxy/internal/metrics" + "github.com/owen/vibeproxy/internal/models" +) + +// UDPProxy is a connectionless proxy that tracks per-client "sessions" (a +// backend UDP socket per source address) so replies are routed back to the +// correct client, with idle sessions reaped after the configured timeout. +type UDPProxy struct { + model models.Stream + log *slog.Logger + conn *net.UDPConn + + mu sync.Mutex + sessions map[string]*udpSession +} + +type udpSession struct { + clientAddr *net.UDPAddr + backend *net.UDPConn + lastActive time.Time +} + +func NewUDPProxy(model models.Stream, log *slog.Logger) *UDPProxy { + return &UDPProxy{model: model, log: log.With("stream", udpLabel(model)), sessions: map[string]*udpSession{}} +} + +func udpLabel(m models.Stream) string { return fmt.Sprintf("udp:%s:%d", m.ListenIP, m.ListenPort) } + +func (p *UDPProxy) Start(ctx context.Context) error { + addr := &net.UDPAddr{IP: net.ParseIP(p.model.ListenIP), Port: p.model.ListenPort} + if addr.IP == nil { + addr.IP = net.IPv4zero + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return fmt.Errorf("listen udp %s:%d: %w", p.model.ListenIP, p.model.ListenPort, err) + } + p.conn = conn + p.log.Info("udp stream listening", "addr", addr.String(), "destination", fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort)) + + go func() { + <-ctx.Done() + conn.Close() + }() + go p.reaper(ctx) + go p.readLoop(ctx) + return nil +} + +func (p *UDPProxy) idleTimeout() time.Duration { + d := time.Duration(p.model.IdleTimeoutMS) * time.Millisecond + if d <= 0 { + return 2 * time.Minute + } + return d +} + +func (p *UDPProxy) readLoop(ctx context.Context) { + buf := make([]byte, 65535) + dest := fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort) + destAddr, err := net.ResolveUDPAddr("udp", dest) + if err != nil { + p.log.Error("failed to resolve destination", "dest", dest, "err", err) + return + } + for { + n, clientAddr, err := p.conn.ReadFromUDP(buf) + if err != nil { + select { + case <-ctx.Done(): + return + default: + return + } + } + metrics.UDPPacketsTotal.WithLabelValues(udpLabel(p.model), "in").Inc() + metrics.BytesTransferred.WithLabelValues("in", "udp").Add(float64(n)) + + sess := p.getOrCreateSession(ctx, clientAddr, destAddr) + if sess == nil { + continue + } + if _, err := sess.backend.Write(buf[:n]); err != nil { + p.log.Warn("write to backend failed", "err", err) + } + } +} + +func (p *UDPProxy) getOrCreateSession(ctx context.Context, clientAddr, destAddr *net.UDPAddr) *udpSession { + key := clientAddr.String() + p.mu.Lock() + sess, ok := p.sessions[key] + if ok { + sess.lastActive = time.Now() + p.mu.Unlock() + return sess + } + p.mu.Unlock() + + backendConn, err := net.DialUDP("udp", nil, destAddr) + if err != nil { + p.log.Warn("failed to dial backend", "err", err) + return nil + } + sess = &udpSession{clientAddr: clientAddr, backend: backendConn, lastActive: time.Now()} + + p.mu.Lock() + p.sessions[key] = sess + p.mu.Unlock() + + metrics.ActiveConnections.WithLabelValues("udp").Inc() + + go p.backendReadLoop(ctx, key, sess) + return sess +} + +func (p *UDPProxy) backendReadLoop(ctx context.Context, key string, sess *udpSession) { + buf := make([]byte, 65535) + for { + sess.backend.SetReadDeadline(time.Now().Add(p.idleTimeout())) + n, err := sess.backend.Read(buf) + if err != nil { + p.closeSession(key) + return + } + sess.lastActive = time.Now() + metrics.UDPPacketsTotal.WithLabelValues(udpLabel(p.model), "out").Inc() + metrics.BytesTransferred.WithLabelValues("out", "udp").Add(float64(n)) + if _, err := p.conn.WriteToUDP(buf[:n], sess.clientAddr); err != nil { + return + } + } +} + +func (p *UDPProxy) closeSession(key string) { + p.mu.Lock() + sess, ok := p.sessions[key] + if ok { + delete(p.sessions, key) + } + p.mu.Unlock() + if ok { + sess.backend.Close() + metrics.ActiveConnections.WithLabelValues("udp").Dec() + } +} + +func (p *UDPProxy) reaper(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cutoff := time.Now().Add(-p.idleTimeout()) + p.mu.Lock() + for key, sess := range p.sessions { + if sess.lastActive.Before(cutoff) { + sess.backend.Close() + delete(p.sessions, key) + metrics.ActiveConnections.WithLabelValues("udp").Dec() + } + } + p.mu.Unlock() + } + } +} + +func (p *UDPProxy) Stop() { + if p.conn != nil { + p.conn.Close() + } + p.mu.Lock() + for key, sess := range p.sessions { + sess.backend.Close() + delete(p.sessions, key) + } + p.mu.Unlock() +} diff --git a/internal/web/accesslists.go b/internal/web/accesslists.go new file mode 100644 index 0000000..2e2f465 --- /dev/null +++ b/internal/web/accesslists.go @@ -0,0 +1,133 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/logging" + "github.com/owen/vibeproxy/internal/models" +) + +func (h *Web) handleAccessListsList(w http.ResponseWriter, r *http.Request) { + lists, err := database.ListAccessLists(h.db) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, "access_lists", PageData{ + Active: "access_lists", Title: "Access Lists", CSRFToken: h.csrf.Token(w), + Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"), + Data: lists, + }) +} + +func (h *Web) handleAccessListForm(w http.ResponseWriter, r *http.Request) { + var l models.AccessList + isNew := true + if idStr := r.PathValue("id"); idStr != "" { + isNew = false + id, _ := strconv.ParseInt(idStr, 10, 64) + existing, err := database.GetAccessList(h.db, id) + if err != nil { + http.Error(w, "access list not found", http.StatusNotFound) + return + } + l = *existing + } + render(w, "access_list_form", PageData{ + Active: "access_lists", Title: pageTitle(isNew, "Access List"), CSRFToken: h.csrf.Token(w), + Data: map[string]any{"List": l, "IsNew": isNew}, + }) +} + +func (h *Web) handleAccessListCreate(w http.ResponseWriter, r *http.Request) { + h.saveAccessListForm(w, r, 0) +} + +func (h *Web) handleAccessListUpdate(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + h.saveAccessListForm(w, r, id) +} + +func (h *Web) saveAccessListForm(w http.ResponseWriter, r *http.Request, id int64) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + l := models.AccessList{ + ID: id, + Name: r.FormValue("name"), + DefaultAllow: r.FormValue("default_allow") == "on", + Comments: r.FormValue("comments"), + } + actions := r.Form["rule_action"] + cidrs := r.Form["rule_cidr"] + for i := range cidrs { + if cidrs[i] == "" { + continue + } + l.Rules = append(l.Rules, models.AccessRule{ + Action: models.AccessRuleAction(get(actions, i)), + CIDR: cidrs[i], + Priority: i, + }) + } + existingHashes := map[string]string{} + if id != 0 { + if existing, err := database.GetAccessList(h.db, id); err == nil { + for _, u := range existing.BasicAuthUsers { + existingHashes[u.Username] = u.PasswordHash + } + } + } + usernames := r.Form["auth_username"] + passwords := r.Form["auth_password"] + for i := range usernames { + if usernames[i] == "" { + continue + } + hash := "" + if passwords[i] != "" { + h2, err := auth.HashPassword(passwords[i]) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + hash = h2 + } else if existing, ok := existingHashes[usernames[i]]; ok { + hash = existing // password left blank on edit: keep the existing hash + } else { + continue // new user with no password set — skip + } + l.BasicAuthUsers = append(l.BasicAuthUsers, models.AccessListUser{Username: usernames[i], PasswordHash: hash}) + } + if l.Name == "" { + render(w, "access_list_form", PageData{ + Active: "access_lists", Title: pageTitle(id == 0, "Access List"), CSRFToken: h.csrf.Token(w), + Flash: "Name is required", FlashKind: "danger", + Data: map[string]any{"List": l, "IsNew": id == 0}, + }) + return + } + if _, err := database.SaveAccessList(h.db, &l); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.reload(r) + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "access_list_changed", l.Name, "") + http.Redirect(w, r, "/access-lists?flash="+flashMsg("Access list saved")+"&kind=success", http.StatusFound) +} + +func (h *Web) handleAccessListDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := database.DeleteAccessList(h.db, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.reload(r) + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "access_list_changed", fmt.Sprintf("id=%d", id), "deleted") + http.Redirect(w, r, "/access-lists?flash="+flashMsg("Access list deleted")+"&kind=success", http.StatusFound) +} diff --git a/internal/web/apitokens.go b/internal/web/apitokens.go new file mode 100644 index 0000000..37d8cd4 --- /dev/null +++ b/internal/web/apitokens.go @@ -0,0 +1,57 @@ +package web + +import ( + "net/http" + "strconv" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/logging" +) + +func (h *Web) handleAPITokens(w http.ResponseWriter, r *http.Request) { + tokens, err := database.ListAPITokens(h.db) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, "api_tokens", PageData{ + Active: "api", Title: "API", CSRFToken: h.csrf.Token(w), + Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"), + Data: tokens, + }) +} + +func (h *Web) handleAPITokenCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := r.FormValue("name") + if name == "" { + http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Name is required")+"&kind=danger", http.StatusFound) + return + } + plaintext, hash, prefix, err := auth.GenerateAPIToken() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if _, err := database.CreateAPIToken(h.db, name, hash, prefix); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "token_created", name, "") + // Shown once: pass the plaintext token through the flash message. + http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Token created: "+plaintext+" (copy it now — it will not be shown again)")+"&kind=warning", http.StatusFound) +} + +func (h *Web) handleAPITokenRevoke(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := database.RevokeAPIToken(h.db, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "token_revoked", "", "") + http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Token revoked")+"&kind=success", http.StatusFound) +} diff --git a/internal/web/backends.go b/internal/web/backends.go new file mode 100644 index 0000000..b4c9646 --- /dev/null +++ b/internal/web/backends.go @@ -0,0 +1,34 @@ +package web + +import "net/http" + +type backendRow struct { + ProxyHostID int64 + ProxyHostName string + Host string + Port int + Weight int + Enabled bool + Healthy bool + HealthCheck string +} + +func (h *Web) handleBackendsList(w http.ResponseWriter, r *http.Request) { + snap := h.store.Current() + seen := map[int64]bool{} + var rows []backendRow + for _, rt := range snap.HostsByDomain { + if seen[rt.Model.ID] || rt.Pool == nil { + continue + } + seen[rt.Model.ID] = true + for _, b := range rt.Pool.Backends { + rows = append(rows, backendRow{ + ProxyHostID: rt.Model.ID, ProxyHostName: rt.Model.Name, + Host: b.Model.Host, Port: b.Model.Port, Weight: b.Model.Weight, + Enabled: b.Model.Enabled, Healthy: b.Healthy(), HealthCheck: string(b.Model.HealthCheckType), + }) + } + } + render(w, "backends", PageData{Active: "backends", Title: "Backends", Data: rows}) +} diff --git a/internal/web/certificates.go b/internal/web/certificates.go new file mode 100644 index 0000000..8a99249 --- /dev/null +++ b/internal/web/certificates.go @@ -0,0 +1,121 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/logging" + "github.com/owen/vibeproxy/internal/models" +) + +func (h *Web) handleCertificatesList(w http.ResponseWriter, r *http.Request) { + certs, err := database.ListCertificates(h.db) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, "certificates", PageData{ + Active: "certificates", Title: "Certificates", CSRFToken: h.csrf.Token(w), + Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"), + Data: certs, + }) +} + +func (h *Web) handleCertificateIssue(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := r.FormValue("name") + domains := splitLines(r.FormValue("domains")) + if name == "" || len(domains) == 0 { + h.redirectCertError(w, r, "Name and at least one domain are required") + return + } + settings := h.store.Current().Settings + email := r.FormValue("email") + if email == "" { + email = settings.ACMEEmail + } + challengeType := models.ChallengeType(r.FormValue("challenge_type")) + if challengeType == "" { + challengeType = models.ChallengeHTTP01 + } + cert, err := h.certs.IssueLetsEncrypt(r.Context(), certificates.IssueLetsEncryptOptions{ + Name: name, Domains: domains, Email: email, Staging: settings.ACMEStaging, + ChallengeType: challengeType, DNSProvider: r.FormValue("dns_provider"), + }) + if err != nil { + h.redirectCertError(w, r, "Issuance failed: "+err.Error()) + return + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_issued", cert.Name, fmt.Sprintf("domains=%v", cert.Domains)) + http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate issued")+"&kind=success", http.StatusFound) +} + +func (h *Web) handleCertificateUpload(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + name := r.FormValue("name") + domains := splitLines(r.FormValue("domains")) + certPEM := r.FormValue("cert_pem") + keyPEM := r.FormValue("key_pem") + if name == "" || certPEM == "" || keyPEM == "" { + h.redirectCertError(w, r, "Name, certificate, and private key are required") + return + } + cert, err := h.certs.UploadCustom(name, domains, []byte(certPEM), []byte(keyPEM)) + if err != nil { + h.redirectCertError(w, r, "Upload failed: "+err.Error()) + return + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_issued", cert.Name, "custom certificate uploaded") + http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate uploaded")+"&kind=success", http.StatusFound) +} + +func (h *Web) redirectCertError(w http.ResponseWriter, r *http.Request, msg string) { + http.Redirect(w, r, "/certificates?flash="+flashMsg(msg)+"&kind=danger", http.StatusFound) +} + +func (h *Web) handleCertificateRenew(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + c, err := database.GetCertificate(h.db, id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if c.Type != models.CertTypeLetsEncrypt { + h.redirectCertError(w, r, "Only Let's Encrypt certificates can be renewed here") + return + } + settings := h.store.Current().Settings + _, err = h.certs.IssueLetsEncrypt(r.Context(), certificates.IssueLetsEncryptOptions{ + Name: c.Name, Domains: c.Domains, Email: settings.ACMEEmail, Staging: settings.ACMEStaging, + ChallengeType: c.ChallengeType, DNSProvider: c.DNSProvider, + }) + if err != nil { + h.redirectCertError(w, r, "Renewal failed: "+err.Error()) + return + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_renewed", c.Name, "") + http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate renewed")+"&kind=success", http.StatusFound) +} + +func (h *Web) handleCertificateDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := database.DeleteCertificate(h.db, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.certs.LoadAll() + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_changed", fmt.Sprintf("id=%d", id), "deleted") + http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate deleted")+"&kind=success", http.StatusFound) +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go new file mode 100644 index 0000000..68d50b1 --- /dev/null +++ b/internal/web/dashboard.go @@ -0,0 +1,16 @@ +package web + +import ( + "net/http" + + "github.com/owen/vibeproxy/internal/stats" +) + +func (h *Web) handleDashboard(w http.ResponseWriter, r *http.Request) { + d, err := stats.Compute(h.db, h.store.Current()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, "dashboard", PageData{Active: "dashboard", Title: "Dashboard", Data: d}) +} diff --git a/internal/web/embed.go b/internal/web/embed.go new file mode 100644 index 0000000..b25fcb0 --- /dev/null +++ b/internal/web/embed.go @@ -0,0 +1,25 @@ +// Package web serves the server-rendered Bootstrap 5 admin UI: Go +// templates plus vanilla JS/CSS, all embedded into the binary. No +// frontend framework is used. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:templates +var templatesFS embed.FS + +//go:embed all:static +var staticFSRaw embed.FS + +// StaticFS returns the embedded static assets rooted at "static/..." so +// they can be served at /static/. +func StaticFS() fs.FS { + sub, err := fs.Sub(staticFSRaw, "static") + if err != nil { + panic(err) + } + return sub +} diff --git a/internal/web/logs.go b/internal/web/logs.go new file mode 100644 index 0000000..6d2750b --- /dev/null +++ b/internal/web/logs.go @@ -0,0 +1,55 @@ +package web + +import ( + "net/http" + + "github.com/owen/vibeproxy/internal/database" +) + +func (h *Web) handleRequestLogs(w http.ResponseWriter, r *http.Request) { + page := atoiDefault(r.URL.Query().Get("page"), 1) + if page < 1 { + page = 1 + } + const pageSize = 50 + f := database.LogFilter{ + Search: r.URL.Query().Get("q"), + Status: atoiDefault(r.URL.Query().Get("status"), 0), + Limit: pageSize, + Offset: (page - 1) * pageSize, + } + logs, total, err := database.QueryRequestLogs(h.db, f) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + totalPages := (total + pageSize - 1) / pageSize + if totalPages < 1 { + totalPages = 1 + } + render(w, "logs", PageData{ + Active: "logs", Title: "Request Logs", + Data: map[string]any{"Logs": logs, "Total": total, "Page": page, "TotalPages": totalPages, "Query": f.Search, "Status": f.Status}, + }) +} + +func (h *Web) handleAuditLogs(w http.ResponseWriter, r *http.Request) { + page := atoiDefault(r.URL.Query().Get("page"), 1) + if page < 1 { + page = 1 + } + const pageSize = 50 + logs, total, err := database.ListAuditLogs(h.db, pageSize, (page-1)*pageSize) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + totalPages := (total + pageSize - 1) / pageSize + if totalPages < 1 { + totalPages = 1 + } + render(w, "logs_audit", PageData{ + Active: "logs", Title: "Audit Log", + Data: map[string]any{"Logs": logs, "Total": total, "Page": page, "TotalPages": totalPages}, + }) +} diff --git a/internal/web/proxyhosts.go b/internal/web/proxyhosts.go new file mode 100644 index 0000000..bda3fa5 --- /dev/null +++ b/internal/web/proxyhosts.go @@ -0,0 +1,264 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/logging" + "github.com/owen/vibeproxy/internal/models" +) + +func (h *Web) handleProxyHostsList(w http.ResponseWriter, r *http.Request) { + hosts, err := database.ListProxyHosts(h.db) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + lists, _ := database.ListAccessLists(h.db) + certs, _ := database.ListCertificates(h.db) + render(w, "proxy_hosts", PageData{ + Active: "proxy_hosts", Title: "Proxy Hosts", CSRFToken: h.csrf.Token(w), + Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"), + Data: map[string]any{"Hosts": hosts, "AccessLists": lists, "Certificates": certs}, + }) +} + +type proxyHostFormData struct { + Host models.ProxyHost + AccessLists []models.AccessList + Certificates []models.Certificate + IsNew bool +} + +func (h *Web) handleProxyHostForm(w http.ResponseWriter, r *http.Request) { + var host models.ProxyHost + isNew := true + if idStr := r.PathValue("id"); idStr != "" { + isNew = false + id, _ := strconv.ParseInt(idStr, 10, 64) + existing, err := database.GetProxyHost(h.db, id) + if err != nil { + http.Error(w, "proxy host not found", http.StatusNotFound) + return + } + host = *existing + } else { + host = models.ProxyHost{ + Type: models.ProxyHostTypeProxy, ForwardScheme: "http", LoadBalancingMethod: "round_robin", + ConnectTimeoutMS: 5000, ReadTimeoutMS: 60000, WriteTimeoutMS: 60000, IdleTimeoutMS: 120000, + WebsocketSupport: true, Enabled: true, RedirectCode: 301, RedirectPreservePath: true, RedirectPreserveQS: true, + Backends: []models.Backend{{Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckTCP, HealthCheckPath: "/", HealthCheckStatus: 200, IntervalSeconds: 10, FailureThreshold: 3, RecoveryThreshold: 2}}, + } + } + lists, _ := database.ListAccessLists(h.db) + certs, _ := database.ListCertificates(h.db) + render(w, "proxy_host_form", PageData{ + Active: "proxy_hosts", Title: pageTitle(isNew, "Proxy Host"), CSRFToken: h.csrf.Token(w), + Data: proxyHostFormData{Host: host, AccessLists: lists, Certificates: certs, IsNew: isNew}, + }) +} + +func pageTitle(isNew bool, kind string) string { + if isNew { + return "New " + kind + } + return "Edit " + kind +} + +func (h *Web) handleProxyHostCreate(w http.ResponseWriter, r *http.Request) { + h.saveProxyHostFromForm(w, r, 0) +} + +func (h *Web) handleProxyHostUpdate(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + h.saveProxyHostFromForm(w, r, id) +} + +func (h *Web) saveProxyHostFromForm(w http.ResponseWriter, r *http.Request, id int64) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + host := parseProxyHostForm(r, id) + if err := validateProxyHostForm(&host); err != nil { + lists, _ := database.ListAccessLists(h.db) + certs, _ := database.ListCertificates(h.db) + render(w, "proxy_host_form", PageData{ + Active: "proxy_hosts", Title: pageTitle(id == 0, "Proxy Host"), CSRFToken: h.csrf.Token(w), + Flash: err.Error(), FlashKind: "danger", + Data: proxyHostFormData{Host: host, AccessLists: lists, Certificates: certs, IsNew: id == 0}, + }) + return + } + if _, err := database.SaveProxyHost(h.db, &host); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.reload(r) + action := "proxy_changed" + if id == 0 { + action = "proxy_created" + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), action, host.Name, fmt.Sprintf("domains=%v", host.Domains)) + http.Redirect(w, r, "/proxy-hosts?flash="+flashMsg("Proxy host saved")+"&kind=success", http.StatusFound) +} + +func flashMsg(s string) string { return strings.ReplaceAll(s, " ", "+") } + +func parseProxyHostForm(r *http.Request, id int64) models.ProxyHost { + h := models.ProxyHost{ + ID: id, + Name: r.FormValue("name"), + Type: models.ProxyHostType(r.FormValue("type")), + ListenAddr: r.FormValue("listen_addr"), + ForwardScheme: r.FormValue("forward_scheme"), + PreserveHostHeader: r.FormValue("preserve_host_header") == "on", + WebsocketSupport: r.FormValue("websocket_support") == "on", + ForceHTTPS: r.FormValue("force_https") == "on", + HSTSEnabled: r.FormValue("hsts_enabled") == "on", + ConnectTimeoutMS: atoiDefault(r.FormValue("connect_timeout_ms"), 5000), + ReadTimeoutMS: atoiDefault(r.FormValue("read_timeout_ms"), 60000), + WriteTimeoutMS: atoiDefault(r.FormValue("write_timeout_ms"), 60000), + IdleTimeoutMS: atoiDefault(r.FormValue("idle_timeout_ms"), 120000), + LoadBalancingMethod: r.FormValue("load_balancing_method"), + RedirectTargetURL: r.FormValue("redirect_target_url"), + RedirectCode: atoiDefault(r.FormValue("redirect_code"), 301), + RedirectPreservePath: r.FormValue("redirect_preserve_path") == "on", + RedirectPreserveQS: r.FormValue("redirect_preserve_qs") == "on", + Enabled: r.FormValue("enabled") == "on", + Comments: r.FormValue("comments"), + } + for _, d := range splitLines(r.FormValue("domains")) { + h.Domains = append(h.Domains, strings.ToLower(d)) + } + if v := r.FormValue("certificate_id"); v != "" && v != "0" { + cid, _ := strconv.ParseInt(v, 10, 64) + h.CertificateID = &cid + } + if v := r.FormValue("access_list_id"); v != "" && v != "0" { + aid, _ := strconv.ParseInt(v, 10, 64) + h.AccessListID = &aid + } + + // Backends: parallel arrays backend_host[], backend_port[], etc. + hosts := r.Form["backend_host"] + ports := r.Form["backend_port"] + weights := r.Form["backend_weight"] + enabledFlags := r.Form["backend_enabled"] // hidden input per row, always "true"/"false" + hcTypes := r.Form["backend_hc_type"] + hcPaths := r.Form["backend_hc_path"] + hcStatuses := r.Form["backend_hc_status"] + hcIntervals := r.Form["backend_hc_interval"] + hcFail := r.Form["backend_hc_fail"] + hcRecover := r.Form["backend_hc_recover"] + for i := range hosts { + if strings.TrimSpace(hosts[i]) == "" { + continue + } + b := models.Backend{ + Host: hosts[i], + Port: atoiDefault(get(ports, i), 80), + Weight: atoiDefault(get(weights, i), 1), + Enabled: get(enabledFlags, i) == "true", + HealthCheckType: models.HealthCheckType(get(hcTypes, i)), + HealthCheckPath: get(hcPaths, i), + HealthCheckStatus: atoiDefault(get(hcStatuses, i), 200), + IntervalSeconds: atoiDefault(get(hcIntervals, i), 10), + FailureThreshold: atoiDefault(get(hcFail, i), 3), + RecoveryThreshold: atoiDefault(get(hcRecover, i), 2), + } + h.Backends = append(h.Backends, b) + } + + // Custom headers: parallel arrays. + hDir := r.Form["header_direction"] + hAction := r.Form["header_action"] + hName := r.Form["header_name"] + hValue := r.Form["header_value"] + for i := range hName { + if strings.TrimSpace(hName[i]) == "" { + continue + } + h.CustomHeaders = append(h.CustomHeaders, models.CustomHeader{ + Direction: models.CustomHeaderDirection(get(hDir, i)), + Action: models.CustomHeaderAction(get(hAction, i)), + Name: hName[i], + Value: get(hValue, i), + }) + } + + return h +} + +func get(s []string, i int) string { + if i < len(s) { + return s[i] + } + return "" +} + +func atoiDefault(s string, def int) int { + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +func splitLines(s string) []string { + var out []string + for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, line) + } + } + return out +} + +func validateProxyHostForm(h *models.ProxyHost) error { + if h.Name == "" { + return fmt.Errorf("name is required") + } + if len(h.Domains) == 0 { + return fmt.Errorf("at least one domain is required") + } + if h.Type == models.ProxyHostTypeProxy && len(h.Backends) == 0 { + return fmt.Errorf("at least one backend is required") + } + if h.Type == models.ProxyHostTypeRedirect && h.RedirectTargetURL == "" { + return fmt.Errorf("redirect target URL is required") + } + return nil +} + +func (h *Web) handleProxyHostDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := database.DeleteProxyHost(h.db, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.reload(r) + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "proxy_changed", fmt.Sprintf("id=%d", id), "deleted") + http.Redirect(w, r, "/proxy-hosts?flash="+flashMsg("Proxy host deleted")+"&kind=success", http.StatusFound) +} + +func (h *Web) handleProxyHostToggle(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + existing, err := database.GetProxyHost(h.db, id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := database.SetProxyHostEnabled(h.db, id, !existing.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.reload(r) + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "proxy_changed", existing.Name, fmt.Sprintf("enabled=%v", !existing.Enabled)) + http.Redirect(w, r, "/proxy-hosts", http.StatusFound) +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..9631786 --- /dev/null +++ b/internal/web/render.go @@ -0,0 +1,99 @@ +package web + +import ( + "fmt" + "html/template" + "net/http" + "strings" + "time" +) + +var funcMap = template.FuncMap{ + "join": func(items []string, sep string) string { return strings.Join(items, sep) }, + "fmtTime": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02 15:04:05") + }, + "timeAgo": timeAgo, + "deref": func(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t + }, + "deref64": func(v *int64) int64 { + if v == nil { + return 0 + } + return *v + }, + "slice": func(items ...int) []int { return items }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b int) int { return a * b }, + "iterate": func(n int) []int { + out := make([]int, n) + for i := range out { + out[i] = i + } + return out + }, + "statusClass": func(status int) string { + switch { + case status >= 500: + return "text-danger" + case status >= 400: + return "text-warning" + case status >= 300: + return "text-info" + default: + return "text-success" + } + }, +} + +func timeAgo(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +var templates *template.Template + +func init() { + t, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html") + if err != nil { + panic(err) + } + templates = t +} + +// PageData is the common envelope passed to every page template. +type PageData struct { + Active string // nav item key for sidebar highlighting + Title string + CSRFToken string + Flash string + FlashKind string // success|danger|warning + Data any +} + +func render(w http.ResponseWriter, name string, data PageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/web/router.go b/internal/web/router.go new file mode 100644 index 0000000..390b967 --- /dev/null +++ b/internal/web/router.go @@ -0,0 +1,97 @@ +package web + +import ( + "database/sql" + "log/slog" + "net/http" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/certificates" + "github.com/owen/vibeproxy/internal/runtime" + "github.com/owen/vibeproxy/internal/streams" +) + +type Web struct { + db *sql.DB + store *runtime.Store + certs *certificates.Manager + streams *streams.Manager + csrf *auth.CSRF + log *slog.Logger +} + +// NewRouter builds the full server-rendered admin UI, protected by HTTP +// Basic Auth against the single administrator account. +func NewRouter(db *sql.DB, store *runtime.Store, certs *certificates.Manager, streamMgr *streams.Manager, csrf *auth.CSRF, log *slog.Logger) *http.ServeMux { + web := &Web{db: db, store: store, certs: certs, streams: streamMgr, csrf: csrf, log: log} + mux := http.NewServeMux() + + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS())))) + + protected := http.NewServeMux() + protected.HandleFunc("GET /{$}", web.handleRoot) + protected.HandleFunc("GET /dashboard", web.handleDashboard) + + protected.HandleFunc("GET /proxy-hosts", web.handleProxyHostsList) + protected.HandleFunc("GET /proxy-hosts/new", web.handleProxyHostForm) + protected.HandleFunc("POST /proxy-hosts/new", web.handleProxyHostCreate) + protected.HandleFunc("GET /proxy-hosts/{id}/edit", web.handleProxyHostForm) + protected.HandleFunc("POST /proxy-hosts/{id}/edit", web.handleProxyHostUpdate) + protected.HandleFunc("POST /proxy-hosts/{id}/delete", web.handleProxyHostDelete) + protected.HandleFunc("POST /proxy-hosts/{id}/toggle", web.handleProxyHostToggle) + + protected.HandleFunc("GET /streams", web.handleStreamsList) + protected.HandleFunc("GET /streams/new", web.handleStreamForm) + protected.HandleFunc("POST /streams/new", web.handleStreamCreate) + protected.HandleFunc("GET /streams/{id}/edit", web.handleStreamForm) + protected.HandleFunc("POST /streams/{id}/edit", web.handleStreamUpdate) + protected.HandleFunc("POST /streams/{id}/delete", web.handleStreamDelete) + protected.HandleFunc("POST /streams/{id}/toggle", web.handleStreamToggle) + + protected.HandleFunc("GET /backends", web.handleBackendsList) + + protected.HandleFunc("GET /certificates", web.handleCertificatesList) + protected.HandleFunc("POST /certificates/letsencrypt", web.handleCertificateIssue) + protected.HandleFunc("POST /certificates/custom", web.handleCertificateUpload) + protected.HandleFunc("POST /certificates/{id}/renew", web.handleCertificateRenew) + protected.HandleFunc("POST /certificates/{id}/delete", web.handleCertificateDelete) + + protected.HandleFunc("GET /access-lists", web.handleAccessListsList) + protected.HandleFunc("GET /access-lists/new", web.handleAccessListForm) + protected.HandleFunc("POST /access-lists/new", web.handleAccessListCreate) + protected.HandleFunc("GET /access-lists/{id}/edit", web.handleAccessListForm) + protected.HandleFunc("POST /access-lists/{id}/edit", web.handleAccessListUpdate) + protected.HandleFunc("POST /access-lists/{id}/delete", web.handleAccessListDelete) + + protected.HandleFunc("GET /logs", web.handleRequestLogs) + protected.HandleFunc("GET /logs/audit", web.handleAuditLogs) + + protected.HandleFunc("GET /settings", web.handleSettings) + protected.HandleFunc("POST /settings", web.handleSettingsUpdate) + protected.HandleFunc("POST /settings/password", web.handlePasswordChange) + protected.HandleFunc("POST /settings/backup", web.handleBackupNow) + + protected.HandleFunc("GET /api-tokens", web.handleAPITokens) + protected.HandleFunc("POST /api-tokens/new", web.handleAPITokenCreate) + protected.HandleFunc("POST /api-tokens/{id}/revoke", web.handleAPITokenRevoke) + + mux.Handle("/", auth.BasicAuthAdmin(db)(csrf.Protect(protected))) + + return mux +} + +func (h *Web) handleRoot(rw http.ResponseWriter, r *http.Request) { + http.Redirect(rw, r, "/dashboard", http.StatusFound) +} + +func (h *Web) reload(r *http.Request) { + if err := h.store.Reload(r.Context()); err != nil { + h.log.Error("config reload failed", "err", err) + } +} + +func (h *Web) reloadStreams(r *http.Request) { + if err := h.streams.Reload(r.Context()); err != nil { + h.log.Error("stream reload failed", "err", err) + } +} diff --git a/internal/web/settings.go b/internal/web/settings.go new file mode 100644 index 0000000..0feabfd --- /dev/null +++ b/internal/web/settings.go @@ -0,0 +1,103 @@ +package web + +import ( + "net/http" + "strings" + + "github.com/owen/vibeproxy/internal/auth" + "github.com/owen/vibeproxy/internal/backup" + "github.com/owen/vibeproxy/internal/database" + "github.com/owen/vibeproxy/internal/logging" +) + +func (h *Web) handleSettings(w http.ResponseWriter, r *http.Request) { + s := h.store.Current().Settings + render(w, "settings", PageData{ + Active: "settings", Title: "Settings", CSRFToken: h.csrf.Token(w), + Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"), + Data: s, + }) +} + +func (h *Web) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + kv := map[string]string{ + "acme_email": r.FormValue("acme_email"), + "acme_staging": boolStr(r.FormValue("acme_staging") == "on"), + "acme_renewal_window_days": r.FormValue("acme_renewal_window_days"), + "cert_storage_path": r.FormValue("cert_storage_path"), + "log_retention_days": r.FormValue("log_retention_days"), + "trusted_proxy_cidrs": strings.Join(splitLines(r.FormValue("trusted_proxy_cidrs")), ","), + "backup_enabled": boolStr(r.FormValue("backup_enabled") == "on"), + "backup_interval_hours": r.FormValue("backup_interval_hours"), + "backup_retention_count": r.FormValue("backup_retention_count"), + "backup_path": r.FormValue("backup_path"), + } + for k, v := range kv { + if err := database.SetSetting(h.db, k, v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + h.reload(r) + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "settings_changed", "settings", "") + http.Redirect(w, r, "/settings?flash="+flashMsg("Settings saved")+"&kind=success", http.StatusFound) +} + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + username := auth.ActorFromContext(r.Context()) + current := r.FormValue("current_password") + newPass := r.FormValue("new_password") + confirm := r.FormValue("confirm_password") + + user, err := database.GetUserByUsername(h.db, username) + if err != nil { + http.Error(w, "user not found", http.StatusInternalServerError) + return + } + ok, _ := auth.VerifyPassword(current, user.PasswordHash) + if !ok { + http.Redirect(w, r, "/settings?flash="+flashMsg("Current password is incorrect")+"&kind=danger", http.StatusFound) + return + } + if len(newPass) < 8 || newPass != confirm { + http.Redirect(w, r, "/settings?flash="+flashMsg("New passwords must match and be at least 8 characters")+"&kind=danger", http.StatusFound) + return + } + hash, err := auth.HashPassword(newPass) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := database.UpdateUserPassword(h.db, username, hash, false); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + logging.Audit(h.db, username, "password_changed", username, "") + http.Redirect(w, r, "/settings?flash="+flashMsg("Password changed")+"&kind=success", http.StatusFound) +} + +func (h *Web) handleBackupNow(w http.ResponseWriter, r *http.Request) { + s := h.store.Current().Settings + path, err := backup.Run(h.db, s.BackupPath, s.BackupRetentionCount) + if err != nil { + http.Redirect(w, r, "/settings?flash="+flashMsg("Backup failed: "+err.Error())+"&kind=danger", http.StatusFound) + return + } + logging.Audit(h.db, auth.ActorFromContext(r.Context()), "settings_changed", "backup", "manual backup: "+path) + http.Redirect(w, r, "/settings?flash="+flashMsg("Backup created: "+path)+"&kind=success", http.StatusFound) +} diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css new file mode 100644 index 0000000..df2581c --- /dev/null +++ b/internal/web/static/css/app.css @@ -0,0 +1,118 @@ +:root { + --vp-sidebar-w: 240px; +} + +body { + background-color: #f4f6f9; +} + +.vp-shell { + display: flex; + min-height: 100vh; +} + +.vp-sidebar { + width: var(--vp-sidebar-w); + flex-shrink: 0; + background-color: #1b2430; + color: #cfd6e2; + position: sticky; + top: 0; + height: 100vh; +} + +.vp-brand { + color: #fff; + text-decoration: none; + font-weight: 600; + font-size: 1.1rem; + padding: 1.1rem 1.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.vp-brand i { + color: #4dabf7; +} + +.vp-nav { + padding: 0.75rem 0.5rem; + flex: 1; +} + +.vp-nav .nav-link { + color: #a9b4c4; + border-radius: 0.4rem; + padding: 0.55rem 0.85rem; + margin-bottom: 0.15rem; + font-size: 0.92rem; +} + +.vp-nav .nav-link i { + width: 1.25rem; + display: inline-block; + text-align: center; + margin-right: 0.4rem; +} + +.vp-nav .nav-link:hover { + background-color: rgba(255, 255, 255, 0.06); + color: #fff; +} + +.vp-nav .nav-link.active { + background-color: #2c5cc5; + color: #fff; +} + +.vp-sidebar-footer { + padding: 0.85rem 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.vp-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.vp-topbar { + background: #fff; + border-bottom: 1px solid #e5e9f0; + padding: 0.9rem 1.5rem; +} + +.vp-content { + padding: 1.5rem; + flex: 1; +} + +.vp-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.5rem; +} + +.vp-chip { + font-weight: 500; + padding: 0.4rem 0.6rem; +} + +.stat-card { + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.stat-card .stat-value { + font-size: 1.7rem; + font-weight: 700; +} + +.table-hover tbody tr { + cursor: default; +} + +code.vp-token { + word-break: break-all; +} diff --git a/internal/web/static/css/bootstrap-icons.min.css b/internal/web/static/css/bootstrap-icons.min.css new file mode 100644 index 0000000..dadd6dc --- /dev/null +++ b/internal/web/static/css/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),url("fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"}.bi-alphabet-uppercase::before{content:"\f2a5"}.bi-alphabet::before{content:"\f68a"}.bi-amazon::before{content:"\f68d"}.bi-arrows-collapse-vertical::before{content:"\f690"}.bi-arrows-expand-vertical::before{content:"\f695"}.bi-arrows-vertical::before{content:"\f698"}.bi-arrows::before{content:"\f6a2"}.bi-ban-fill::before{content:"\f6a3"}.bi-ban::before{content:"\f6b6"}.bi-bing::before{content:"\f6c2"}.bi-cake::before{content:"\f6e0"}.bi-cake2::before{content:"\f6ed"}.bi-cookie::before{content:"\f6ee"}.bi-copy::before{content:"\f759"}.bi-crosshair::before{content:"\f769"}.bi-crosshair2::before{content:"\f794"}.bi-emoji-astonished-fill::before{content:"\f795"}.bi-emoji-astonished::before{content:"\f79a"}.bi-emoji-grimace-fill::before{content:"\f79b"}.bi-emoji-grimace::before{content:"\f7a0"}.bi-emoji-grin-fill::before{content:"\f7a1"}.bi-emoji-grin::before{content:"\f7a6"}.bi-emoji-surprise-fill::before{content:"\f7a7"}.bi-emoji-surprise::before{content:"\f7ac"}.bi-emoji-tear-fill::before{content:"\f7ad"}.bi-emoji-tear::before{content:"\f7b2"}.bi-envelope-arrow-down-fill::before{content:"\f7b3"}.bi-envelope-arrow-down::before{content:"\f7b8"}.bi-envelope-arrow-up-fill::before{content:"\f7b9"}.bi-envelope-arrow-up::before{content:"\f7be"}.bi-feather::before{content:"\f7bf"}.bi-feather2::before{content:"\f7c4"}.bi-floppy-fill::before{content:"\f7c5"}.bi-floppy::before{content:"\f7d8"}.bi-floppy2-fill::before{content:"\f7d9"}.bi-floppy2::before{content:"\f7e4"}.bi-gitlab::before{content:"\f7e5"}.bi-highlighter::before{content:"\f7f8"}.bi-marker-tip::before{content:"\f802"}.bi-nvme-fill::before{content:"\f803"}.bi-nvme::before{content:"\f80c"}.bi-opencollective::before{content:"\f80d"}.bi-pci-card-network::before{content:"\f8cd"}.bi-pci-card-sound::before{content:"\f8ce"}.bi-radar::before{content:"\f8cf"}.bi-send-arrow-down-fill::before{content:"\f8d0"}.bi-send-arrow-down::before{content:"\f8d1"}.bi-send-arrow-up-fill::before{content:"\f8d2"}.bi-send-arrow-up::before{content:"\f8d3"}.bi-sim-slash-fill::before{content:"\f8d4"}.bi-sim-slash::before{content:"\f8d5"}.bi-sourceforge::before{content:"\f8d6"}.bi-substack::before{content:"\f8d7"}.bi-threads-fill::before{content:"\f8d8"}.bi-threads::before{content:"\f8d9"}.bi-transparency::before{content:"\f8da"}.bi-twitter-x::before{content:"\f8db"}.bi-type-h4::before{content:"\f8dc"}.bi-type-h5::before{content:"\f8dd"}.bi-type-h6::before{content:"\f8de"}.bi-backpack-fill::before{content:"\f8df"}.bi-backpack::before{content:"\f8e0"}.bi-backpack2-fill::before{content:"\f8e1"}.bi-backpack2::before{content:"\f8e2"}.bi-backpack3-fill::before{content:"\f8e3"}.bi-backpack3::before{content:"\f8e4"}.bi-backpack4-fill::before{content:"\f8e5"}.bi-backpack4::before{content:"\f8e6"}.bi-brilliance::before{content:"\f8e7"}.bi-cake-fill::before{content:"\f8e8"}.bi-cake2-fill::before{content:"\f8e9"}.bi-duffle-fill::before{content:"\f8ea"}.bi-duffle::before{content:"\f8eb"}.bi-exposure::before{content:"\f8ec"}.bi-gender-neuter::before{content:"\f8ed"}.bi-highlights::before{content:"\f8ee"}.bi-luggage-fill::before{content:"\f8ef"}.bi-luggage::before{content:"\f8f0"}.bi-mailbox-flag::before{content:"\f8f1"}.bi-mailbox2-flag::before{content:"\f8f2"}.bi-noise-reduction::before{content:"\f8f3"}.bi-passport-fill::before{content:"\f8f4"}.bi-passport::before{content:"\f8f5"}.bi-person-arms-up::before{content:"\f8f6"}.bi-person-raised-hand::before{content:"\f8f7"}.bi-person-standing-dress::before{content:"\f8f8"}.bi-person-standing::before{content:"\f8f9"}.bi-person-walking::before{content:"\f8fa"}.bi-person-wheelchair::before{content:"\f8fb"}.bi-shadows::before{content:"\f8fc"}.bi-suitcase-fill::before{content:"\f8fd"}.bi-suitcase-lg-fill::before{content:"\f8fe"}.bi-suitcase-lg::before{content:"\f8ff"}.bi-suitcase::before{content:"\f900"}.bi-suitcase2-fill::before{content:"\f901"}.bi-suitcase2::before{content:"\f902"}.bi-vignette::before{content:"\f903"} \ No newline at end of file diff --git a/internal/web/static/css/bootstrap.min.css b/internal/web/static/css/bootstrap.min.css new file mode 100644 index 0000000..3993414 --- /dev/null +++ b/internal/web/static/css/bootstrap.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/internal/web/static/css/fonts/bootstrap-icons.woff2 b/internal/web/static/css/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000..92c4830 Binary files /dev/null and b/internal/web/static/css/fonts/bootstrap-icons.woff2 differ diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js new file mode 100644 index 0000000..480085e --- /dev/null +++ b/internal/web/static/js/app.js @@ -0,0 +1,104 @@ +// vibeproxy admin UI — minimal vanilla JS, no framework. +(function () { + "use strict"; + + // Generic delete-confirmation modal. Any element with + // data-confirm-delete-form="
" and data-confirm-message="..." + // opens #confirmDeleteModal and, on confirm, submits the named form. + document.addEventListener("click", function (e) { + var trigger = e.target.closest("[data-confirm-delete-form]"); + if (!trigger) return; + e.preventDefault(); + var formId = trigger.getAttribute("data-confirm-delete-form"); + var message = trigger.getAttribute("data-confirm-message") || "Are you sure? This cannot be undone."; + var modalEl = document.getElementById("confirmDeleteModal"); + if (!modalEl) return; + modalEl.querySelector(".modal-body").textContent = message; + var confirmBtn = modalEl.querySelector("[data-confirm-action]"); + confirmBtn.onclick = function () { + var form = document.getElementById(formId); + if (form) form.submit(); + }; + var modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + }); + + // Dynamic add/remove rows for repeatable form sections (backends, + // custom headers), driven by a