[ci skip] Move Terraform modules into stack directories

Move all 88 service modules (66 individual + 22 platform) from
modules/kubernetes/<service>/ into their corresponding stack directories:

- Service stacks: stacks/<service>/module/
- Platform stack: stacks/platform/modules/<service>/

This collocates module source code with its Terragrunt definition.
Only shared utility modules remain in modules/kubernetes/:
ingress_factory, setup_tls_secret, dockerhub_secret, oauth-proxy.

All cross-references to shared modules updated to use correct
relative paths. Verified with terragrunt run --all -- plan:
0 adds, 0 destroys across all 68 stacks.
This commit is contained in:
Viktor Barzin 2026-02-22 14:38:14 +00:00
parent 73cb696f12
commit e225e81ebf
No known key found for this signature in database
GPG key ID: 0EB088298288D958
614 changed files with 12075 additions and 352 deletions

View file

@ -1,213 +0,0 @@
variable "tls_secret_name" {}
variable "name" {}
variable "tag" {
default = "latest"
}
variable "tier" { type = string }
variable "sync_id" {
type = string
default = null # If not passed, we won't run banksync
}
variable "budget_encryption_password" {
type = string
default = null # If not passed, we won't run banksync ;known after initial installation
}
resource "kubernetes_deployment" "actualbudget" {
metadata {
name = "actualbudget-${var.name}"
namespace = "actualbudget"
labels = {
app = "actualbudget-${var.name}"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "actualbudget-${var.name}"
}
}
template {
metadata {
annotations = {
"diun.enable" = "false" # daily updates; pretty noisy
"diun.include_tags" = "^${var.tag}$"
}
labels = {
app = "actualbudget-${var.name}"
}
}
spec {
container {
image = "actualbudget/actual-server:${var.tag}"
name = "actualbudget"
port {
container_port = 5006
}
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "data"
nfs {
path = "/mnt/main/actualbudget/${var.name}"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "actualbudget" {
metadata {
name = "budget-${var.name}"
namespace = "actualbudget"
labels = {
app = "actualbudget-${var.name}"
}
}
spec {
selector = {
app = "actualbudget-${var.name}"
}
port {
name = "http"
port = 80
target_port = 5006
}
}
}
module "ingress" {
source = "../../ingress_factory"
namespace = "actualbudget"
name = "budget-${var.name}"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "3e6b6b68088a"
}
resource "random_string" "api-key" {
length = 32
lower = true
}
resource "kubernetes_deployment" "actualbudget-http-api" {
count = var.budget_encryption_password != null ? 1 : 0
metadata {
name = "actualbudget-http-api-${var.name}"
namespace = "actualbudget"
labels = {
app = "actualbudget-http-api-${var.name}"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate"
}
selector {
match_labels = {
app = "actualbudget-http-api-${var.name}"
}
}
template {
metadata {
labels = {
app = "actualbudget-http-api-${var.name}"
}
}
spec {
container {
image = "jhonderson/actual-http-api:latest"
name = "actualbudget"
port {
container_port = 5007
}
env {
name = "ACTUAL_SERVER_URL"
value = "https://budget-${var.name}.viktorbarzin.me"
}
env {
name = "ACTUAL_SERVER_PASSWORD"
value = var.budget_encryption_password
}
env {
name = "API_KEY"
value = random_string.api-key.result
}
}
}
}
}
}
resource "kubernetes_service" "actualbudget-http-api" {
metadata {
name = "budget-http-api-${var.name}"
namespace = "actualbudget"
labels = {
app = "actualbudget-http-api-${var.name}"
}
}
spec {
selector = {
app = "actualbudget-http-api-${var.name}"
}
port {
name = "http"
port = 80
target_port = 5007
}
}
}
resource "kubernetes_cron_job_v1" "bank-sync" {
count = var.sync_id != null && var.budget_encryption_password != null ? 1 : 0
metadata {
name = "bank-sync-${var.name}"
namespace = "actualbudget"
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 5
schedule = "0 0 * * *" # Daily
starting_deadline_seconds = 10
successful_jobs_history_limit = 10
job_template {
metadata {}
spec {
backoff_limit = 3
ttl_seconds_after_finished = 10
template {
metadata {}
spec {
container {
name = "bank-sync"
image = "curlimages/curl"
command = ["/bin/sh", "-c", <<-EOT
# set -eux # Shows credentials so use only when debugging
curl -X POST --location 'http://budget-http-api-${var.name}/v1/budgets/${var.sync_id}/accounts/banksync' --header 'accept: application/json' --header 'budget-encryption-password: ${var.budget_encryption_password}' --header 'x-api-key: ${random_string.api-key.result}'
EOT
]
}
}
}
}
}
}
}

View file

@ -1,63 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "credentials" { type = map(any) }
# To create a new deployment:
/**
1. Export a new nfs share with {name} in truenas
2. Add {name} as proxied cloudflare route (tfvars)
3. Add module here
*/
resource "kubernetes_namespace" "actualbudget" {
metadata {
name = "actualbudget"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.actualbudget.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# https://budget-viktor.viktorbarzin.me/
module "viktor" {
source = "./factory"
name = "viktor"
tag = "edge"
tls_secret_name = var.tls_secret_name
depends_on = [kubernetes_namespace.actualbudget]
tier = var.tier
budget_encryption_password = lookup(var.credentials["viktor"], "password", null)
sync_id = lookup(var.credentials["viktor"], "sync_id", null)
}
# https://budget-anca.viktorbarzin.me/
module "anca" {
source = "./factory"
name = "anca"
tag = "edge"
tls_secret_name = var.tls_secret_name
depends_on = [kubernetes_namespace.actualbudget]
tier = var.tier
budget_encryption_password = lookup(var.credentials["anca"], "password", null)
sync_id = lookup(var.credentials["anca"], "sync_id", null)
}
# https://budget-emo.viktorbarzin.me/
module "emo" {
source = "./factory"
name = "emo"
tag = "edge"
tls_secret_name = var.tls_secret_name
depends_on = [kubernetes_namespace.actualbudget]
tier = var.tier
budget_encryption_password = lookup(var.credentials["emo"], "password", null)
sync_id = lookup(var.credentials["emo"], "sync_id", null)
}

View file

@ -1,217 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "postgresql_password" {}
variable "smtp_password" { type = string }
resource "kubernetes_namespace" "affine" {
metadata {
name = "affine"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.affine.metadata[0].name
tls_secret_name = var.tls_secret_name
}
locals {
common_env = [
{
name = "DATABASE_URL"
value = "postgresql://affine:${var.postgresql_password}@postgresql.dbaas.svc.cluster.local:5432/affine"
},
{
name = "REDIS_SERVER_HOST"
value = "redis.redis.svc.cluster.local"
},
{
name = "AFFINE_INDEXER_ENABLED"
value = "false"
},
{
name = "NODE_OPTIONS"
value = "--max-old-space-size=4096"
},
# Server URL configuration
{
name = "AFFINE_SERVER_EXTERNAL_URL"
value = "https://affine.viktorbarzin.me"
},
{
name = "AFFINE_SERVER_HTTPS"
value = "true"
},
# Email/SMTP configuration
{
name = "MAILER_HOST"
value = "mailserver.viktorbarzin.me"
},
{
name = "MAILER_PORT"
value = "587"
},
{
name = "MAILER_USER"
value = "info@viktorbarzin.me"
},
{
name = "MAILER_PASSWORD"
value = var.smtp_password
},
{
name = "MAILER_SENDER"
value = "AFFiNE <info@viktorbarzin.me>"
},
]
}
resource "kubernetes_deployment" "affine" {
metadata {
name = "affine"
namespace = kubernetes_namespace.affine.metadata[0].name
labels = {
app = "affine"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "affine"
}
}
template {
metadata {
labels = {
app = "affine"
}
}
spec {
# Init container to run database migrations
init_container {
name = "migration"
image = "ghcr.io/toeverything/affine:stable"
command = ["sh", "-c", "npx prisma migrate deploy && SERVER_FLAVOR=script node ./dist/main.js run"]
dynamic "env" {
for_each = local.common_env
content {
name = env.value.name
value = env.value.value
}
}
volume_mount {
name = "data"
mount_path = "/root/.affine/storage"
sub_path = "storage"
}
volume_mount {
name = "data"
mount_path = "/root/.affine/config"
sub_path = "config"
}
}
container {
name = "affine"
image = "ghcr.io/toeverything/affine:stable"
port {
container_port = 3010
}
dynamic "env" {
for_each = local.common_env
content {
name = env.value.name
value = env.value.value
}
}
volume_mount {
name = "data"
mount_path = "/root/.affine/storage"
sub_path = "storage"
}
volume_mount {
name = "data"
mount_path = "/root/.affine/config"
sub_path = "config"
}
resources {
requests = {
memory = "512Mi"
cpu = "100m"
}
limits = {
memory = "4Gi"
cpu = "2"
}
}
liveness_probe {
http_get {
path = "/info"
port = 3010
}
initial_delay_seconds = 120
period_seconds = 30
timeout_seconds = 10
}
readiness_probe {
http_get {
path = "/info"
port = 3010
}
initial_delay_seconds = 60
period_seconds = 10
timeout_seconds = 5
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/affine"
}
}
}
}
}
}
resource "kubernetes_service" "affine" {
metadata {
name = "affine"
namespace = kubernetes_namespace.affine.metadata[0].name
labels = {
app = "affine"
}
}
spec {
selector = {
app = "affine"
}
port {
name = "http"
port = 80
target_port = 3010
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.affine.metadata[0].name
name = "affine"
tls_secret_name = var.tls_secret_name
max_body_size = "500m"
}

View file

@ -1,135 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "audiobookshelf" {
metadata {
name = "audiobookshelf"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.audiobookshelf.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "audiobookshelf" {
metadata {
name = "audiobookshelf"
namespace = kubernetes_namespace.audiobookshelf.metadata[0].name
labels = {
app = "audiobookshelf"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "audiobookshelf"
}
}
template {
metadata {
labels = {
app = "audiobookshelf"
}
}
spec {
container {
image = "ghcr.io/advplyr/audiobookshelf:2.32.1"
name = "audiobookshelf"
port {
container_port = 80
}
volume_mount {
name = "audiobooks"
mount_path = "/audiobooks"
}
volume_mount {
name = "podcasts"
mount_path = "/podcasts"
}
volume_mount {
name = "config"
mount_path = "/config"
}
volume_mount {
name = "metadata"
mount_path = "/metadata"
}
}
volume {
name = "audiobooks"
nfs {
path = "/mnt/main/audiobookshelf/audiobooks"
server = "10.0.10.15"
}
}
volume {
name = "podcasts"
nfs {
path = "/mnt/main/audiobookshelf/podcasts"
server = "10.0.10.15"
}
}
volume {
name = "config"
nfs {
path = "/mnt/main/audiobookshelf/config"
server = "10.0.10.15"
}
}
volume {
name = "metadata"
nfs {
path = "/mnt/main/audiobookshelf/metadata"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "audiobookshelf" {
metadata {
name = "audiobookshelf"
namespace = kubernetes_namespace.audiobookshelf.metadata[0].name
labels = {
"app" = "audiobookshelf"
}
}
spec {
selector = {
app = "audiobookshelf"
}
port {
name = "http"
target_port = 80
port = 80
protocol = "TCP"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.audiobookshelf.metadata[0].name
name = "audiobookshelf"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "b38fda4285df"
}

View file

@ -1,72 +0,0 @@
variable "tls_secret_name" {}
variable "secret_key" {}
variable "postgres_password" {}
variable "tier" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.authentik.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "authentik" {
metadata {
name = "authentik"
labels = {
tier = var.tier
"resource-governance/custom-quota" = "true"
}
}
}
resource "kubernetes_resource_quota" "authentik" {
metadata {
name = "authentik-quota"
namespace = kubernetes_namespace.authentik.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "8"
"requests.memory" = "8Gi"
"limits.cpu" = "24"
"limits.memory" = "48Gi"
pods = "30"
}
}
}
resource "helm_release" "authentik" {
namespace = kubernetes_namespace.authentik.metadata[0].name
create_namespace = true
name = "goauthentik"
repository = "https://charts.goauthentik.io/"
chart = "authentik"
# version = "2025.8.1"
version = "2025.10.3"
atomic = true
timeout = 6000
values = [templatefile("${path.module}/values.yaml", { postgres_password = var.postgres_password, secret_key = var.secret_key })]
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.authentik.metadata[0].name
name = "authentik"
service_name = "goauthentik-server"
tls_secret_name = var.tls_secret_name
}
module "ingress-outpost" {
source = "../ingress_factory"
namespace = kubernetes_namespace.authentik.metadata[0].name
name = "authentik-outpost"
host = "authentik"
service_name = "ak-outpost-authentik-embedded-outpost"
port = 9000
ingress_path = ["/outpost.goauthentik.io"]
tls_secret_name = var.tls_secret_name
}

View file

@ -1,14 +0,0 @@
[databases]
authentik = host=postgresql.dbaas port=5432 dbname=authentik user=authentik password=${password}
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 5
ignore_startup_parameters = extra_float_digits

View file

@ -1,134 +0,0 @@
resource "kubernetes_config_map" "pgbouncer_config" {
metadata {
name = "pgbouncer-config"
namespace = "authentik"
}
data = {
"pgbouncer.ini" = templatefile("${path.module}/pgbouncer.ini", { password = var.postgres_password })
}
}
# --- 2 Secret for user credentials ---
resource "kubernetes_secret" "pgbouncer_auth" {
metadata {
name = "pgbouncer-auth"
namespace = "authentik"
}
data = {
"userlist.txt" = templatefile("${path.module}/userlist.txt", { password = var.postgres_password })
}
type = "Opaque"
}
# --- 3 Deployment ---
resource "kubernetes_deployment" "pgbouncer" {
metadata {
name = "pgbouncer"
namespace = "authentik"
labels = {
app = "pgbouncer"
tier = var.tier
}
}
spec {
replicas = 3
selector {
match_labels = {
app = "pgbouncer"
}
}
template {
metadata {
labels = {
app = "pgbouncer"
}
}
spec {
affinity {
pod_anti_affinity {
required_during_scheduling_ignored_during_execution {
label_selector {
match_expressions {
key = "component"
operator = "In"
values = ["server"]
}
}
topology_key = "kubernetes.io/hostname"
}
}
}
container {
name = "pgbouncer"
image = "edoburu/pgbouncer:latest"
image_pull_policy = "IfNotPresent"
port {
container_port = 6432
}
volume_mount {
name = "config"
mount_path = "/etc/pgbouncer/pgbouncer.ini"
sub_path = "pgbouncer.ini"
}
volume_mount {
name = "auth"
mount_path = "/etc/pgbouncer/userlist.txt"
sub_path = "userlist.txt"
}
env {
name = "DATABASES_AUTHENTIK"
value = "host=postgres port=5432 dbname=authentik user=authentik password=${var.postgres_password}"
}
}
volume {
name = "config"
config_map {
name = kubernetes_config_map.pgbouncer_config.metadata[0].name
}
}
volume {
name = "auth"
secret {
secret_name = kubernetes_secret.pgbouncer_auth.metadata[0].name
}
}
}
}
}
depends_on = [kubernetes_secret.pgbouncer_auth]
}
# --- 4 Service ---
resource "kubernetes_service" "pgbouncer" {
metadata {
name = "pgbouncer"
namespace = "authentik"
}
spec {
selector = {
app = "pgbouncer"
}
port {
port = 6432
target_port = 6432
protocol = "TCP"
}
type = "ClusterIP"
}
}

View file

@ -1 +0,0 @@
"authentik" "${password}"

View file

@ -1,31 +0,0 @@
authentik:
log_level: warning
# log_level: trace
secret_key: "${secret_key}"
# This sends anonymous usage-data, stack traces on errors and
# performance data to authentik.error-reporting.a7k.io, and is fully opt-in
error_reporting:
enabled: true
postgresql:
# host: postgresql.dbaas
host: pgbouncer.authentik
port: 6432
user: authentik
password: ${postgres_password}
redis:
host: redis.redis
server:
replicas: 3
ingress:
enabled: false
# hosts:
# - authentik.viktorbarzin.me
podAnnotations:
diun.enable: true
diun.include_tags: "^202[0-9].[0-9]+.*$" # no need to annotate the worker as it uses the same image
global:
addPrometheusAnnotations: true
worker:
replicas: 3

View file

@ -1,130 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
# variable "dockerhub_password" {}
resource "kubernetes_namespace" "website" {
metadata {
name = "website"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.website.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# module "dockerhub_creds" {
# source = "../dockerhub_secret"
# namespace = kubernetes_namespace.website.metadata[0].name
# password = var.dockerhub_password
# }
resource "kubernetes_deployment" "blog" {
metadata {
name = "blog"
namespace = kubernetes_namespace.website.metadata[0].name
labels = {
run = "blog"
tier = var.tier
}
}
spec {
replicas = 3
selector {
match_labels = {
run = "blog"
}
}
template {
metadata {
labels = {
run = "blog"
}
}
spec {
container {
image = "viktorbarzin/blog:latest"
name = "blog"
resources {
limits = {
cpu = "0.5"
memory = "512Mi"
}
requests = {
cpu = "250m"
memory = "50Mi"
}
}
port {
container_port = 80
}
}
container {
image = "nginx/nginx-prometheus-exporter"
name = "nginx-exporter"
args = ["-nginx.scrape-uri", "http://127.0.0.1:8080/nginx_status"]
port {
container_port = 9113
}
}
}
}
}
}
resource "kubernetes_service" "blog" {
metadata {
name = "blog"
namespace = kubernetes_namespace.website.metadata[0].name
labels = {
"run" = "blog"
}
annotations = {
"prometheus.io/scrape" = "true"
"prometheus.io/path" = "/metrics"
"prometheus.io/port" = "9113"
}
}
spec {
selector = {
run = "blog"
}
port {
name = "http"
port = "80"
target_port = "80"
}
port {
name = "prometheus"
port = "9113"
target_port = "9113"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.website.metadata[0].name
name = "blog"
service_name = "blog"
full_host = "viktorbarzin.me"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "da853a2438d0"
}
module "ingress-www" {
source = "../ingress_factory"
namespace = kubernetes_namespace.website.metadata[0].name
name = "blog-www"
service_name = "blog"
full_host = "www.viktorbarzin.me"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "da853a2438d0"
}

View file

@ -1,335 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "homepage_username" {
default = ""
}
variable "homepage_password" {
default = ""
}
resource "kubernetes_namespace" "calibre" {
metadata {
name = "calibre"
labels = {
tier = var.tier
}
# labels = {
# "istio-injection" : "enabled"
# }
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.calibre.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# resource "kubernetes_deployment" "calibre" {
# metadata {
# name = "calibre"
# namespace = kubernetes_namespace.calibre.metadata[0].name
# labels = {
# app = "calibre"
# }
# annotations = {
# "reloader.stakater.com/search" = "true"
# }
# }
# spec {
# replicas = 1
# strategy {
# type = "Recreate"
# }
# selector {
# match_labels = {
# app = "calibre"
# }
# }
# template {
# metadata {
# annotations = {
# # "diun.enable" = "true"
# "diun.enable" = "false"
# "diun.include_tags" = "^\\d+(?:\\.\\d+)?(?:\\.\\d+)?$"
# }
# labels = {
# app = "calibre"
# }
# }
# spec {
# container {
# image = "lscr.io/linuxserver/calibre-web:latest"
# name = "calibre"
# env {
# name = "PUID"
# value = 1000
# }
# env {
# name = "PGID"
# value = 1000
# }
# env {
# name = "DOCKER_MODS"
# value = "linuxserver/mods:universal-calibre"
# }
# port {
# container_port = 8083
# }
# volume_mount {
# name = "data"
# mount_path = "/config"
# }
# volume_mount {
# name = "data"
# mount_path = "/books"
# }
# }
# volume {
# name = "data"
# nfs {
# path = "/mnt/main/calibre"
# server = "10.0.10.15"
# }
# }
# }
# }
# }
# }
resource "kubernetes_deployment" "calibre-web-automated" {
metadata {
name = "calibre-web-automated"
namespace = kubernetes_namespace.calibre.metadata[0].name
labels = {
app = "calibre-web-automated"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "calibre-web-automated"
}
}
template {
metadata {
annotations = {
# "diun.enable" = "true"
"diun.enable" = "false"
"diun.include_tags" = "^\\d+(?:\\.\\d+)?(?:\\.\\d+)?$"
}
labels = {
app = "calibre-web-automated"
}
}
spec {
container {
image = "crocodilestick/calibre-web-automated:latest"
name = "calibre-web-automated"
env {
name = "PUID"
value = 1000
}
env {
name = "PGID"
value = 1000
}
env {
name = "DOCKER_MODS"
value = "linuxserver/mods:universal-calibre"
}
env {
# If your library is on a network share (e.g., NFS/SMB), disable WAL to reduce locking issues
name = "NETWORK_SHARE_MODE"
value = "true"
}
env {
name = "CALIBRE_PORT"
value = "8083"
}
port {
container_port = 8083
}
volume_mount {
name = "config"
mount_path = "/config"
}
volume_mount {
name = "library"
mount_path = "/calibre-library"
}
volume_mount {
name = "ingest"
mount_path = "/cwa-book-ingest"
}
}
volume {
name = "library"
nfs {
path = "/mnt/main/calibre-web-automated/calibre-library"
server = "10.0.10.15"
}
}
volume {
name = "config"
nfs {
path = "/mnt/main/calibre-web-automated/config"
server = "10.0.10.15"
}
}
volume {
name = "ingest"
nfs {
path = "/mnt/main/calibre-web-automated/cwa-book-ingest"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "calibre" {
metadata {
name = "calibre"
namespace = kubernetes_namespace.calibre.metadata[0].name
labels = {
"app" = "calibre"
}
}
spec {
selector = {
# app = "calibre"
app = "calibre-web-automated"
}
port {
name = "http"
target_port = 8083
port = 80
protocol = "TCP"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.calibre.metadata[0].name
name = "calibre"
tls_secret_name = var.tls_secret_name
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/description" = "Book library"
# gethomepage.dev/group: Media
"gethomepage.dev/icon" : "calibre-web.png"
"gethomepage.dev/name" = "Calibre"
"gethomepage.dev/widget.type" = "calibreweb"
"gethomepage.dev/widget.url" = "https://calibre.viktorbarzin.me"
"gethomepage.dev/widget.username" = var.homepage_username
"gethomepage.dev/widget.password" = var.homepage_password
"gethomepage.dev/pod-selector" = ""
# gethomepage.dev/weight: 10 # optional
# gethomepage.dev/instance: "public" # optional
}
rybbit_site_id = "17a5c7fbb077"
custom_content_security_policy = "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://rybbit.viktorbarzin.me"
}
# Stacks - Anna's Archive Download Manager
resource "kubernetes_deployment" "annas-archive-stacks" {
metadata {
name = "annas-archive-stacks"
namespace = kubernetes_namespace.calibre.metadata[0].name
labels = {
app = "annas-archive-stacks"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "annas-archive-stacks"
}
}
template {
metadata {
labels = {
app = "annas-archive-stacks"
}
}
spec {
container {
image = "zelest/stacks:latest"
name = "annas-archive-stacks"
port {
container_port = 7788
}
volume_mount {
name = "config"
mount_path = "/opt/stacks/config"
}
volume_mount {
name = "ingest"
mount_path = "/opt/stacks/download" # this must be the same as CWA ingest dir to auto ingest
}
}
volume {
name = "config"
nfs {
path = "/mnt/main/calibre-web-automated/stacks"
server = "10.0.10.15"
}
}
volume {
name = "ingest"
nfs {
path = "/mnt/main/calibre-web-automated/cwa-book-ingest"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "annas-archive-stacks" {
metadata {
name = "annas-archive-stacks"
namespace = kubernetes_namespace.calibre.metadata[0].name
labels = {
"app" = "annas-archive-stacks"
}
}
spec {
selector = {
app = "annas-archive-stacks"
}
port {
name = "http"
port = "80"
target_port = 7788
}
}
}
module "stacks-ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.calibre.metadata[0].name
name = "stacks"
service_name = "annas-archive-stacks"
tls_secret_name = var.tls_secret_name
protected = true
rybbit_site_id = "ce5f8aed6bbb"
}

View file

@ -1,132 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "changedetection" {
metadata {
name = "changedetection"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.changedetection.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "changedetection" {
metadata {
name = "changedetection"
namespace = kubernetes_namespace.changedetection.metadata[0].name
labels = {
app = "changedetection"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "changedetection"
}
}
template {
metadata {
labels = {
app = "changedetection"
}
}
spec {
container {
name = "sockpuppetbrowser"
image = "dgtlmoon/sockpuppetbrowser:latest"
image_pull_policy = "IfNotPresent"
port {
name = "ws"
container_port = 3000
protocol = "TCP"
}
security_context {
capabilities {
add = ["SYS_ADMIN"]
}
}
}
container {
name = "changedetection"
image = "ghcr.io/dgtlmoon/changedetection.io:latest" # latest is latest stable
env {
name = "PLAYWRIGHT_DRIVER_URL"
value = "ws://localhost:3000"
}
env {
name = "BASE_URL"
value = "https://changedetection.viktorbarzin.me"
}
env {
name = "LOGGER_LEVEL"
value = "WARNING"
}
env {
name = "TZ"
value = "Europe/Sofia"
}
volume_mount {
name = "data"
mount_path = "/datastore"
}
port {
name = "http"
container_port = 5000
protocol = "TCP"
}
}
# security_context {
# fs_group = "1500"
# }
volume {
name = "data"
nfs {
path = "/mnt/main/changedetection"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "changedetection" {
metadata {
name = "changedetection"
namespace = kubernetes_namespace.changedetection.metadata[0].name
labels = {
"app" = "changedetection"
}
}
spec {
selector = {
app = "changedetection"
}
port {
port = 80
target_port = 5000
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.changedetection.metadata[0].name
name = "changedetection"
tls_secret_name = var.tls_secret_name
protected = true
}

View file

@ -1,154 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "city-guesser" {
metadata {
name = "city-guesser"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = "city-guesser"
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "city-guesser" {
metadata {
name = "city-guesser"
namespace = "city-guesser"
labels = {
run = "city-guesser"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
run = "city-guesser"
}
}
template {
metadata {
labels = {
run = "city-guesser"
}
}
spec {
container {
image = "viktorbarzin/city-guesser:latest"
name = "city-guesser"
resources {
limits = {
cpu = "0.5"
memory = "512Mi"
}
requests = {
cpu = "250m"
memory = "50Mi"
}
}
port {
container_port = 80
}
}
}
}
}
}
resource "kubernetes_service" "city-guesser" {
metadata {
name = "city-guesser"
namespace = "city-guesser"
labels = {
"run" = "city-guesser"
}
}
spec {
selector = {
run = "city-guesser"
}
port {
name = "http"
port = "80"
target_port = "80"
}
}
}
# resource "kubernetes_service" "city-guesser-oauth" {
# metadata {
# name = "city-guesser-oauth"
# namespace = "city-guesser"
# labels = {
# "run" = "city-guesser-oauth"
# }
# }
# spec {
# type = "ExternalName"
# external_name = "oauth-proxy.oauth.svc.cluster.local"
# # port {
# # name = "tcp"
# # port = "80"
# # target_port = "80"
# # }
# }
# }
module "ingress" {
source = "../ingress_factory"
namespace = "city-guesser"
name = "city-guesser"
tls_secret_name = var.tls_secret_name
protected = true
}
# resource "kubernetes_ingress_v1" "city-guesser-oauth" {
# metadata {
# name = "city-guesser-ingress-oauth"
# namespace = "city-guesser"
# annotations = {
# "kubernetes.io/ingress.class" = "nginx"
# }
# }
# spec {
# tls {
# hosts = ["city-guesser.viktorbarzin.me"]
# secret_name = var.tls_secret_name
# }
# rule {
# host = "city-guesser.viktorbarzin.me"
# http {
# path {
# path = "/oauth2"
# backend {
# service_name = "city-guesser-oauth"
# service_port = "80"
# }
# }
# }
# }
# }
# }
# module "oauth" {
# source = "../oauth-proxy"
# # oauth_client_id = "3d8ce4bf7b893899d967"
# # oauth_client_secret = "08dca09b05e511cfa7f85cd7f85c332fd0768113"
# client_id = "3d8ce4bf7b893899d967"
# client_secret = "08dca09b05e511cfa7f85cd7f85c332fd0768113"
# namespace = "city-guesser"
# host = "city-guesser.viktorbarzin.me"
# tls_secret_name = var.tls_secret_name
# svc_name = "city-guesser-oauth"
# }

View file

@ -1,159 +0,0 @@
# Contents for cloudflare account
variable "cloudflare_api_key" {}
variable "cloudflare_email" {}
variable "cloudflare_proxied_names" { type = list(string) }
variable "cloudflare_non_proxied_names" { type = list(string) }
variable "cloudflare_zone_id" {
description = "Zone ID for your domain"
type = string
}
variable "cloudflare_account_id" {
type = string
sensitive = true
}
variable "cloudflare_tunnel_id" {
type = string
sensitive = true
}
variable "public_ip" {
type = string
}
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4"
}
}
}
provider "cloudflare" {
api_key = var.cloudflare_api_key # I gave up on getting the permissions on the token...
email = var.cloudflare_email
}
locals {
cloudflare_proxied_names_map = {
for h in var.cloudflare_proxied_names :
h => h
}
cloudflare_non_proxied_names_map = {
for h in var.cloudflare_non_proxied_names :
h => h
}
}
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "sof" {
account_id = var.cloudflare_account_id
tunnel_id = var.cloudflare_tunnel_id
config {
warp_routing {
enabled = true
}
dynamic "ingress_rule" {
for_each = toset(var.cloudflare_proxied_names)
content {
hostname = ingress_rule.value == "viktorbarzin.me" ? ingress_rule.value : "${ingress_rule.value}.viktorbarzin.me"
path = "/"
service = "https://10.0.20.202:443"
origin_request {
no_tls_verify = true
}
}
}
ingress_rule {
service = "http_status:404"
}
}
}
resource "cloudflare_record" "dns_record" {
# count = length(var.cloudflare_proxied_names)
# name = var.cloudflare_proxied_names[count.index]
for_each = local.cloudflare_proxied_names_map
name = each.key
content = "${var.cloudflare_tunnel_id}.cfargotunnel.com"
proxied = true
ttl = 1
type = "CNAME"
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_record" "non_proxied_dns_record" {
# count = length(var.cloudflare_non_proxied_names)
# name = var.cloudflare_non_proxied_names[count.index]
for_each = local.cloudflare_non_proxied_names_map
name = each.key
# content = var.non_proxied_names[count.index].ip
content = var.public_ip
proxied = false
ttl = 1
type = "A"
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_record" "mail" {
content = "mail.viktorbarzin.me"
name = "viktorbarzin.me"
proxied = false
ttl = 1
type = "MX"
priority = 1
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_record" "mail_domainkey" {
content = "\"k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIDLB8mhAHNqs1s6GeZMQHOxWweoNKIrqo5tqRM3yFilgfPUX34aTIXNZg9xAmlK+2S/xXO1ymt127ZGMjnoFKOEP8/uZ54iHTCnioHaPZWMfJ7o6TYIXjr+9ShKfoJxZLv7lHJ2wKQK3yOw4lg4cvja5nxQ6fNoGRwo+mQ/mgJQIDAQAB\""
name = "s1._domainkey.viktorbarzin.me"
proxied = false
ttl = 1
type = "TXT"
priority = 1
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_record" "mail_spf" {
content = "\"v=spf1 include:mailgun.org ~all\""
name = "viktorbarzin.me"
proxied = false
ttl = 1
type = "TXT"
priority = 1
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_record" "mail_dmarc" {
content = "\"v=DMARC1; p=none; pct=100; fo=1; ri=3600; sp=none; adkim=r; aspf=r; rua=mailto:e21c0ff8@dmarc.mailgun.org,mailto:adb84997@inbox.ondmarc.com; ruf=mailto:e21c0ff8@dmarc.mailgun.org,mailto:adb84997@inbox.ondmarc.com,mailto:postmaster@viktorbarzin.me;\""
name = "_dmarc.viktorbarzin.me"
proxied = false
ttl = 1
type = "TXT"
priority = 1
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_record" "keyserver" {
content = "130.162.165.220" # Oracle VPS
name = "keyserver.viktorbarzin.me"
proxied = false
ttl = 3600
type = "A"
priority = 1
zone_id = var.cloudflare_zone_id
}
# Enable HTTP/3 (QUIC) for Cloudflare-proxied domains
resource "cloudflare_zone_settings_override" "http3" {
zone_id = var.cloudflare_zone_id
settings {
http3 = "on"
}
}

View file

@ -1,90 +0,0 @@
# Contents for cloudflare tunnel
variable "tls_secret_name" {}
variable "cloudflare_tunnel_token" {}
resource "kubernetes_namespace" "cloudflared" {
metadata {
name = "cloudflared"
labels = {
tier = var.tier
}
}
}
variable "tier" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.cloudflared.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "cloudflared" {
metadata {
name = "cloudflared"
namespace = kubernetes_namespace.cloudflared.metadata[0].name
labels = {
app = "cloudflared"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 3
strategy {
type = "RollingUpdate"
}
selector {
match_labels = {
app = "cloudflared"
}
}
template {
metadata {
labels = {
app = "cloudflared"
}
}
spec {
container {
# image = "wisdomsky/cloudflared-web:latest"
image = "cloudflare/cloudflared"
name = "cloudflared"
command = ["cloudflared", "tunnel", "run"]
env {
name = "TUNNEL_TOKEN"
value = var.cloudflare_tunnel_token
}
port {
container_port = 14333
}
}
}
}
}
}
resource "kubernetes_service" "cloudflared" {
metadata {
name = "cloudflared"
namespace = kubernetes_namespace.cloudflared.metadata[0].name
labels = {
"app" = "cloudflared"
}
}
spec {
selector = {
app = "cloudflared"
}
port {
name = "http"
target_port = 14333
port = 80
protocol = "TCP"
}
}
}

View file

@ -1,194 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "turn_secret" { type = string }
variable "public_ip" { type = string }
locals {
turn_realm = "viktorbarzin.me"
turn_port = 3478
# Small relay range 100 ports is plenty for a home lab (~50 concurrent streams)
min_port = 49152
max_port = 49252
}
resource "kubernetes_namespace" "coturn" {
metadata {
name = "coturn"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.coturn.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_config_map" "coturn_config" {
metadata {
name = "coturn-config"
namespace = kubernetes_namespace.coturn.metadata[0].name
}
data = {
"turnserver.conf" = <<-EOF
# TURN server configuration
listening-port=${local.turn_port}
fingerprint
lt-cred-mech
use-auth-secret
static-auth-secret=${var.turn_secret}
realm=${local.turn_realm}
server-name=turn.${local.turn_realm}
# Network use 0.0.0.0, coturn auto-detects pod IP
listening-ip=0.0.0.0
external-ip=${var.public_ip}
# Media relay port range (narrow 100 ports)
min-port=${local.min_port}
max-port=${local.max_port}
# Logging
verbose
no-stdout-log
syslog
# Security
no-multicast-peers
no-cli
no-tlsv1
no-tlsv1_1
# Performance
total-quota=100
stale-nonce=600
max-bps=0
EOF
}
}
resource "kubernetes_deployment" "coturn" {
metadata {
name = "coturn"
namespace = kubernetes_namespace.coturn.metadata[0].name
labels = {
app = "coturn"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate"
rolling_update {
max_unavailable = 0
max_surge = 1
}
}
selector {
match_labels = {
app = "coturn"
}
}
template {
metadata {
labels = {
app = "coturn"
}
}
spec {
container {
name = "coturn"
image = "coturn/coturn:latest"
args = ["-c", "/etc/turnserver/turnserver.conf"]
# STUN/TURN signaling port
port {
name = "turn-udp"
container_port = local.turn_port
protocol = "UDP"
}
port {
name = "turn-tcp"
container_port = local.turn_port
protocol = "TCP"
}
volume_mount {
name = "config"
mount_path = "/etc/turnserver"
read_only = true
}
resources {
requests = {
cpu = "100m"
memory = "128Mi"
}
limits = {
cpu = "1"
memory = "512Mi"
}
}
}
volume {
name = "config"
config_map {
name = kubernetes_config_map.coturn_config.metadata[0].name
}
}
}
}
}
}
# LoadBalancer service with MetalLB exposes STUN/TURN signaling + relay ports
resource "kubernetes_service" "coturn" {
metadata {
name = "coturn"
namespace = kubernetes_namespace.coturn.metadata[0].name
annotations = {
"metallb.universe.tf/loadBalancerIPs" = "10.0.20.200"
"metallb.universe.tf/allow-shared-ip" = "shared"
}
}
spec {
type = "LoadBalancer"
selector = {
app = "coturn"
}
# STUN/TURN signaling
port {
name = "turn-udp"
port = local.turn_port
target_port = local.turn_port
protocol = "UDP"
}
port {
name = "turn-tcp"
port = local.turn_port
target_port = local.turn_port
protocol = "TCP"
}
# Relay port range (49152-49252)
dynamic "port" {
for_each = range(local.min_port, local.max_port + 1)
content {
name = "relay-${port.value}"
port = port.value
target_port = port.value
protocol = "UDP"
}
}
}
}

View file

@ -1,44 +0,0 @@
controller:
extraVolumes:
- name: crowdsec-bouncer-plugin
emptyDir: {}
extraInitContainers:
- name: init-clone-crowdsec-bouncer
image: crowdsecurity/lua-bouncer-plugin
imagePullPolicy: IfNotPresent
env:
- name: API_URL
value: "http://crowdsec-service.crowdsec.svc.cluster.local:8080" # crowdsec lapi service-name
- name: API_KEY
value: "<API KEY>" # generated with `cscli bouncers add -n <bouncer_name>
- name: BOUNCER_CONFIG
value: "/crowdsec/crowdsec-bouncer.conf"
- name: CAPTCHA_PROVIDER
value: "recaptcha" # valid providers are recaptcha, hcaptcha, turnstile
- name: SECRET_KEY
value: "<your-captcha-secret-key>" # If you want captcha support otherwise remove this ENV VAR
- name: SITE_KEY
value: "<your-captcha-site-key>" # If you want captcha support otherwise remove this ENV VAR
- name: BAN_TEMPLATE_PATH
value: /etc/nginx/lua/plugins/crowdsec/templates/ban.html
- name: CAPTCHA_TEMPLATE_PATH
value: /etc/nginx/lua/plugins/crowdsec/templates/captcha.html
command:
[
"sh",
"-c",
"sh /docker_start.sh; mkdir -p /lua_plugins/crowdsec/; cp -R /crowdsec/* /lua_plugins/crowdsec/",
]
volumeMounts:
- name: crowdsec-bouncer-plugin
mountPath: /lua_plugins
extraVolumeMounts:
- name: crowdsec-bouncer-plugin
mountPath: /etc/nginx/lua/plugins/crowdsec
subPath: crowdsec
config:
plugins: "crowdsec"
lua-shared-dicts: "crowdsec_cache: 50m"
server-snippet: |
lua_ssl_trusted_certificate "/etc/ssl/certs/ca-certificates.crt"; # If you want captcha support otherwise remove this line
resolver local=on ipv6=off;

View file

@ -1,353 +0,0 @@
variable "tls_secret_name" {}
variable "homepage_username" {}
variable "homepage_password" {}
variable "db_password" {}
variable "enroll_key" {}
variable "crowdsec_dash_api_key" { type = string } # used for web dash
variable "crowdsec_dash_machine_id" { type = string } # used for web dash
variable "crowdsec_dash_machine_password" { type = string } # used for web dash
variable "tier" { type = string }
variable "slack_webhook_url" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "crowdsec" {
metadata {
name = "crowdsec"
labels = {
tier = var.tier
"resource-governance/custom-quota" = "true"
}
}
}
resource "kubernetes_config_map" "crowdsec_custom_scenarios" {
metadata {
name = "crowdsec-custom-scenarios"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
labels = {
"app.kubernetes.io/name" = "crowdsec"
}
}
data = {
"http-403-abuse.yaml" = <<-YAML
type: leaky
name: crowdsecurity/http-403-abuse
description: "Detect IPs triggering too many HTTP 403s in NGINX ingress logs"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Parsed.status == '403'"
groupby: "evt.Meta.source_ip"
leakspeed: "2s"
capacity: 10
blackhole: 5m
labels:
service: http
behavior: abusive_403
remediation: true
YAML
"http-429-abuse.yaml" : <<-YAML
type: leaky
name: crowdsecurity/http-429-abuse
description: "Detect IPs repeatedly triggering rate-limit (HTTP 429)"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Parsed.status == '429'"
groupby: "evt.Meta.source_ip"
leakspeed: "10s"
capacity: 5
blackhole: 1m
labels:
service: http
behavior: rate_limit_abuse
remediation: true
YAML
}
}
# Whitelist for trusted IPs that should never be blocked
resource "kubernetes_config_map" "crowdsec_whitelist" {
metadata {
name = "crowdsec-whitelist"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
labels = {
"app.kubernetes.io/name" = "crowdsec"
}
}
data = {
"whitelist.yaml" = <<-YAML
name: crowdsecurity/whitelist-trusted-ips
description: "Whitelist for trusted IPs that should never be blocked"
whitelist:
reason: "Trusted IP - never block"
ip:
- "176.12.22.76"
YAML
}
}
resource "helm_release" "crowdsec" {
namespace = kubernetes_namespace.crowdsec.metadata[0].name
create_namespace = true
name = "crowdsec"
atomic = true
version = "0.21.0"
repository = "https://crowdsecurity.github.io/helm-charts"
chart = "crowdsec"
values = [templatefile("${path.module}/values.yaml", { homepage_username = var.homepage_username, homepage_password = var.homepage_password, DB_PASSWORD = var.db_password, ENROLL_KEY = var.enroll_key, SLACK_WEBHOOK_URL = var.slack_webhook_url })]
timeout = 3600
}
# Deployment for my custom dashboard that helps me unblock myself when I blocklist myself
resource "kubernetes_deployment" "crowdsec-web" {
metadata {
name = "crowdsec-web"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
labels = {
app = "crowdsec_web"
"kubernetes.io/cluster-service" = "true"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate"
}
selector {
match_labels = {
app = "crowdsec_web"
}
}
template {
metadata {
labels = {
app = "crowdsec_web"
"kubernetes.io/cluster-service" = "true"
}
}
spec {
priority_class_name = "tier-1-cluster"
container {
name = "crowdsec-web"
image = "viktorbarzin/crowdsec_web"
env {
name = "CS_API_URL"
value = "http://crowdsec-service.crowdsec.svc.cluster.local:8080/v1"
}
env {
name = "CS_API_KEY"
value = var.crowdsec_dash_api_key
}
env {
name = "CS_MACHINE_ID"
value = var.crowdsec_dash_machine_id
}
env {
name = "CS_MACHINE_PASSWORD"
value = var.crowdsec_dash_machine_password
}
port {
name = "http"
container_port = 8000
protocol = "TCP"
}
}
}
}
}
}
resource "kubernetes_service" "crowdsec-web" {
metadata {
name = "crowdsec-web"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
labels = {
"app" = "crowdsec_web"
}
}
spec {
selector = {
app = "crowdsec_web"
}
port {
port = "80"
target_port = "8000"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
name = "crowdsec-web"
protected = true
tls_secret_name = var.tls_secret_name
exclude_crowdsec = true
rybbit_site_id = "d09137795ccc"
}
# CronJob to import public blocklists into CrowdSec
# https://github.com/wolffcatskyy/crowdsec-blocklist-import
# Uses kubectl exec to run in an existing CrowdSec agent pod that's already registered
resource "kubernetes_cron_job_v1" "crowdsec_blocklist_import" {
metadata {
name = "crowdsec-blocklist-import"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
labels = {
app = "crowdsec-blocklist-import"
tier = var.tier
}
}
spec {
# Run daily at 4 AM
schedule = "0 4 * * *"
timezone = "Europe/London"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {
labels = {
app = "crowdsec-blocklist-import"
}
}
spec {
backoff_limit = 3
template {
metadata {
labels = {
app = "crowdsec-blocklist-import"
}
}
spec {
service_account_name = kubernetes_service_account.blocklist_import.metadata[0].name
restart_policy = "OnFailure"
container {
name = "blocklist-import"
image = "bitnami/kubectl:latest"
command = ["/bin/bash", "-c"]
args = [
<<-EOF
set -e
echo "Finding CrowdSec agent pod..."
AGENT_POD=$(kubectl get pods -n crowdsec -l k8s-app=crowdsec,type=agent -o jsonpath='{.items[0].metadata.name}')
if [ -z "$AGENT_POD" ]; then
echo "ERROR: Could not find CrowdSec agent pod"
exit 1
fi
echo "Using agent pod: $AGENT_POD"
# Download the import script
echo "Downloading blocklist import script..."
curl -fsSL -o /tmp/import.sh \
https://raw.githubusercontent.com/wolffcatskyy/crowdsec-blocklist-import/main/import.sh
chmod +x /tmp/import.sh
# Copy script to agent pod and execute
echo "Copying script to agent pod and executing..."
kubectl cp /tmp/import.sh crowdsec/$AGENT_POD:/tmp/import.sh
kubectl exec -n crowdsec "$AGENT_POD" -- /bin/bash -c '
set -e
# Run with native mode since we are inside the CrowdSec container
export MODE=native
export DECISION_DURATION=24h
export FETCH_TIMEOUT=60
export LOG_LEVEL=INFO
/tmp/import.sh
# Cleanup
rm -f /tmp/import.sh
'
echo "Blocklist import completed successfully!"
EOF
]
}
}
}
}
}
}
}
# Service account for the blocklist import job (needs kubectl exec permissions)
resource "kubernetes_service_account" "blocklist_import" {
metadata {
name = "crowdsec-blocklist-import"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
}
}
resource "kubernetes_role" "blocklist_import" {
metadata {
name = "crowdsec-blocklist-import"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
}
rule {
api_groups = [""]
resources = ["pods"]
verbs = ["get", "list"]
}
rule {
api_groups = [""]
resources = ["pods/exec"]
verbs = ["create"]
}
}
resource "kubernetes_role_binding" "blocklist_import" {
metadata {
name = "crowdsec-blocklist-import"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "Role"
name = kubernetes_role.blocklist_import.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.blocklist_import.metadata[0].name
namespace = kubernetes_namespace.crowdsec.metadata[0].name
}
}
# Custom ResourceQuota for CrowdSec needs more than default 1-cluster quota
# because it runs DaemonSet agents (1 per worker node) + 3 LAPI replicas + web UI
resource "kubernetes_resource_quota" "crowdsec" {
metadata {
name = "crowdsec-quota"
namespace = kubernetes_namespace.crowdsec.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "8"
"requests.memory" = "8Gi"
"limits.cpu" = "16"
"limits.memory" = "16Gi"
pods = "30"
}
}
}

View file

@ -1,196 +0,0 @@
# values from - https://github.com/crowdsecurity/helm-charts/blob/main/charts/crowdsec/values.yaml
container_runtime: containerd
agent:
priorityClassName: "tier-1-cluster"
# To specify each pod you want to process it logs (pods present in the node)
acquisition:
# The namespace where the pod is located
- namespace: traefik
# The pod name
podName: traefik-*
# as in crowdsec configuration, we need to specify the program name so the parser will match and parse logs
program: traefik
# Those are ENV variables
env:
# As it's a test, we don't want to share signals with CrowdSec so disable the Online API.
# - name: DISABLE_ONLINE_API
# value: "true"
# As we are running Traefik, we want to install the Traefik collection
- name: COLLECTIONS
value: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/http-cve"
- name: SCENARIOS
value: ""
# value: "crowdsecurity/http-crawl-aggressive"
# Mount custom scenarios into /etc/crowdsec/scenarios
extraVolumeMounts:
- name: custom-scenarios
mountPath: /etc/crowdsec/scenarios/http-403-abuse.yaml
subPath: "http-403-abuse.yaml"
readonly: true
- name: custom-scenarios
mountPath: /etc/crowdsec/scenarios/http-429-abuse.yaml
subPath: "http-429-abuse.yaml"
readonly: true
- name: whitelist
mountPath: /etc/crowdsec/parsers/s02-enrich/whitelist.yaml
subPath: "whitelist.yaml"
readonly: true
extraVolumes:
- name: custom-scenarios
configMap:
name: crowdsec-custom-scenarios
- name: whitelist
configMap:
name: crowdsec-whitelist
lapi:
priorityClassName: "tier-1-cluster"
replicas: 3
extraSecrets:
dbPassword: "${DB_PASSWORD}"
storeCAPICredentialsInSecret: true
persistentVolume:
config:
enabled: false
data:
enabled: false
env:
- name: ENROLL_KEY
value: "${ENROLL_KEY}"
- name: ENROLL_INSTANCE_NAME
value: "k8s-cluster"
- name: ENROLL_TAGS
value: "k8s linux"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: crowdsec-lapi-secrets
key: dbPassword
# As it's a test, we don't want to share signals with CrowdSec, so disable the Online API.
# - name: DISABLE_ONLINE_API
# value: "true"
dashboard:
enabled: true
env:
- name: MB_DB_TYPE
value: "mysql"
- name: MB_DB_DBNAME
value: crowdsec-metabase
- name: MB_DB_USER
value: "crowdsec"
- name: MB_DB_PASS
value: "${DB_PASSWORD}"
- name: MB_DB_HOST
value: "mysql.dbaas.svc.cluster.local"
- name: MB_EMAIL_SMTP_USERNAME
value: "info@viktorbarzin.me"
- name: MB_EMAIL_FROM_ADDRESS
value: "info@viktorbarzin.me"
- name: MB_EMAIL_SMTP_HOST
value: "mailserver.mailserver.svc.cluster.local"
- name: MB_EMAIL_SMTP_PASSWORD
value: "" # Ignore for now as it's unclear what notifications we can get
- name: MB_EMAIL_SMTP_PORT
value: "587"
- name: MB_EMAIL_SMTP_SECURITY
value: "starttls"
ingress:
enabled: true
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
#nginx.ingress.kubernetes.io/auth-url: "https://oauth2.viktorbarzin.me/oauth2/auth"
nginx.ingress.kubernetes.io/auth-url: "http://ak-outpost-authentik-embedded-outpost.authentik.svc.cluster.local:9000/outpost.goauthentik.io/auth/nginx"
# nginx.ingress.kubernetes.io/auth-signin: "https://oauth2.viktorbarzin.me/oauth2/start?rd=/redirect/$http_host$escaped_request_uri"
nginx.ingress.kubernetes.io/auth-signin: "https://authentik.viktorbarzin.me/outpost.goauthentik.io/start?rd=$scheme%3A%2F%2F$host$escaped_request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"
nginx.ingress.kubernetes.io/auth-snippet: "proxy_set_header X-Forwarded-Host $http_host;"
gethomepage.dev/enabled: "true"
gethomepage.dev/description: "Web Application Firewall"
gethomepage.dev/icon: "crowdsec.png"
gethomepage.dev/name: "CrowdSec"
gethomepage.dev/widget.type: "crowdsec"
gethomepage.dev/widget.url: "http://crowdsec-service.crowdsec.svc.cluster.local:8080"
gethomepage.dev/widget.username: "${homepage_username}"
gethomepage.dev/widget.password: "${homepage_password}"
gethomepage.dev/pod-selector: ""
ingressClassName: "nginx"
host: "crowdsec.viktorbarzin.me"
tls:
- hosts:
- crowdsec.viktorbarzin.me
secretName: "tls-secret"
metrics:
enabled: true
strategy:
type: RollingUpdate
config:
# Custom profiles: captcha for rate limiting, ban for attacks
profiles.yaml: |
# Captcha for rate limiting and 403 abuse (user can unblock themselves)
name: captcha_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() in ["crowdsecurity/http-429-abuse", "crowdsecurity/http-403-abuse", "crowdsecurity/http-crawl-non_statics", "crowdsecurity/http-sensitive-files"]
decisions:
- type: captcha
duration: 4h
notifications:
- slack_alerts
on_success: break
---
# Default: Ban for serious attacks (CVE exploits, scanners, brute force)
name: default_ip_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: 4h
notifications:
- slack_alerts
on_success: break
---
name: default_range_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Range"
decisions:
- type: ban
duration: 4h
notifications:
- slack_alerts
on_success: break
config.yaml.local: |
db_config:
type: mysql
user: crowdsec
password: ${DB_PASSWORD}
db_name: crowdsec
host: mysql.dbaas.svc.cluster.local
port: 3306
api:
server:
auto_registration: # Activate if not using TLS for authentication
enabled: true
token: "$${REGISTRATION_TOKEN}" # /!\ do not change
allowed_ranges: # /!\ adapt to the pod IP ranges used by your cluster
- "127.0.0.1/32"
- "192.168.0.0/16"
- "10.0.0.0/8"
- "172.16.0.0/12"
notifications:
slack.yaml: |
type: slack
name: slack_alerts
log_level: info
format: |
:rotating_light: *CrowdSec Alert*
{{range .}}
*Scenario:* {{.Alert.Scenario}}
*Source IP:* {{.Alert.Source.IP}} ({{.Alert.Source.Cn}})
*Decisions:*
{{range .Alert.Decisions}} - {{.Type}} for {{.Duration}} (scope: {{.Scope}}, value: {{.Value}})
{{end}}
{{end}}
webhook: ${SLACK_WEBHOOK_URL}

View file

@ -1,88 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "cyberchef" {
metadata {
name = "cyberchef"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.cyberchef.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "cyberchef" {
metadata {
name = "cyberchef"
namespace = kubernetes_namespace.cyberchef.metadata[0].name
labels = {
app = "cyberchef"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate"
}
selector {
match_labels = {
app = "cyberchef"
}
}
template {
metadata {
labels = {
app = "cyberchef"
}
}
spec {
container {
image = "mpepping/cyberchef:latest"
name = "cyberchef"
port {
container_port = 8000
}
}
}
}
}
}
resource "kubernetes_service" "cyberchef" {
metadata {
name = "cc"
namespace = kubernetes_namespace.cyberchef.metadata[0].name
labels = {
"app" = "cyberchef"
}
}
spec {
selector = {
app = "cyberchef"
}
port {
name = "http"
target_port = 8000
port = 80
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.cyberchef.metadata[0].name
name = "cc"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "7c460afc68c4"
}

View file

@ -1,440 +0,0 @@
pageInfo:
title: Dashy
description: Welcome to your new dashboard!
navLinks:
- title: GitHub
path: https://github.com/Lissy93/dashy
- title: Documentation
path: https://dashy.to/docs
appConfig:
theme: material
layout: auto
iconSize: large
sections:
- name: Infra
icon: si-databricks
displayData:
sortBy: alphabetical
rows: 2
cols: 2
collapsed: false
hideForGuests: false
items:
- &ref_0
title: ESXi R730 (Server)
description: R730 esxi UI
icon: si-vmware
url: https://esxi.viktorbarzin.me/ui/#/login
target: newtab
id: 0_496_esxirserver
- &ref_1
title: PFsense (Firewall)
description: Firewall
icon: si-pfsense
url: https://pfsense.viktorbarzin.me
target: newtab
id: 1_496_pfsensefirewall
- &ref_2
title: iDRAC
description: ""
icon: si-dell
url: https://idrac.viktorbarzin.me/
target: newtab
id: 2_496_idrac
- &ref_3
title: TP-Link Gateway Router
icon: hl-asus-router
url: https://gw.viktorbarzin.me/webpages/login.html
id: 3_496_tplinkgatewayrouter
- &ref_4
title: Home Assistant London
description: Home Assistant London Deployment
icon: si-homeassistant
url: http://ha-london.viktorbarzin.me/
target: newtab
id: 4_496_homeassistantlondon
- &ref_5
title: NAS
description: ""
icon: si-synology
url: https://nas.viktorbarzin.me/
id: 5_496_nas
- &ref_6
title: Server Switch
description: TP-Link Extension Switch
icon: 🔀
url: http://192.168.1.6/
target: newtab
id: 6_496_serverswitch
- &ref_7
title: Home Assistant Sofia
description: Home Assistant Sofia Deployment
icon: si-homeassistant
url: http://ha-sofia.viktorbarzin.me/
target: newtab
id: 7_496_homeassistantsofia
- &ref_8
title: IP Cameras
description: Frigate
icon: si-protodotio
url: https://frigate.viktorbarzin.me
target: newtab
id: 8_496_ipcameras
filteredItems:
- *ref_0
- *ref_1
- *ref_2
- *ref_3
- *ref_4
- *ref_5
- *ref_6
- *ref_7
- *ref_8
- name: Valchedrym Infra
displayData:
sortBy: default
rows: 2
cols: 2
collapsed: false
hideForGuests: false
items:
- &ref_9
title: Valchedrym OpenWRT
icon: si-openwrt
url: https://valchedrym.viktorbarzin.me/
target: newtab
id: 0_1567_valchedrymopenwrt
- &ref_10
title: Valchedram Video System
icon: 📷
url: http://valchedrym-video.viktorbarzin.me:5080/
target: newtab
id: 1_1567_valchedramvideosystem
- &ref_11
title: Mladost 3 Router
icon: si-ghostery
url: https://mladost3.viktorbarzin.me/
target: newtab
id: 2_1567_mladostrouter
- &ref_12
title: Valchedrym Services Uptime
description: Uptime Dashboard for Valchedrym Services
icon: si-openwrt
url: https://uptime.viktorbarzin.me/status/valchedrym
target: newtab
id: 3_1567_valchedrymservicesuptime
icon: 🐶
filteredItems:
- *ref_9
- *ref_10
- *ref_11
- *ref_12
- name: Monitoring
icon: hl-grafana
displayData:
sortBy: alphabetical
rows: 3
collapsed: false
hideForGuests: false
cols: 2
items:
- &ref_13
title: Uptime Kuma
description: Internal Uptime Monitoring
icon: si-uptimekuma
url: https://uptime.viktorbarzin.me/status/cluster-internal
target: newtab
id: 0_1062_uptimekuma
- &ref_14
title: iDRAC Grafana
icon: si-dell
url: https://grafana.viktorbarzin.me/d/O19gr0jZk/idrac-host-stats
target: newtab
statusCheckAcceptCodes: "400"
id: 1_1062_idracgrafana
- &ref_15
title: Kubernetes Cluster Nodes
description: Kubernetes Nodes Stats
icon: hl-kubernetes
url: https://grafana.viktorbarzin.me/d/xfpJB9FGz/node-exporter?orgId=1
target: newtab
statusCheckAcceptCodes: "400"
id: 2_1062_kubernetesclusternodes
- &ref_16
title: OpenWRT (London)
icon: si-openwrt
url: https://grafana.viktorbarzin.me/d/fLi0yXAWk/openwrt?orgId=1
target: newtab
statusCheckAcceptCodes: "400"
id: 3_1062_openwrtlondon
- &ref_17
title: Prometheus
icon: si-prometheus
url: https://prometheus.viktorbarzin.me/
statusCheck: false
statusCheckAcceptCodes: "400"
id: 4_1062_prometheus
- &ref_18
title: Alert Manager
icon: si-protractor
url: https://alertmanager.viktorbarzin.me/
target: newtab
id: 5_1062_alertmanager
- &ref_19
title: External Monitoring
description: Hetrix report
icon: si-amp
url: https://wl.hetrixtools.com/r/38981b548b5d38b052aca8d01285a3f3/
target: modal
id: 6_1062_externalmonitoring
- &ref_20
title: K8S Dashboard
description: Kubernetes dashboard with view of all nodes, pods etc
icon: si-kubernetes
url: https://k8s.viktorbarzin.me/#/node
id: 7_1062_ksdashboard
filteredItems:
- *ref_13
- *ref_14
- *ref_15
- *ref_16
- *ref_17
- *ref_18
- *ref_19
- *ref_20
- name: Infra Services
displayData:
sortBy: default
rows: 3
cols: 2
collapsed: false
hideForGuests: false
items:
- &ref_21
title: PhpMyAdmin
description: Admin UI for the DB Cluster
icon: si-phpmyadmin
url: https://pma.viktorbarzin.me/index.php
displayData: ttt
target: newtab
statusCheck: false
id: 0_1364_phpmyadmin
- &ref_22
title: Drone CI
description: CI/CD Service
icon: si-drone
url: https://drone.viktorbarzin.me/
target: newtab
id: 1_1364_droneci
- &ref_23
title: DNS Server
description: Technitium
icon: hl-azure-dns
url: https://technitium.viktorbarzin.me/
target: newtab
statusCheck: false
statusCheckAcceptCodes: "400"
id: 2_1364_dnsserver
- &ref_24
title: Headscale (VPN) UI
icon: si-wireguard
url: https://headscale.viktorbarzin.me/manager
target: newtab
statusCheck: false
statusCheckAcceptCodes: "400"
id: 3_1364_headscalevpnui
- &ref_25
title: URL Shorterner
description: Shlink
icon: si-curl
url: https://shlink.viktorbarzin.me
statusCheck: false
statusCheckAcceptCodes: "400"
id: 4_1364_urlshorterner
- &ref_26
title: Crowdsec Dashboard
icon: si-crowdsource
url: >-
https://crowdsec.viktorbarzin.me/public/dashboard/8f6226be-d4dc-45f1-bacf-a4584f71dcb0
target: newtab
id: 5_1364_crowdsecdashboard
- &ref_27
title: Redis
description: Redis
icon: si-redis
url: https://redis.viktorbarzin.me/
target: newtab
id: 6_1364_redis
- &ref_28
title: Truenas
description: Network Storage VM
icon: si-truenas
url: http://truenas.viktorbarzin.me/ui/dashboard
id: 7_1364_truenas
icon: si-adminer
filteredItems:
- *ref_21
- *ref_22
- *ref_23
- *ref_24
- *ref_25
- *ref_26
- *ref_27
- *ref_28
- name: Public Services
displayData:
sortBy: alphabetical
rows: 2
cols: 4
collapsed: false
hideForGuests: false
items:
- &ref_29
title: City Guesser
description: Geolocator Game
icon: hl-openmaptiles
url: https://city-guesser.viktorbarzin.me/
target: newtab
statusCheck: false
id: 0_1475_cityguesser
- &ref_30
title: Excalidraw
description: Collaborative Hand Drawing Tool
icon: hl-excalidraw-light
url: https://excalidraw.viktorbarzin.me
target: newtab
statusCheck: false
id: 1_1475_excalidraw
- &ref_31
title: Formula 1 Stream
icon: si-f1
url: http://f1.viktorbarzin.me/
statusCheck: false
id: 2_1475_formulastream
- &ref_32
title: HackMD
description: Collaborative Markdown Document Editing
icon: si-hackclub
url: https://hackmd.viktorbarzin.me/
statusCheck: false
id: 3_1475_hackmd
- &ref_33
title: Activate Windows (KMS)
description: How to activate Windows Machines
icon: si-windows95
url: https://kms.viktorbarzin.me/
statusCheck: false
id: 4_1475_activatewindowskms
- &ref_34
title: PrivateBin
description: E2E Encrypted Pastebin
icon: si-pastebin
url: https://pb.viktorbarzin.me/
statusCheck: false
id: 5_1475_privatebin
- &ref_35
title: Blog
description: Personal Blog
icon: si-rss
url: https://viktorbarzin.me/
statusCheck: false
id: 6_1475_blog
- &ref_36
title: Setup VPN (Tailscale)
description: "URL to set in app config: https://headscale.viktorbarzin.me"
icon: si-wireguard
url: https://github.com/juanfont/headscale/blob/main/docs/iOS-client.md
target: newtab
id: 7_1475_setupvpntailscale
- &ref_37
title: Vaultwarden
description: Self-hosted Bitwarden server (Password Manager)
icon: si-bitwarden
url: https://vaultwarden.viktorbarzin.me
target: newtab
id: 8_1475_vaultwarden
- &ref_38
title: Send
description: Share files
icon: si-libreoffice
url: https://send.viktorbarzin.me/
target: newtab
id: 9_1475_send
- &ref_39
title: Youtube Downloader
icon: si-youtube
url: https://yt.viktorbarzin.me
target: newtab
id: 10_1475_youtubedownloader
- &ref_40
title: Photos
description: Immich
icon: si-immich
url: https://photos.viktorbarzin.me
target: newtab
id: 11_1475_photos
- &ref_41
title: Audiobookshelf
description: >-
Audiobook shelf. For iOS, install app from
https://url.viktorbarzin.me/audiobookshelf
icon: si-audible
url: https://audiobookshelf.viktorbarzin.me/
target: newtab
id: 12_1475_audiobookshelf
- &ref_42
title: Ollama
description: Self-hosted ChatGPT (using llama3)
icon: si-openai
url: https://ollama.viktorbarzin.me/
target: newtab
id: 13_1475_ollama
- &ref_43
title: Paperless-ngx
description: Document index
icon: hl-paperless-ngx
url: https://pdf.viktorbarzin.me/
target: newtab
id: 14_1475_paperlessngx
icon: si-sublimetext
filteredItems:
- *ref_29
- *ref_30
- *ref_31
- *ref_32
- *ref_33
- *ref_34
- *ref_35
- *ref_36
- *ref_37
- *ref_38
- *ref_39
- *ref_40
- *ref_41
- *ref_42
- *ref_43
- name: Under Construction
displayData:
sortBy: alphabetical
rows: 1
cols: 1
collapsed: false
hideForGuests: false
items:
- &ref_44
title: Travel Blog
icon: si-hugo
url: https://travel.viktorbarzin.me/
target: newtab
statusCheck: false
id: 0_1833_travelblog
- &ref_45
title: Personal Finance App
icon: si-abstract
url: https://finance.viktorbarzin.me/transaction
statusCheck: false
id: 1_1833_personalfinanceapp
icon: si-progress
filteredItems:
- *ref_44
- *ref_45

View file

@ -1,126 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.dashy.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "dashy" {
metadata {
name = "dashy"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
resource "kubernetes_config_map" "config" {
metadata {
name = "config"
namespace = kubernetes_namespace.dashy.metadata[0].name
annotations = {
"reloader.stakater.com/match" = "true"
}
}
data = {
"conf.yml" = file("${path.module}/conf.yml")
}
}
resource "kubernetes_deployment" "dashy" {
metadata {
name = "dashy"
namespace = kubernetes_namespace.dashy.metadata[0].name
labels = {
app = "dashy"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "dashy"
}
}
template {
metadata {
annotations = {
# "diun.enable" = "true"
}
labels = {
app = "dashy"
}
}
spec {
container {
image = "lissy93/dashy:latest"
name = "dashy"
resources {
requests = {
cpu = "50m"
memory = "256Mi"
}
limits = {
cpu = "1"
memory = "2Gi"
}
}
port {
container_port = 8080
}
volume_mount {
name = "config"
mount_path = "/app/user-data/"
}
}
volume {
name = "config"
config_map {
name = "config"
}
}
}
}
}
}
resource "kubernetes_service" "dashy" {
metadata {
name = "dashy"
namespace = kubernetes_namespace.dashy.metadata[0].name
labels = {
app = "dashy"
}
}
spec {
selector = {
app = "dashy"
}
port {
name = "http"
port = 80
target_port = 8080
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.dashy.metadata[0].name
name = "dashy"
tls_secret_name = var.tls_secret_name
protected = true # hidden as we use homepage now
}

View file

@ -1,325 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "database_password" {}
variable "geoapify_api_key" {}
variable "image_version" {
type = string
default = "0.37.1"
}
resource "kubernetes_namespace" "dawarich" {
metadata {
name = "dawarich"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.dawarich.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "dawarich" {
metadata {
name = "dawarich"
namespace = kubernetes_namespace.dawarich.metadata[0].name
labels = {
app = "dawarich"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "dawarich"
}
}
template {
metadata {
labels = {
app = "dawarich"
}
annotations = {
# "diun.enable" = "true"
# "diun.include_tags" = "latest"
}
}
spec {
container {
image = "freikin/dawarich:${var.image_version}"
name = "dawarich"
port {
name = "http"
container_port = 3000
}
port {
name = "prometheus"
container_port = 9394
}
command = ["web-entrypoint.sh"]
args = ["bin/dev"]
env {
name = "REDIS_URL"
value = "redis://redis.redis.svc.cluster.local:6379"
}
env {
name = "DATABASE_HOST"
value = "postgresql.dbaas"
}
env {
name = "DATABASE_USERNAME"
value = "dawarich"
}
env {
name = "DATABASE_PASSWORD"
value = var.database_password
}
env {
name = "DATABASE_NAME"
value = "dawarich"
}
env {
name = "MIN_MINUTES_SPENT_IN_CITY"
value = "60"
}
env {
name = "TIME_ZONE"
value = "Europe/London"
}
env {
name = "DISTANCE_UNIT"
value = "km"
}
env {
name = "ENABLE_TELEMETRY"
value = "true"
}
env {
name = "APPLICATION_HOSTS"
value = "dawarich.viktorbarzin.me"
}
# env {
# name = "PROMETHEUS_EXPORTER_ENABLED"
# value = "true"
# }
# env {
# name = "PROMETHEUS_EXPORTER_PORT"
# value = "9394"
# }
# env {
# name = "PROMETHEUS_EXPORTER_HOST"
# value = "0.0.0.0"
# }
env {
name = "SELF_HOSTED"
value = "true"
}
# env {
# name = "PHOTON_API_HOST"
# value = "photon.dawarich"
# }
# volume_mount {
# name = "watched"
# mount_path = "/var/app/tmp/imports/watched"
# }
}
# container {
# image = "freikin/dawarich:${var.image_version}"
# name = "dawarich-sidekiq"
# command = ["sidekiq-entrypoint.sh"]
# args = ["bundle exec sidekiq"]
# env {
# name = "REDIS_URL"
# value = "redis://redis.redis.svc.cluster.local:6379"
# }
# env {
# name = "DATABASE_HOST"
# value = "postgresql.dbaas"
# }
# env {
# name = "DATABASE_USERNAME"
# value = "dawarich"
# }
# env {
# name = "DATABASE_PASSWORD"
# value = var.database_password
# }
# env {
# name = "DATABASE_NAME"
# value = "dawarich"
# }
# env {
# name = "MIN_MINUTES_SPENT_IN_CITY"
# value = "60"
# }
# env {
# name = "BACKGROUND_PROCESSING_CONCURRENCY"
# value = "10"
# }
# env {
# name = "ENABLE_TELEMETRY"
# value = "true"
# }
# env {
# name = "APPLICATION_HOST"
# value = "dawarich.viktorbarzin.me"
# }
# # env {
# # name = "PROMETHEUS_EXPORTER_ENABLED"
# # value = "false"
# # }
# # env {
# # name = "PROMETHEUS_EXPORTER_HOST"
# # value = "dawarich.dawarich"
# # }
# # env {
# # name = "PHOTON_API_HOST"
# # value = "photon.dawarich:2322"
# # # value = "photon.komoot.io"
# # }
# # env {
# # name = "PHOTON_API_USE_HTTPS"
# # value = "false"
# # }
# env {
# name = "GEOAPIFY_API_KEY"
# value = var.geoapify_api_key
# }
# env {
# name = "SELF_HOSTED"
# value = "true"
# }
# # volume_mount {
# # name = "watched"
# # mount_path = "/var/app/tmp/imports/watched"
# # }
# }
}
}
}
}
# resource "kubernetes_deployment" "photon" {
# metadata {
# name = "photon"
# namespace = kubernetes_namespace.dawarich.metadata[0].name
# labels = {
# app = "photon"
# }
# }
# spec {
# replicas = 1
# strategy {
# type = "Recreate"
# }
# selector {
# match_labels = {
# app = "photon"
# }
# }
# template {
# metadata {
# labels = {
# app = "photon"
# }
# }
# spec {
# container {
# image = "rtuszik/photon-docker:latest"
# name = "photon"
# port {
# name = "tcp"
# container_port = 2322
# }
# env {
# name = "COUNTRY_CODE"
# value = "bg"
# }
# volume_mount {
# name = "data"
# mount_path = "/photon/photon_data"
# }
# }
# volume {
# name = "data"
# nfs {
# path = "/mnt/main/photon"
# server = "10.0.10.15"
# }
# }
# }
# }
# }
# }
resource "kubernetes_service" "dawarich" {
metadata {
name = "dawarich"
namespace = kubernetes_namespace.dawarich.metadata[0].name
labels = {
"app" = "dawarich"
}
}
spec {
selector = {
app = "dawarich"
}
port {
name = "http"
port = 80
target_port = 3000
protocol = "TCP"
}
}
}
# resource "kubernetes_service" "photon" {
# metadata {
# name = "photon"
# namespace = kubernetes_namespace.dawarich.metadata[0].name
# labels = {
# "app" = "photon"
# }
# }
# spec {
# selector = {
# app = "photon"
# }
# port {
# name = "http"
# port = 2322
# target_port = 2322
# protocol = "TCP"
# }
# }
# }
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.dawarich.metadata[0].name
name = "dawarich"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "0abfd409f2fb"
}

View file

@ -1,17 +0,0 @@
tls:
useSelfSigned: true
credentials:
root:
password: ${root_password}
user: root
serverInstances: 1
podSpec:
containers:
- name: mysql
resources:
requests:
memory: "1024Mi" # adapt to your needs
cpu: "1800m" # adapt to your needs
limits:
memory: "2048Mi" # adapt to your needs
cpu: "3600m" # adapt to your needs

View file

@ -1,30 +0,0 @@
apiVersion: mysql.presslabs.org/v1alpha1
kind: MysqlCluster
metadata:
name: mysql-cluster
namespace: dbaas
spec:
mysqlVersion: "5.7"
replicas: 1
secretName: cluster-secret
mysqlConf:
# read_only: 0 # mysql forms a single transaction for each sql statement, autocommit for each statement
# automatic_sp_privileges: "ON" # automatically grants the EXECUTE and ALTER ROUTINE privileges to the creator of a stored routine
# auto_generate_certs: "ON" # Auto Generation of Certificate
# auto_increment_increment: 1 # Auto Incrementing value from +1
# auto_increment_offset: 1 # Auto Increment Offset
# binlog-format: "STATEMENT" # contains various options such ROW(SLOW,SAFE) STATEMENT(FAST,UNSAFE), MIXED(combination of both)
# wait_timeout: 31536000 # 28800 number of seconds the server waits for activity on a non-interactive connection before closing it, You might encounter MySQL server has gone away error, you then tweak this value acccordingly
# interactive_timeout: 28800 # The number of seconds the server waits for activity on an interactive connection before closing it.
# max_allowed_packet: "512M" # Maximum size of MYSQL Network protocol packet that the server can create or read 4MB, 8MB, 16MB, 32MB
# max-binlog-size: 1073741824 # binary logs contains the events that describe database changes, this parameter describe size for the bin_log file.
# log_output: "TABLE" # Format in which the logout will be dumped
# master-info-repository: "TABLE" # Format in which the master info will be dumped
# relay_log_info_repository: "TABLE" # Format in which the relay info will be dumped
volumeSpec:
persistentVolumeClaim:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

View file

@ -1,916 +0,0 @@
# DB as a service. Installs MySQL operator
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "dbaas_root_password" {}
variable "cluster_master_service" {
default = "mysql"
}
variable "postgresql_root_password" {}
variable "pgadmin_password" {}
variable "prod" {
default = false
type = bool
}
resource "kubernetes_namespace" "dbaas" {
metadata {
name = "dbaas"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.dbaas.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_config_map" "mycnf" {
metadata {
name = "mycnf"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/match" = "true"
}
}
data = {
"my.cnf" = <<-EOT
# For advice on how to change settings please see
# http://dev.mysql.com/doc/refman/8.2/en/server-configuration-defaults.html
[mysqld]
#
# Remove leading # and set to the amount of RAM for the most important data
# cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%.
# innodb_buffer_pool_size = 128M
#
# Remove leading # to turn on a very important data integrity option: logging
# changes to the binary log between backups.
# log_bin
#
# Remove leading # to set options mainly useful for reporting servers.
# The server defaults are faster for transactions and fast SELECTs.
# Adjust sizes as needed, experiment to find the optimal values.
# join_buffer_size = 128M
# sort_buffer_size = 2M
# read_rnd_buffer_size = 2M
# Remove leading # to revert to previous value for default_authentication_plugin,
# this will increase compatibility with older clients. For background, see:
# https://dev.mysql.com/doc/refman/8.2/en/server-system-variables.html#sysvar_default_authentication_plugin
# default-authentication-plugin=mysql_native_password
#skip-host-cache
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql
#innodb_force_recovery = 6
#log_error_verbosity = 6
pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock
!includedir /etc/mysql/conf.d/
EOT
}
}
resource "kubernetes_service" "mysql" {
metadata {
name = var.cluster_master_service
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
app = "mysql"
}
port {
port = 3306
}
}
}
resource "kubernetes_deployment" "mysql" {
metadata {
name = "mysql"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/search" = "true"
}
labels = {
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "mysql"
}
}
strategy {
type = "Recreate"
}
template {
metadata {
labels = {
app = "mysql"
}
annotations = {
"diun.enable" = "false"
"diun.include_tags" = "^\\d+(?:\\.\\d+)?(?:\\.\\d+)?$"
}
}
spec {
container {
image = "mysql:9.2.0"
name = "mysql"
env {
name = "MYSQL_ROOT_PASSWORD"
value = var.dbaas_root_password
}
port {
container_port = 3306
name = "mysql"
}
volume_mount {
name = "mysql-persistent-storage"
mount_path = "/var/lib/mysql"
}
volume_mount {
name = "mycnf"
mount_path = "/etc/my.cnf"
sub_path = "my.cnf"
}
}
volume {
name = "mysql-persistent-storage"
nfs {
path = "/mnt/main/mysql"
server = "10.0.10.15"
}
}
volume {
name = "mycnf"
config_map {
name = "mycnf"
}
}
}
}
}
}
resource "kubernetes_cron_job_v1" "mysql-backup" {
metadata {
name = "mysql-backup"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 5
schedule = "0 0 * * *"
# schedule = "* * * * *"
starting_deadline_seconds = 10
successful_jobs_history_limit = 10
job_template {
metadata {}
spec {
backoff_limit = 3
ttl_seconds_after_finished = 10
template {
metadata {}
spec {
container {
name = "mysql-backup"
image = "mysql"
# TODO: would be nice to rotate at some point... Current size is 11MB so not really needed atm
command = ["/bin/bash", "-c", <<-EOT
set -euxo pipefail
export now=$(date +"%Y_%m_%d_%H_%M")
mysqldump --all-databases -u root -p${var.dbaas_root_password} --host mysql.dbaas.svc.cluster.local > /backup/dump_$now.sql
# Rotate - delete last log file
cd /backup
find . -name "dump_*.sql" -type f -mtime +14 -delete # 14 day retention of backups
echo Done
EOT
]
# To restore (from outside of the cluster):
# run kubectl port-forward to pod e.g.:
# > kb port-forward mysql-647cfd4969-46rmw --address 0.0.0.0 3307:3306
# run mysql import (and specify non-localhost address to avoid using unix socket): (password is in tfvars)
# > mysql -u root -p --host 10.0.10.10 --port 3307 < /mnt/nfs/2024_01_06_13_54.sql
volume_mount {
name = "mysql-backup"
mount_path = "/backup"
}
}
volume {
name = "mysql-backup"
nfs {
path = "/mnt/main/mysql-backup"
server = "10.0.10.15"
}
}
}
}
}
}
}
}
# resource "kubernetes_persistent_volume" "mysql" {
# metadata {
# name = "mysql-pv"
# }
# spec {
# capacity = {
# "storage" = "10Gi"
# }
# access_modes = ["ReadWriteOnce"]
# persistent_volume_source {
# iscsi {
# target_portal = "iscsi.viktorbarzin.lan:3260"
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:dbaas:mysql"
# lun = 0
# fs_type = "ext4"
# }
# }
# }
# }
# resource "helm_release" "mysql" {
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# create_namespace = false
# name = "mysql"
# repository = "https://presslabs.github.io/charts"
# chart = "mysql-operator"
# # version = "v0.5.0-rc.3"
# values = [templatefile("${path.module}/mysql_chart_values.yaml", { secretName = var.tls_secret_name })]
# atomic = true
# depends_on = [kubernetes_namespace.dbaas]
# }
# # resource "helm_release" "mysql" {
# # namespace = kubernetes_namespace.dbaas.metadata[0].name
# # create_namespace = false
# # name = "mysql-operator"
# # repository = "https://mysql.github.io/mysql-operator/"
# # chart = "mysql-operator"
# # atomic = true
# # depends_on = [kubernetes_namespace.dbaas]
# # }
# # resource "helm_release" "innodb-cluster" {
# # namespace = kubernetes_namespace.dbaas.metadata[0].name
# # create_namespace = false
# # name = var.cluster_master_service
# # repository = "https://mysql.github.io/mysql-operator/"
# # chart = "mysql-innodbcluster"
# # atomic = true
# # depends_on = [kubernetes_namespace.dbaas]
# # values = [templatefile("${path.module}/chart_values.tpl", { root_password = var.dbaas_root_password })]
# # }
# resource "kubernetes_persistent_volume" "mysql-operator" {
# metadata {
# name = "mysql-operator-pv"
# }
# spec {
# capacity = {
# "storage" = "1Gi"
# }
# access_modes = ["ReadWriteOnce"]
# persistent_volume_source {
# iscsi {
# target_portal = "iscsi.viktorbarzin.lan:3260"
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:dbaas:operator"
# lun = 0
# fs_type = "ext4"
# }
# }
# }
# }
resource "kubernetes_secret" "cluster-password" {
metadata {
name = "cluster-secret"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/match" = "true"
}
}
type = "Opaque"
data = {
"ROOT_PASSWORD" = var.dbaas_root_password
}
}
# resource "kubernetes_ingress_v1" "dbaas" {
# metadata {
# name = "orchestrator-ingress"
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# annotations = {
# "kubernetes.io/ingress.class" = "nginx"
# "nginx.ingress.kubernetes.io/auth-tls-verify-client" = "on"
# "nginx.ingress.kubernetes.io/auth-tls-secret" = "default/ca-secret"
# }
# }
# spec {
# tls {
# hosts = ["db.viktorbarzin.me"]
# secret_name = var.tls_secret_name
# }
# rule {
# host = "db.viktorbarzin.me"
# http {
# path {
# path = "/"
# backend {
# service {
# name = "mysql-mysql-operator"
# port {
# number = 80
# }
# }
# }
# }
# }
# }
# }
# }
# PHPMyAdmin instance
resource "kubernetes_deployment" "phpmyadmin" {
metadata {
name = "phpmyadmin"
namespace = kubernetes_namespace.dbaas.metadata[0].name
labels = {
"app" = "phpmyadmin"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = "1"
selector {
match_labels = {
"app" = "phpmyadmin"
}
}
template {
metadata {
labels = {
"app" = "phpmyadmin"
}
}
spec {
container {
name = "phpmyadmin"
image = "phpmyadmin/phpmyadmin:5.2.3"
port {
container_port = 80
}
env {
name = "PMA_HOST"
value = var.cluster_master_service
}
env {
name = "PMA_PORT"
value = "3306"
}
env {
name = "MYSQL_ROOT_PASSWORD"
value_from {
secret_key_ref {
name = "cluster-secret"
key = "ROOT_PASSWORD"
}
}
}
env {
name = "UPLOAD_LIMIT"
value = "300M"
}
}
}
}
}
}
resource "kubernetes_service" "phpmyadmin" {
metadata {
name = "pma"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
"app" = "phpmyadmin"
}
port {
name = "web"
port = 80
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.dbaas.metadata[0].name
name = "pma"
tls_secret_name = var.tls_secret_name
protected = true
extra_annotations = {}
rybbit_site_id = "942c76b8bd4d"
custom_content_security_policy = "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://rybbit.viktorbarzin.me"
}
# resource "kubectl_manifest" "mysql-cluster" {
# yaml_body = <<-YAML
# apiVersion: mysql.presslabs.org/v1alpha1
# kind: MysqlCluster
# metadata:
# name: mysql-cluster
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# spec:
# mysqlVersion: "5.7"
# replicas: 1
# secretName: cluster-secret
# mysqlConf:
# # read_only: 0 # mysql forms a single transaction for each sql statement, autocommit for each statement
# # automatic_sp_privileges: "ON" # automatically grants the EXECUTE and ALTER ROUTINE privileges to the creator of a stored routine
# # auto_generate_certs: "ON" # Auto Generation of Certificate
# # auto_increment_increment: 1 # Auto Incrementing value from +1
# # auto_increment_offset: 1 # Auto Increment Offset
# # binlog-format: "STATEMENT" # contains various options such ROW(SLOW,SAFE) STATEMENT(FAST,UNSAFE), MIXED(combination of both)
# # wait_timeout: 31536000 # 28800 number of seconds the server waits for activity on a non-interactive connection before closing it, You might encounter MySQL server has gone away error, you then tweak this value acccordingly
# # interactive_timeout: 28800 # The number of seconds the server waits for activity on an interactive connection before closing it.
# # max_allowed_packet: "512M" # Maximum size of MYSQL Network protocol packet that the server can create or read 4MB, 8MB, 16MB, 32MB
# # max-binlog-size: 1073741824 # binary logs contains the events that describe database changes, this parameter describe size for the bin_log file.
# # log_output: "TABLE" # Format in which the logout will be dumped
# # master-info-repository: "TABLE" # Format in which the master info will be dumped
# # relay_log_info_repository: "TABLE" # Format in which the relay info will be dumped
# volumeSpec:
# persistentVolumeClaim:
# accessModes:
# - ReadWriteOnce
# resources:
# requests:
# storage: 10Gi
# YAML
# depends_on = [helm_release.mysql]
# # manifest = {
# # apiVersion = "mysql.presslabs.org/v1alpha1"
# # kind = "MysqlCluster"
# # metadata = {
# # name = "mysql-cluster"
# # namespace = kubernetes_namespace.dbaas.metadata[0].name
# # }
# # spec = {
# # mysqlVersion = "5.7"
# # replicas = 1
# # secretName = "cluster-secret"
# # mysqlConf = {
# # read_only = 0
# # }
# # volumeSpec = {
# # persistentVolumeClaim = {
# # resources = {
# # requests = {
# # storage = "10Gi"
# # }
# # }
# # }
# # }
# # }
# # }
# }
# For some unknwown reason not all CRDs are installed. Add them manually
# resource "kubectl_manifest" "mysql-user" {
# yaml_body = <<-EOF
# apiVersion: apiextensions.k8s.io/v1
# kind: CustomResourceDefinition
# metadata:
# annotations:
# controller-gen.kubebuilder.io/version: v0.5.0
# helm.sh/hook: crd-install
# name: mysqlusers.mysql.presslabs.org
# labels:
# app: mysql-operator
# spec:
# group: mysql.presslabs.org
# names:
# kind: MysqlUser
# listKind: MysqlUserList
# plural: mysqlusers
# singular: mysqluser
# scope:namespace = kubernetes_namespace.dbaas.metadata[0].name
# versions:
# - additionalPrinterColumns:
# - description: The user status
# jsonPath: .status.conditions[?(@.type == 'Ready')].status
# name: Ready
# type: string
# - jsonPath: .spec.clusterRef.name
# name: Cluster
# type: string
# - jsonPath: .spec.user
# name: UserName
# type: string
# - jsonPath: .metadata.creationTimestamp
# name: Age
# type: date
# name: v1alpha1
# schema:
# openAPIV3Schema:
# description: MysqlUser is the Schema for the MySQL User API
# properties:
# apiVersion:
# description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
# type: string
# kind:
# description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
# type: string
# metadata:
# type: object
# spec:
# description: MysqlUserSpec defines the desired state of MysqlUserSpec
# properties:
# allowedHosts:
# description: AllowedHosts is the allowed host to connect from.
# items:
# type: string
# type: array
# clusterRef:
# description: ClusterRef represents a reference to the MySQL cluster. This field should be immutable.
# properties:
# name:
# description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?'
# type: string
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# description:namespace = kubernetes_namespace.dbaas.metadata[0].name
# type: string
# type: object
# password:
# description: Password is the password for the user.
# properties:
# key:
# description: The key of the secret to select from. Must be a valid secret key.
# type: string
# name:
# description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?'
# type: string
# optional:
# description: Specify whether the Secret or its key must be defined
# type: boolean
# required:
# - key
# type: object
# permissions:
# description: Permissions is the list of roles that user has in the specified database.
# items:
# description: MysqlPermission defines a MySQL schema permission
# properties:
# permissions:
# description: Permissions represents the permissions granted on the schema/tables
# items:
# type: string
# type: array
# schema:
# description: Schema represents the schema to which the permission applies
# type: string
# tables:
# description: Tables represents the tables inside the schema to which the permission applies
# items:
# type: string
# type: array
# required:
# - permissions
# - schema
# - tables
# type: object
# type: array
# resourceLimits:
# additionalProperties:
# anyOf:
# - type: integer
# - type: string
# pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
# x-kubernetes-int-or-string: true
# description: 'ResourceLimits allow settings limit per mysql user as defined here: https://dev.mysql.com/doc/refman/5.7/en/user-resources.html'
# type: object
# user:
# description: User is the name of the user that will be created with will access the specified database. This field should be immutable.
# type: string
# required:
# - allowedHosts
# - clusterRef
# - password
# - user
# type: object
# status:
# description: MysqlUserStatus defines the observed state of MysqlUser
# properties:
# allowedHosts:
# description: AllowedHosts contains the list of hosts that the user is allowed to connect from.
# items:
# type: string
# type: array
# conditions:
# description: Conditions represents the MysqlUser resource conditions list.
# items:
# description: MySQLUserCondition defines the condition struct for a MysqlUser resource
# properties:
# lastTransitionTime:
# description: Last time the condition transitioned from one status to another.
# format: date-time
# type: string
# lastUpdateTime:
# description: The last time this condition was updated.
# format: date-time
# type: string
# message:
# description: A human readable message indicating details about the transition.
# type: string
# reason:
# description: The reason for the condition's last transition.
# type: string
# status:
# description: Status of the condition, one of True, False, Unknown.
# type: string
# type:
# description: Type of MysqlUser condition.
# type: string
# required:
# - lastTransitionTime
# - message
# - reason
# - status
# - type
# type: object
# type: array
# type: object
# type: object
# served: true
# storage: true
# subresources:
# status: {}
# EOF
# }
resource "kubernetes_deployment" "postgres" {
metadata {
name = "postgresql"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/search" = "true"
}
labels = {
tier = var.tier
}
}
spec {
selector {
match_labels = {
app = "postgresql"
}
}
strategy {
type = "Recreate"
}
template {
metadata {
labels = {
app = "postgresql"
}
annotations = {
"diun.enable" = "false"
"diun.include_tags" = "^\\d+(?:\\.\\d+)?-bullseye$"
}
}
spec {
container {
# image = "postgis/postgis:16-master"
image = "viktorbarzin/postgres:16-master" # mix of postgis + pgvector
# image = "postgres:17.2-bullseye" # needs pg_upgrade to data dir
name = "postgresql"
env {
name = "POSTGRES_PASSWORD"
value = var.postgresql_root_password
}
env {
name = "POSTGRES_USER"
value = "root"
}
port {
container_port = 5432
protocol = "TCP"
name = "postgresql"
}
volume_mount {
name = "postgresql-persistent-storage"
mount_path = "/var/lib/postgresql/data"
}
# volume_mount {
# name = "mycnf"
# mount_path = "/etc/my.cnf"
# sub_path = "my.cnf"
# }
}
volume {
name = "postgresql-persistent-storage"
nfs {
path = "/mnt/main/postgresql/data"
server = "10.0.10.15"
}
}
# volume {
# name = "mycnf"
# config_map {
# name = "mycnf"
# }
# }
}
}
}
}
resource "kubernetes_service" "postgresql" {
metadata {
name = "postgresql"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
"app" = "postgresql"
}
port {
name = "postgresql"
port = 5432
target_port = 5432
}
}
}
#### PGADMIN
resource "kubernetes_deployment" "pgadmin" {
metadata {
name = "pgadmin"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/search" = "true"
}
labels = {
tier = var.tier
}
}
spec {
selector {
match_labels = {
app = "pgadmin"
}
}
template {
metadata {
labels = {
app = "pgadmin"
}
}
spec {
container {
image = "dpage/pgadmin4"
name = "pgadmin"
env {
name = "PGADMIN_DEFAULT_EMAIL"
value = "me@viktorbarzin.me"
}
env {
name = "PGADMIN_DEFAULT_PASSWORD"
# Changed at startup
value = var.pgadmin_password
}
port {
container_port = 80
name = "web"
}
volume_mount {
name = "pgadmin"
mount_path = "/var/lib/pgadmin/"
}
}
volume {
name = "pgadmin"
# config_map {
# name = "pgadmin-config"
# }
nfs {
path = "/mnt/main/postgresql/pgadmin"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "pgadmin" {
metadata {
name = "pgadmin"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
"app" = "pgadmin"
}
port {
name = "pgadmin"
port = 80
}
}
}
module "ingress-pgadmin" {
source = "../ingress_factory"
namespace = kubernetes_namespace.dbaas.metadata[0].name
name = "pgadmin"
tls_secret_name = var.tls_secret_name
protected = true
rybbit_site_id = "7cef78e30485"
}
resource "kubernetes_cron_job_v1" "postgresql-backup" {
metadata {
name = "postgresql-backup"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 5
schedule = "0 0 * * *"
# schedule = "* * * * *"
starting_deadline_seconds = 10
successful_jobs_history_limit = 10
job_template {
metadata {}
spec {
backoff_limit = 3
ttl_seconds_after_finished = 10
template {
metadata {}
spec {
container {
name = "postgresql-backup"
image = "postgres:16.4-bullseye"
command = ["/bin/bash", "-c", <<-EOT
set -euxo pipefail
export now=$(date +"%Y_%m_%d_%H_%M")
PGPASSWORD=${var.postgresql_root_password} pg_dumpall -h postgresql.dbaas -U root > /backup/dump_$now.sql
# Rotate - delete last log file
cd /backup
find . -name "dump_*.sql" -type f -mtime +7 -delete # 7 day retention of backups
echo Done
EOT
]
volume_mount {
name = "postgresql-backup"
mount_path = "/backup"
}
}
volume {
name = "postgresql-backup"
nfs {
path = "/mnt/main/postgresql-backup"
server = "10.0.10.15"
}
}
}
}
}
}
}
}

View file

@ -1,14 +0,0 @@
---
orchestrator:
# persistence:
# enabled: false
ingress:
enable: false
hosts:
- host: db.viktorbarzin.me
paths:
- path: /
tls:
- secretName: ${secretName}
hosts:
- db.viktorbarzin.me

View file

@ -1,30 +0,0 @@
# Use the PostGIS image as the base
FROM pgvector/pgvector:0.8.0-pg16 as binary
FROM postgis/postgis:16-master
COPY --from=binary /pgvecto-rs-binary-release.deb /tmp/vectors.deb
RUN apt-get install -y /tmp/vectors.deb && rm -f /tmp/vectors.deb
# Install necessary packages
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
wget \
git \
postgresql-server-dev-16 \
postgresql-16-pgvector \
# Clean up to reduce layer size
&& rm -rf /var/lib/apt/lists/* \
&& cd /tmp \
&& git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git \
&& cd pgvector \
&& make \
&& make install \
# Clean up unnecessary files
&& cd - \
&& apt-get purge -y --auto-remove build-essential postgresql-server-dev-16 libpq-dev wget git \
&& rm -rf /tmp/pgvector
# Copy initialization scripts
#COPY ./docker-entrypoint-initdb.d/ /docker-entrypoint-initdb.d/
CMD ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", "search_path=\"$user\", public, vectors", "-c", "logging_collector=on"]

View file

@ -1,9 +0,0 @@
# terraform {
# required_providers {
# kubectl = {
# source = "gavinbunney/kubectl"
# version = ">= 1.10.0"
# }
# }
# required_version = ">= 0.13"
# }

View file

@ -1,87 +0,0 @@
resource "kubernetes_namespace" "descheduler" {
metadata {
name = "descheduler"
}
}
resource "kubernetes_cluster_role" "descheduler" {
metadata {
name = "descheduler-cluster-role"
}
rule {
api_groups = [""]
resources = ["events"]
verbs = ["create", "update"]
}
rule {
api_groups = ["metrics.k8s.io"]
resources = ["nodes"]
verbs = ["get", "watch", "list"]
}
rule {
api_groups = [""]
resources = ["namespaces"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["metrics.k8s.io"]
resources = ["pods"]
verbs = ["get", "watch", "list", "delete"]
}
rule {
api_groups = [""]
resources = ["pods/eviction"]
verbs = ["create"]
}
rule {
api_groups = [""]
resources = ["scheduling.k8s.io"]
verbs = ["get", "watch", "list"]
}
rule {
api_groups = ["scheduling.k8s.io"]
resources = ["priorityclasses"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["policy"]
resources = ["poddisruptionbudgets"]
verbs = ["get", "list", "watch"]
}
}
resource "kubernetes_service_account" "descheduler" {
metadata {
name = "descheduler-sa"
namespace = kubernetes_namespace.descheduler.metadata[0].name
}
}
resource "kubernetes_cluster_role_binding" "descheduler" {
metadata {
name = "descheduler-cluster-role-binding"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "descheduler-cluster-role"
}
subject {
name = "descheduler-sa"
kind = "ServiceAccount"
namespace = kubernetes_namespace.descheduler.metadata[0].name
}
}
resource "helm_release" "descheduler" { # rename me
namespace = kubernetes_namespace.descheduler.metadata[0].name
name = "descheduler"
repository = "https://kubernetes-sigs.github.io/descheduler/"
chart = "descheduler"
values = [templatefile("${path.module}/values.yaml", {})]
}

View file

@ -1,311 +0,0 @@
# Source from https://github.com/kubernetes-sigs/descheduler/blob/master/charts/descheduler/values.yaml
# Default values for descheduler.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# CronJob or Deployment
kind: CronJob
image:
repository: registry.k8s.io/descheduler/descheduler
# Overrides the image tag whose default is the chart version
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets:
# - name: container-registry-secret
resources:
requests:
cpu: 500m
memory: 256Mi
limits:
cpu: 500m
memory: 256Mi
ports:
- containerPort: 10258
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
# podSecurityContext -- [Security context for pod](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/)
podSecurityContext:
{}
# fsGroup: 1000
nameOverride: ""
fullnameOverride: ""
# -- Override the deployment namespace; defaults to .Release.Namespace
namespaceOverride: ""
# labels that'll be applied to all resources
commonLabels: {}
cronJobApiVersion: "batch/v1"
schedule: "0 * * * *"
suspend: false
# startingDeadlineSeconds: 200
successfulJobsHistoryLimit: 10
# failedJobsHistoryLimit: 1
# ttlSecondsAfterFinished 600
# timeZone: Etc/UTC
# Required when running as a Deployment
deschedulingInterval: 5m
# Specifies the replica count for Deployment
# Set leaderElection if you want to use more than 1 replica
# Set affinity.podAntiAffinity rule if you want to schedule onto a node
# only if that node is in the same zone as at least one already-running descheduler
replicas: 1
# Specifies whether Leader Election resources should be created
# Required when running as a Deployment
# NOTE: Leader election can't be activated if DryRun enabled
leaderElection: {}
# enabled: true
# leaseDuration: 15s
# renewDeadline: 10s
# retryPeriod: 2s
# resourceLock: "leases"
# resourceName: "descheduler"
# resourceNamespace: "kube-system"
command:
- "/bin/descheduler"
cmdOptions:
v: 3
# Recommended to use the latest Policy API version supported by the Descheduler app version
deschedulerPolicyAPIVersion: "descheduler/v1alpha2"
# deschedulerPolicy contains the policies the descheduler will execute.
# To use policies stored in an existing configMap use:
# NOTE: The name of the cm should comply to {{ template "descheduler.fullname" . }}
# deschedulerPolicy: {}
deschedulerPolicy:
# nodeSelector: "key1=value1,key2=value2"
# maxNoOfPodsToEvictPerNode: 10
maxNoOfPodsToEvictTotal: 10
# maxNoOfPodsToEvictPerNamespace: 10
# ignorePvcPods: true
# evictLocalStoragePods: true
# evictDaemonSetPods: true
# tracing:
# collectorEndpoint: otel-collector.observability.svc.cluster.local:4317
# transportCert: ""
# serviceName: ""
# serviceNamespace: ""
# sampleRate: 1.0
# fallbackToNoOpProviderOnError: true
metricsCollector:
enabled: true
profiles:
- name: default
pluginConfig:
- name: DefaultEvictor
args:
ignorePvcPods: true
evictLocalStoragePods: true
- name: RemoveDuplicates
- name: RemovePodsHavingTooManyRestarts
args:
podRestartThreshold: 2
includingInitContainers: true
states:
- CrashLoopBackOff
- name: RemovePodsViolatingNodeAffinity
args:
nodeAffinityType:
- requiredDuringSchedulingIgnoredDuringExecution
- name: RemovePodsViolatingNodeTaints
- name: RemovePodsViolatingInterPodAntiAffinity
- name: RemovePodsViolatingTopologySpreadConstraint
- name: LowNodeUtilization
args:
evictableNamespaces:
exclude:
- "dbaas" # let's not meddle with the dbs
thresholds:
cpu: 50
memory: 50
# pods: 20
targetThresholds:
cpu: 80
memory: 80
# pods: 30
metricsUtilization:
metricsServer: true
- name: PodLifeTime
args:
maxPodLifeTimeSeconds: 604800
namespaces:
exclude:
- "dbaas" # let's not meddle with the dbs
- "kube-system"
- "calico-system"
- "calico-apiserver"
- "metallb-system"
- "monitoring"
- "authentik"
- name: "RemoveFailedPods"
args:
reasons:
- "CrashLoopBackOff"
- "Error"
- "ContainerStatusUnknown"
- "ImagePullBackOff"
# exitCodes:
# - 1
includingInitContainers: true
# minPodLifetimeSeconds: 0
plugins:
balance:
enabled:
- RemoveDuplicates
- RemovePodsViolatingTopologySpreadConstraint
- LowNodeUtilization
deschedule:
enabled:
- RemovePodsHavingTooManyRestarts
- RemovePodsViolatingNodeTaints
- RemovePodsViolatingNodeAffinity
- RemovePodsViolatingInterPodAntiAffinity
- PodLifeTime
- RemoveFailedPods
- name: idrac-restart
pluginConfig:
- name: DefaultEvictor
args:
ignorePvcPods: true
evictLocalStoragePods: true
- name: PodLifeTime
args:
maxPodLifeTimeSeconds: 21600
namespaces:
include:
- "monitoring"
labelSelector:
matchLabels:
app: idrac-redfish-exporter
plugins:
deschedule:
enabled:
- PodLifeTime
priorityClassName: system-cluster-critical
nodeSelector: {}
# foo: bar
affinity: {}
# nodeAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# nodeSelectorTerms:
# - matchExpressions:
# - key: kubernetes.io/e2e-az-name
# operator: In
# values:
# - e2e-az1
# - e2e-az2
# podAntiAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# - labelSelector:
# matchExpressions:
# - key: app.kubernetes.io/name
# operator: In
# values:
# - descheduler
# topologyKey: "kubernetes.io/hostname"
topologySpreadConstraints: []
# - maxSkew: 1
# topologyKey: kubernetes.io/hostname
# whenUnsatisfiable: DoNotSchedule
# labelSelector:
# matchLabels:
# app.kubernetes.io/name: descheduler
tolerations: []
# - key: 'management'
# operator: 'Equal'
# value: 'tool'
# effect: 'NoSchedule'
rbac:
# Specifies whether RBAC resources should be created
create: true
serviceAccount:
# Specifies whether a ServiceAccount should be created
create: false
# The name of the ServiceAccount to use.
# If not set and create is true, a name is generated using the fullname template
name: "descheduler-sa"
# Specifies custom annotations for the serviceAccount
annotations: {}
podAnnotations: {}
podLabels: {}
dnsConfig: {}
livenessProbe:
failureThreshold: 3
httpGet:
path: /healthz
port: 10258
scheme: HTTPS
initialDelaySeconds: 3
periodSeconds: 10
service:
enabled: false
# @param service.ipFamilyPolicy [string], support SingleStack, PreferDualStack and RequireDualStack
#
ipFamilyPolicy: ""
# @param service.ipFamilies [array] List of IP families (e.g. IPv4, IPv6) assigned to the service.
# Ref: https://kubernetes.io/docs/concepts/services-networking/dual-stack/
# E.g.
# ipFamilies:
# - IPv6
# - IPv4
ipFamilies: []
serviceMonitor:
enabled: false
# The namespace where Prometheus expects to find service monitors.
# namespace: ""
# Add custom labels to the ServiceMonitor resource
additionalLabels:
{}
# prometheus: kube-prometheus-stack
interval: ""
# honorLabels: true
insecureSkipVerify: true
serverName: null
metricRelabelings:
[]
# - action: keep
# regex: 'descheduler_(build_info|pods_evicted)'
# sourceLabels: [__name__]
relabelings:
[]
# - sourceLabels: [__meta_kubernetes_pod_node_name]
# separator: ;
# regex: ^(.*)$
# targetLabel: nodename
# replacement: $1
# action: replace

View file

@ -1,177 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "diun_nfty_token" {}
variable "diun_slack_url" {}
resource "kubernetes_namespace" "diun" {
metadata {
name = "diun"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.diun.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_service_account" "diun" {
metadata {
name = "diun"
namespace = kubernetes_namespace.diun.metadata[0].name
}
}
resource "kubernetes_cluster_role" "diun" {
metadata {
name = "diun"
}
rule {
api_groups = [""]
resources = ["pods"]
verbs = ["get", "watch", "list"]
}
}
resource "kubernetes_cluster_role_binding" "diun" {
metadata {
name = "diun"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "diun"
}
subject {
kind = "ServiceAccount"
name = "diun"
namespace = kubernetes_namespace.diun.metadata[0].name
}
}
resource "kubernetes_deployment" "diun" {
metadata {
name = "diun"
namespace = kubernetes_namespace.diun.metadata[0].name
labels = {
app = "diun"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
"diun.enable" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "diun"
}
}
template {
metadata {
labels = {
app = "diun"
}
}
spec {
service_account_name = "diun"
container {
image = "crazymax/diun:latest"
name = "diun"
args = ["serve"]
env {
name = "TZ"
value = "Europe/Sofia"
}
env {
name = "DIUN_WATCH_WORKERS"
value = "20"
}
env {
name = "DIUN_WATCH_SCHEDULE"
value = "0 */6 * * *"
}
env {
name = "DIUN_WATCH_JITTER"
value = "30s"
}
env {
name = "DIUN_PROVIDERS_KUBERNETES"
value = "true"
}
# env {
# name = "DIUN_DEFAULTS_EXCLUDETAGS"
# value = "^.*nightly.*$"
# }
# env {
# name = "DIUN_DEFAULTS_INCLUDETAGS"
# value = "^\\d+\\.\\d+\\.\\d+$"
# }
env {
name = "DIUN_DEFAULTS_WATCHREPO"
value = "true"
# value = "false"
}
env {
name = "DIUN_DEFAULTS_MAXTAGS"
value = "3"
}
env {
name = "DIUN_DEFAULTS_SORTTAGS"
value = "reverse"
}
# DIUN_PROVIDERS_KUBERNETES_WATCHBYDEFAULT = "true" ??
// ntfy settings
# env { // disabled as if this fails, no other notifications are sent
# name = "DIUN_NOTIF_NTFY_ENDPOINT"
# value = "https://ntfy.viktorbarzin.me"
# }
# env {
# name = "DIUN_NOTIF_NTFY_TOPIC"
# value = "diun-updates"
# }
# env {
# name = "DIUN_NOTIF_NTFY_TOKEN"
# value = var.diun_nfty_token
# }
env {
name = "DIUN_NOTIF_SLACK_WEBHOOKURL"
value = var.diun_slack_url
}
env {
name = "LOG_LEVEL"
# value = "info"
value = "debug"
}
# env {
# name = "DIUN_WATCH_FIRSTCHECKNOTIF"
# value = "true" # send notfication on start; subsequent checks check for newer versions and is what you need
# }
# env {
# name = "DIUN_NOTIF_NTFY_TIMEOUT"
# value = "10s"
# }
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "data"
nfs {
path = "/mnt/main/diun"
server = "10.0.10.15"
}
}
}
}
}
}

View file

@ -1,415 +0,0 @@
variable "tls_secret_name" {}
variable "git_crypt_key_base64" { type = string }
variable "tier" { type = string }
variable "github_client_id" {}
variable "github_client_secret" {}
variable "rpc_secret" {}
variable "webhook_secret" {}
variable "server_host" {}
variable "server_proto" {}
variable "rpc_host" {
default = "drone.drone.svc.cluster.local"
}
variable "allowed_users" {
# comma separated list
default = "viktorbarzin,ancamilea"
}
resource "kubernetes_namespace" "drone" {
metadata {
name = "drone"
labels = {
"resource-governance/custom-quota" = "true"
tier = var.tier
}
}
}
resource "kubernetes_resource_quota" "drone" {
metadata {
name = "tier-quota"
namespace = kubernetes_namespace.drone.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "16"
"requests.memory" = "16Gi"
"limits.cpu" = "64"
"limits.memory" = "128Gi"
pods = "60"
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.drone.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_config_map" "git_crypt_key" {
metadata {
name = "git-crypt-key"
namespace = kubernetes_namespace.drone.metadata[0].name
}
data = {
"key" = var.git_crypt_key_base64
}
}
resource "kubernetes_deployment" "drone_server" {
metadata {
name = "drone-server"
namespace = kubernetes_namespace.drone.metadata[0].name
labels = {
app = "drone"
tier = var.tier
}
}
spec {
strategy {
type = "Recreate"
}
replicas = 1
selector {
match_labels = {
app = "drone"
}
}
template {
metadata {
labels = {
app = "drone"
}
}
spec {
container {
image = "drone/drone:2.27.0"
name = "drone-server"
# resources {
# limits = {
# cpu = "1"
# memory = "1Gi"
# }
# requests = {
# cpu = "500m"
# memory = "1Gi"
# }
# }
port {
container_port = 80
}
volume_mount {
name = "data"
mount_path = "/data"
}
env {
name = "DRONE_GITHUB_CLIENT_ID"
value = var.github_client_id
}
env {
name = "DRONE_GITHUB_CLIENT_SECRET"
value = var.github_client_secret
}
env {
name = "DRONE_RPC_SECRET"
value = var.rpc_secret
}
env {
name = "DRONE_WEBHOOK_SECRET"
value = var.webhook_secret
}
env {
name = "DRONE_SERVER_HOST"
value = var.server_host
}
env {
name = "DRONE_SERVER_PROTO"
value = var.server_proto
}
env {
name = "DRONE_USER_FILTER"
value = var.allowed_users
}
env {
name = "DRONE_CRON_INTERVAL"
value = "1m"
}
env {
name = "DRONE_LOGS_TRACE"
value = "true"
}
env {
name = "DRONE_LOGS_PRETTY"
value = "true"
}
env {
name = "DRONE_LOGS_TEXT"
value = "true"
}
}
volume {
name = "data"
nfs {
path = "/mnt/main/drone"
server = "10.0.10.15"
}
# iscsi {
# target_portal = "iscsi.viktorbarzin.lan:3260"
# fs_type = "ext4"
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:drone"
# lun = 0
# read_only = false
# }
}
}
}
}
}
resource "kubernetes_service" "drone" {
metadata {
name = "drone"
namespace = kubernetes_namespace.drone.metadata[0].name
labels = {
app = "drone"
}
}
spec {
selector = {
app = "drone"
}
port {
name = "http"
port = "80"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.drone.metadata[0].name
name = "drone"
tls_secret_name = var.tls_secret_name
# protected = true
}
# Setup drone runner
resource "kubernetes_cluster_role" "drone" {
metadata {
name = "drone"
}
rule {
api_groups = [""]
resources = ["configmaps"]
verbs = ["get", "list", "update", "patch"]
}
rule {
api_groups = [""]
resources = ["secrets"]
verbs = ["get", "list", "create", "delete"]
}
rule {
api_groups = [""]
resources = ["pods", "pods/log"]
verbs = ["get", "create", "delete", "list", "watch", "update"]
}
rule {
api_groups = ["apps"]
resources = ["deployments"]
verbs = ["get", "create", "delete", "list", "watch", "update", "patch"]
}
}
resource "kubernetes_cluster_role_binding" "drone" {
metadata {
name = "drone"
}
subject {
kind = "ServiceAccount"
name = "default"
namespace = kubernetes_namespace.drone.metadata[0].name
}
role_ref {
kind = "ClusterRole"
# name = "drone"
name = "cluster-admin"
api_group = "rbac.authorization.k8s.io"
}
}
resource "kubernetes_deployment" "drone_runner" {
metadata {
name = "drone-runner"
namespace = kubernetes_namespace.drone.metadata[0].name
labels = {
app = "drone-runner"
tier = var.tier
}
}
spec {
strategy {
type = "Recreate"
}
replicas = 4
selector {
match_labels = {
app = "drone-runner"
}
}
template {
metadata {
labels = {
app = "drone-runner"
}
}
spec {
container {
image = "drone/drone-runner-kube:latest"
name = "drone-runner"
# resources {
# limits = {
# cpu = "1"
# memory = "1Gi"
# }
# requests = {
# cpu = "500m"
# memory = "1Gi"
# }
# }
env {
name = "DRONE_RPC_HOST"
value = var.rpc_host
}
env {
name = "DRONE_RPC_PROTO"
value = "http"
}
env {
name = "DRONE_RPC_SECRET"
value = var.rpc_secret
}
env {
name = "DRONE_NAMESPACE_DEFAULT"
value = "drone"
}
env {
name = "SECRET_KEY"
value = var.rpc_secret
}
env {
name = "DRONE_SECRET_PLUGIN_ENDPOINT"
value = "http://drone-runner-secret.drone.svc.cluster.local:3000"
}
env {
name = "DRONE_SECRET_PLUGIN_TOKEN"
value = var.rpc_secret
}
env {
name = "DRONE_DEBUG"
value = "true"
}
}
}
}
}
}
resource "kubernetes_deployment" "drone_runner_secret" {
metadata {
name = "drone-runner-secret"
namespace = kubernetes_namespace.drone.metadata[0].name
labels = {
app = "drone-runner-secret"
tier = var.tier
}
}
spec {
strategy {
type = "Recreate"
}
replicas = 1
selector {
match_labels = {
app = "drone-runner-secret"
}
}
template {
metadata {
labels = {
app = "drone-runner-secret"
}
}
spec {
container {
name = "secret"
image = "drone/kubernetes-secrets:latest"
port {
container_port = 3000
}
env {
name = "SECRET_KEY"
value = var.rpc_secret
}
env {
name = "DEBUG"
value = "true"
}
env {
name = "KUBERNETES_NAMESPACE"
value = "drone"
}
// Custom variable to start terraform as prod
env {
name = "TF_VAR_prod"
value = true
}
}
}
}
}
}
resource "kubernetes_service" "drone_runner_secret" {
metadata {
name = "drone-runner-secret"
namespace = kubernetes_namespace.drone.metadata[0].name
labels = {
app = "drone-runner-secret"
}
}
spec {
selector = {
app = "drone-runner-secret"
}
port {
name = "http"
port = "3000"
}
}
}
# SQL to delete last N builds (n = 1000)
# PRAGMA foreign_keys = ON;
# WITH n_build_ids_per_repo as (
# SELECT build_id
# FROM (
# SELECT
# build_id,
# build_repo_id,
# DENSE_RANK() OVER (PARTITION BY build_repo_id ORDER BY build_id DESC) AS rank
# FROM builds
# ) AS t
# WHERE t.rank <= 1000
# )
# DELETE FROM
# builds
# WHERE
# builds.build_id NOT IN (SELECT build_id FROM n_build_ids_per_repo);

@ -1 +0,0 @@
Subproject commit 22a69d0fb169bcff79f894f7ae9769c6fea09b6d

View file

@ -1,410 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "ebook2audiobook" {
metadata {
name = "ebook2audiobook"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
resource "kubernetes_deployment" "ebook2audiobook" {
metadata {
name = "ebook2audiobook"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
labels = {
app = "ebook2audiobook"
tier = var.tier
}
}
spec {
replicas = 0 # Disabled - using audiblez instead
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "ebook2audiobook"
}
}
template {
metadata {
labels = {
app = "ebook2audiobook"
}
}
spec {
node_selector = {
"gpu" : "true"
}
toleration {
key = "nvidia.com/gpu"
operator = "Equal"
value = "true"
effect = "NoSchedule"
}
container {
name = "ebook2audiobook"
image = "docker.io/athomasson2/ebook2audiobook:v25.12.30-cu128"
tty = true
stdin = true
port {
container_port = 7860
}
# LD_LIBRARY_PATH needed for CUDA detection - libcudart.so is in non-standard location
env {
name = "LD_LIBRARY_PATH"
value = "/usr/local/lib/python3.12/site-packages/nvidia/cuda_runtime/lib:/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib"
}
volume_mount {
mount_path = "/home/user"
name = "data"
}
resources {
limits = {
"nvidia.com/gpu" = "1"
}
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/ebook2audiobook"
}
}
}
}
}
}
resource "kubernetes_service" "ebook2audiobook" {
metadata {
name = "ebook2audiobook"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
labels = {
"app" = "ebook2audiobook"
}
}
spec {
selector = {
app = "ebook2audiobook"
}
port {
name = "http"
port = 80
target_port = 7860
}
}
}
# resource "kubernetes_deployment" "piper" {
# metadata {
# name = "piper"
# namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
# labels = {
# app = "piper"
# }
# }
# spec {
# replicas = 1
# strategy {
# type = "Recreate"
# }
# selector {
# match_labels = {
# app = "piper"
# }
# }
# template {
# metadata {
# labels = {
# app = "piper"
# }
# }
# spec {
# container {
# name = "piper"
# # image = "lscr.io/linuxserver/piper:gpu"
# # image = "piper-tts-wyoming:latest"
# image = "viktorbarzin/piper"
# # image = "nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04"
# # working_dir = "/app"
# command = ["sleep", "3600"]
# volume_mount {
# mount_path = "/config"
# name = "data"
# }
# resources {
# limits = {
# "nvidia.com/gpu" = "1"
# }
# }
# # env {
# # name = "PIPER_VOICE"
# # value = "en_US-lessac-medium"
# # }
# env {
# name = "VOICE_MODEL"
# value = "en_US-lessac-medium"
# }
# env {
# name = "LOG_LEVEL"
# value = "DEBUG"
# }
# port {
# name = "web"
# container_port = 10200
# }
# }
# volume {
# name = "data"
# nfs {
# server = "10.0.10.15"
# path = "/mnt/main/piper"
# }
# }
# }
# }
# }
# }
# resource "kubernetes_service" "piper" {
# metadata {
# name = "piper"
# namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
# labels = {
# "app" = "piper"
# }
# }
# spec {
# selector = {
# app = "piper"
# }
# port {
# name = "http"
# port = 80
# target_port = 10200
# }
# }
# }
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
name = "ebook2audiobook"
tls_secret_name = var.tls_secret_name
protected = true
}
resource "kubernetes_deployment" "audiblez" {
metadata {
name = "audiblez"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
labels = {
app = "audiblez"
tier = var.tier
}
}
spec {
replicas = 0 # Disabled - using audiblez-web instead
selector {
match_labels = {
app = "audiblez"
}
}
template {
metadata {
labels = {
app = "audiblez"
}
}
spec {
node_selector = {
"gpu" : "true"
}
toleration {
key = "nvidia.com/gpu"
operator = "Equal"
value = "true"
effect = "NoSchedule"
}
container {
image = "viktorbarzin/audiblez:latest"
name = "audiblez"
command = ["/usr/bin/sleep", "infinity"]
volume_mount {
name = "data"
mount_path = "/mnt"
}
resources {
limits = {
"nvidia.com/gpu" = "1"
}
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/audiblez"
}
}
}
}
}
}
# Audiblez Web UI
resource "kubernetes_deployment" "audiblez-web" {
metadata {
name = "audiblez-web"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
labels = {
app = "audiblez-web"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "audiblez-web"
}
}
template {
metadata {
labels = {
app = "audiblez-web"
}
}
spec {
node_selector = {
"gpu" : "true"
}
toleration {
key = "nvidia.com/gpu"
operator = "Equal"
value = "true"
effect = "NoSchedule"
}
container {
# Use digest to bypass local registry cache
image = "docker.io/viktorbarzin/audiblez-web@sha256:eb6d13e6372b931bcac45ca389c063dfadc7b3fc2a607127fc76c5627b13a34c"
image_pull_policy = "Always"
name = "audiblez-web"
port {
container_port = 8000
}
volume_mount {
name = "data"
mount_path = "/mnt"
}
resources {
limits = {
"nvidia.com/gpu" = "1"
}
}
# liveness_probe {
# http_get {
# path = "/health"
# port = 8000
# }
# initial_delay_seconds = 10
# period_seconds = 30
# }
# readiness_probe {
# http_get {
# path = "/health"
# port = 8000
# }
# initial_delay_seconds = 5
# period_seconds = 10
# }
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/audiblez"
}
}
}
}
}
}
resource "kubernetes_service" "audiblez-web" {
metadata {
name = "audiblez-web"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
labels = {
"app" = "audiblez-web"
}
}
spec {
selector = {
app = "audiblez-web"
}
port {
name = "http"
port = 80
target_port = 8000
}
}
}
module "audiblez-web-ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.ebook2audiobook.metadata[0].name
name = "audiblez-web"
host = "audiblez"
tls_secret_name = var.tls_secret_name
protected = true
max_body_size = "500m" # Allow large EPUB uploads
}

View file

@ -1,84 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "echo" {
metadata {
name = "echo"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.echo.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "echo" {
metadata {
name = "echo"
namespace = kubernetes_namespace.echo.metadata[0].name
labels = {
app = "echo"
tier = var.tier
}
}
spec {
replicas = 5
selector {
match_labels = {
app = "echo"
}
}
template {
metadata {
labels = {
app = "echo"
}
}
spec {
container {
image = "mendhak/http-https-echo"
name = "echo"
port {
container_port = 8080
}
port {
container_port = 8443
}
}
}
}
}
}
resource "kubernetes_service" "echo" {
metadata {
name = "echo"
namespace = kubernetes_namespace.echo.metadata[0].name
labels = {
"app" = "echo"
}
}
spec {
selector = {
app = "echo"
}
port {
name = "http"
port = "80"
target_port = "8080"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.echo.metadata[0].name
name = "echo"
tls_secret_name = var.tls_secret_name
}

View file

@ -1,107 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "excalidraw" {
metadata {
name = "excalidraw"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.excalidraw.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "excalidraw" {
metadata {
name = "excalidraw"
namespace = kubernetes_namespace.excalidraw.metadata[0].name
labels = {
app = "excalidraw"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "excalidraw"
}
}
template {
metadata {
labels = {
app = "excalidraw"
}
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^latest$"
}
}
spec {
container {
image = "viktorbarzin/excalidraw-library:v4"
image_pull_policy = "IfNotPresent"
name = "excalidraw"
port {
container_port = 8080
}
env {
name = "DATA_DIR"
value = "/data"
}
env {
name = "PORT"
value = "8080"
}
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/excalidraw"
}
}
}
}
}
}
resource "kubernetes_service" "draw" {
metadata {
name = "draw"
namespace = kubernetes_namespace.excalidraw.metadata[0].name
labels = {
app = "excalidraw"
}
}
spec {
selector = {
app = "excalidraw"
}
port {
name = "http"
port = 80
target_port = 8080
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.excalidraw.metadata[0].name
name = "draw"
tls_secret_name = var.tls_secret_name
protected = true
}

View file

@ -1,14 +0,0 @@
# Binaries
excalidraw-library
*.exe
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
._*

View file

@ -1,22 +0,0 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod ./
COPY main.go ./
COPY static/ ./static/
RUN go build -o excalidraw-library .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/excalidraw-library .
ENV DATA_DIR=/data
ENV PORT=8080
EXPOSE 8080
CMD ["./excalidraw-library"]

View file

@ -1,131 +0,0 @@
# Excalidraw Rooms
A self-hosted Excalidraw library with per-user drawing storage and management.
## Features
- Dashboard to manage all your drawings
- Per-user storage (via Authentik SSO headers)
- Create, edit, and delete drawings
- Persistent storage via NFS
## Docker Image
```
viktorbarzin/excalidraw-library:v4
```
Available on Docker Hub: https://hub.docker.com/r/viktorbarzin/excalidraw-library
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATA_DIR` | `/data` | Directory where drawings are stored |
| `PORT` | `8080` | HTTP server port |
### Storage
Mount a persistent volume to the `DATA_DIR` path. Drawings are stored as `.excalidraw` files, organized by username:
```
/data/
├── user1/
│ ├── drawing1.excalidraw
│ └── drawing2.excalidraw
└── user2/
└── my-diagram.excalidraw
```
## Deployment
### Docker
```bash
docker run -d \
--name excalidraw-rooms \
-p 8080:8080 \
-v /path/to/storage:/data \
viktorbarzin/excalidraw-library:v4
```
### Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: excalidraw
spec:
replicas: 1
selector:
matchLabels:
app: excalidraw
template:
metadata:
labels:
app: excalidraw
spec:
containers:
- name: excalidraw
image: viktorbarzin/excalidraw-library:v4
ports:
- containerPort: 8080
env:
- name: DATA_DIR
value: /data
- name: PORT
value: "8080"
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
nfs:
server: 10.0.10.15
path: /mnt/main/excalidraw
```
### With Authentik SSO
The application reads user identity from Authentik headers:
- `X-Authentik-Username` - Used to create per-user storage directories
- `X-Authentik-Email` - Displayed in UI
- `X-Authentik-Name` - Displayed in UI
Configure your ingress to pass these headers:
```yaml
annotations:
nginx.ingress.kubernetes.io/auth-response-headers: "X-authentik-username,X-authentik-email,X-authentik-name"
```
## Building
```bash
# Build the Docker image
docker build -t excalidraw-library .
# Or build locally
go build -o excalidraw-library .
./excalidraw-library
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Dashboard UI |
| GET | `/api/drawings` | List all drawings for current user |
| GET | `/api/drawings/:id` | Get drawing data |
| PUT | `/api/drawings/:id` | Save drawing |
| DELETE | `/api/drawings/:id` | Delete drawing |
| GET | `/api/user` | Get current user info |
| GET | `/draw/:id` | Open drawing in editor |
## License
MIT

View file

@ -1,3 +0,0 @@
module excalidraw-library
go 1.21

View file

@ -1,461 +0,0 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
//go:embed static/*
var staticFiles embed.FS
type Drawing struct {
ID string `json:"id"`
Name string `json:"name"`
Modified time.Time `json:"modified"`
Size int64 `json:"size"`
}
var dataDir string
func main() {
dataDir = os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "/data"
}
// Ensure data directory exists
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
http.HandleFunc("/", handleDashboard)
http.HandleFunc("/api/drawings", handleListDrawings)
http.HandleFunc("/api/drawings/", handleDrawing)
http.HandleFunc("/api/user", handleUser)
http.HandleFunc("/draw/", handleDraw)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting server on :%s with data dir: %s", port, dataDir)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
// getUsername extracts username from Authentik header, returns "anonymous" if not set
func getUsername(r *http.Request) string {
username := r.Header.Get("X-Authentik-Username")
if username == "" {
username = "anonymous"
}
// Sanitize to prevent directory traversal
username = filepath.Base(username)
return username
}
// getUserDataDir returns the data directory for a specific user and ensures it exists
func getUserDataDir(username string) string {
userDir := filepath.Join(dataDir, username)
if err := os.MkdirAll(userDir, 0755); err != nil {
log.Printf("Warning: Failed to create user directory %s: %v", userDir, err)
}
return userDir
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, dashboardHTML)
}
func handleListDrawings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
username := getUsername(r)
userDataDir := getUserDataDir(username)
files, err := os.ReadDir(userDataDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var drawings []Drawing
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".excalidraw") {
continue
}
info, err := f.Info()
if err != nil {
continue
}
id := strings.TrimSuffix(f.Name(), ".excalidraw")
drawings = append(drawings, Drawing{
ID: id,
Name: id,
Modified: info.ModTime(),
Size: info.Size(),
})
}
// Sort by modified time, newest first
sort.Slice(drawings, func(i, j int) bool {
return drawings[i].Modified.After(drawings[j].Modified)
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(drawings)
}
func handleDrawing(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/drawings/")
if id == "" {
http.Error(w, "Missing drawing ID", http.StatusBadRequest)
return
}
username := getUsername(r)
userDataDir := getUserDataDir(username)
// Sanitize ID to prevent directory traversal
id = filepath.Base(id)
filePath := filepath.Join(userDataDir, id+".excalidraw")
switch r.Method {
case http.MethodGet:
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Drawing not found", http.StatusNotFound)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
case http.MethodPut, http.MethodPost:
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "saved", "id": id})
case http.MethodDelete:
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
http.Error(w, "Drawing not found", http.StatusNotFound)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleUser returns the current authenticated user info
func handleUser(w http.ResponseWriter, r *http.Request) {
username := getUsername(r)
email := r.Header.Get("X-Authentik-Email")
name := r.Header.Get("X-Authentik-Name")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"username": username,
"email": email,
"name": name,
})
}
func handleDraw(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/draw/")
if id == "" {
http.Error(w, "Missing drawing ID", http.StatusBadRequest)
return
}
// Serve the static editor.html - the JS will parse the ID from the URL
data, err := staticFiles.ReadFile("static/editor.html")
if err != nil {
http.Error(w, "Editor not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
}
const dashboardHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Library</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
padding: 2rem;
}
.container { max-width: 900px; margin: 0 auto; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #333;
}
.header-left { display: flex; align-items: center; gap: 1rem; }
h1 { font-size: 1.5rem; }
.user-info {
font-size: 0.9rem;
color: #a29bfe;
padding: 0.4rem 0.8rem;
background: #252542;
border-radius: 6px;
}
.btn {
background: #6c5ce7;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
}
.btn:hover { background: #5b4cdb; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.drawings { display: grid; gap: 1rem; }
.drawing {
background: #252542;
border-radius: 12px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.1s, box-shadow 0.1s;
}
.drawing:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.drawing-info { flex: 1; }
.drawing-name {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 0.25rem;
color: #fff;
text-decoration: none;
}
.drawing-name:hover { color: #a29bfe; }
.drawing-meta { font-size: 0.85rem; color: #888; }
.drawing-actions { display: flex; gap: 0.5rem; }
.empty {
text-align: center;
padding: 4rem 2rem;
color: #666;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active { display: flex; }
.modal-content {
background: #252542;
padding: 2rem;
border-radius: 12px;
width: 90%;
max-width: 400px;
}
.modal h2 { margin-bottom: 1rem; }
.modal input {
width: 100%;
padding: 0.75rem;
border: 1px solid #444;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 1rem;
margin-bottom: 1rem;
}
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-left">
<h1>Excalidraw Library</h1>
<span id="user-info" class="user-info">Loading...</span>
</div>
<button class="btn" onclick="showNewModal()">+ New Drawing</button>
</header>
<div id="drawings" class="drawings">
<div class="empty">Loading...</div>
</div>
</div>
<div id="modal" class="modal">
<div class="modal-content">
<h2>New Drawing</h2>
<input type="text" id="drawingName" placeholder="Drawing name..." autofocus>
<div class="modal-actions">
<button class="btn" style="background:#444" onclick="hideModal()">Cancel</button>
<button class="btn" onclick="createDrawing()">Create</button>
</div>
</div>
</div>
<script>
async function loadUser() {
try {
const resp = await fetch('/api/user');
const user = await resp.json();
const el = document.getElementById('user-info');
if (user.name) {
el.textContent = user.name;
} else if (user.username) {
el.textContent = user.username;
} else {
el.textContent = 'Guest';
}
} catch (e) {
document.getElementById('user-info').textContent = 'Guest';
}
}
async function loadDrawings() {
const resp = await fetch('/api/drawings');
const drawings = await resp.json();
const container = document.getElementById('drawings');
if (!drawings || drawings.length === 0) {
container.innerHTML = '<div class="empty">No drawings yet. Create your first one!</div>';
return;
}
container.innerHTML = drawings.map(function(d) {
return '<div class="drawing">' +
'<div class="drawing-info">' +
'<a href="/draw/' + d.id + '" class="drawing-name">' + d.name + '</a>' +
'<div class="drawing-meta">' +
'Modified: ' + new Date(d.modified).toLocaleDateString() + ' ' + new Date(d.modified).toLocaleTimeString() +
' - ' + formatSize(d.size) +
'</div>' +
'</div>' +
'<div class="drawing-actions">' +
'<a href="/draw/' + d.id + '" class="btn btn-small">Open</a>' +
'<button class="btn btn-small btn-danger" onclick="deleteDrawing(\'' + d.id + '\')">Delete</button>' +
'</div>' +
'</div>';
}).join('');
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function showNewModal() {
document.getElementById('modal').classList.add('active');
document.getElementById('drawingName').focus();
}
function hideModal() {
document.getElementById('modal').classList.remove('active');
document.getElementById('drawingName').value = '';
}
async function createDrawing() {
var name = document.getElementById('drawingName').value.trim();
if (!name) {
name = 'drawing-' + Date.now();
}
// Sanitize name
name = name.replace(/[^a-zA-Z0-9-_]/g, '-');
// Create empty drawing
var emptyDrawing = {
type: "excalidraw",
version: 2,
source: "excalidraw-library",
elements: [],
appState: { viewBackgroundColor: "#ffffff" }
};
await fetch('/api/drawings/' + name, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emptyDrawing)
});
hideModal();
window.location.href = '/draw/' + name;
}
async function deleteDrawing(id) {
if (!confirm('Delete "' + id + '"?')) return;
await fetch('/api/drawings/' + id, { method: 'DELETE' });
loadDrawings();
}
document.getElementById('drawingName').addEventListener('keypress', function(e) {
if (e.key === 'Enter') createDrawing();
});
document.getElementById('modal').addEventListener('click', function(e) {
if (e.target.id === 'modal') hideModal();
});
loadUser();
loadDrawings();
</script>
</body>
</html>`

View file

@ -1,241 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Editor</title>
<style>
* { margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#root { width: 100%; height: 100%; }
.toolbar {
position: fixed;
top: 10px;
left: 10px;
z-index: 1000;
display: flex;
gap: 8px;
background: rgba(255,255,255,0.95);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.toolbar button, .toolbar a {
padding: 6px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
background: #6c5ce7;
color: white;
text-decoration: none;
display: inline-block;
}
.toolbar button:hover, .toolbar a:hover { background: #5b4cdb; }
.toolbar .secondary { background: #ddd; color: #333; }
.toolbar .secondary:hover { background: #ccc; }
.toolbar .title {
font-weight: 600;
padding: 6px 0;
color: #333;
}
.status {
position: fixed;
bottom: 10px;
right: 10px;
padding: 6px 12px;
background: rgba(0,0,0,0.7);
color: white;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
}
.status.show { opacity: 1; }
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 1.2rem;
color: #666;
flex-direction: column;
gap: 1rem;
}
.error { color: #e74c3c; }
</style>
</head>
<body>
<div class="toolbar">
<a href="/" class="secondary">Back to Library</a>
<span class="title" id="title">Loading...</span>
<button onclick="saveDrawing()">Save</button>
</div>
<div id="root">
<div class="loading">
<div>Loading Excalidraw...</div>
<div id="load-status" style="font-size: 0.9rem; color: #888;"></div>
</div>
</div>
<div id="status" class="status">Saved</div>
<script>
// Get drawing ID from URL path: /draw/{id}
var pathParts = window.location.pathname.split('/');
var drawingId = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
if (!drawingId) {
document.getElementById('root').innerHTML = '<div class="loading error">No drawing ID specified</div>';
throw new Error('No drawing ID');
}
document.getElementById('title').textContent = drawingId;
document.title = drawingId + ' - Excalidraw';
var excalidrawAPI = null;
var autoSaveTimeout = null;
function updateLoadStatus(msg) {
var el = document.getElementById('load-status');
if (el) el.textContent = msg;
console.log('[Excalidraw]', msg);
}
function showStatus(msg) {
var el = document.getElementById('status');
el.textContent = msg;
el.classList.add('show');
setTimeout(function() { el.classList.remove('show'); }, 2000);
}
async function loadDrawing() {
try {
var resp = await fetch('/api/drawings/' + drawingId);
if (resp.ok) {
return await resp.json();
}
} catch (e) {
console.log('No existing drawing, starting fresh');
}
return null;
}
async function saveDrawing() {
if (!excalidrawAPI) {
console.log('Cannot save: excalidrawAPI not ready');
return;
}
var elements = excalidrawAPI.getSceneElements();
var appState = excalidrawAPI.getAppState();
var data = {
type: "excalidraw",
version: 2,
source: "excalidraw-library",
elements: elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize
}
};
try {
await fetch('/api/drawings/' + drawingId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
showStatus('Saved');
} catch (e) {
showStatus('Save failed!');
console.error('Save error:', e);
}
}
function onChange() {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(saveDrawing, 2000);
}
// Load scripts dynamically
function loadScript(src) {
return new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.crossOrigin = 'anonymous';
script.onload = resolve;
script.onerror = function() { reject(new Error('Failed to load: ' + src)); };
document.head.appendChild(script);
});
}
async function init() {
try {
updateLoadStatus('Loading React...');
await loadScript('https://unpkg.com/react@18.2.0/umd/react.production.min.js');
updateLoadStatus('Loading ReactDOM...');
await loadScript('https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js');
updateLoadStatus('Loading Excalidraw...');
await loadScript('https://unpkg.com/@excalidraw/excalidraw@0.17.6/dist/excalidraw.production.min.js');
updateLoadStatus('Initializing...');
// Verify libraries loaded
if (!window.React) throw new Error('React not loaded');
if (!window.ReactDOM) throw new Error('ReactDOM not loaded');
if (!window.ExcalidrawLib) throw new Error('ExcalidrawLib not loaded');
console.log('React version:', React.version);
console.log('ExcalidrawLib:', Object.keys(ExcalidrawLib));
updateLoadStatus('Loading drawing data...');
var initialData = await loadDrawing();
updateLoadStatus('Rendering Excalidraw...');
// Create Excalidraw component
function App() {
return React.createElement(ExcalidrawLib.Excalidraw, {
initialData: initialData ? {
elements: initialData.elements || [],
appState: initialData.appState || {}
} : undefined,
excalidrawAPI: function(api) {
excalidrawAPI = api;
console.log('Excalidraw API ready');
},
onChange: onChange
});
}
var root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
console.log('Excalidraw rendered successfully');
} catch (e) {
console.error('Init error:', e);
document.getElementById('root').innerHTML =
'<div class="loading error">' +
'<div>Failed to load Excalidraw</div>' +
'<div style="font-size:0.9rem">' + e.message + '</div>' +
'</div>';
}
}
// Keyboard shortcut: Ctrl+S to save
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveDrawing();
}
});
init();
</script>
</body>
</html>

View file

@ -1,3 +0,0 @@
node_modules/
.claude/
.git/

View file

@ -1,21 +0,0 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /f1-stream .
FROM alpine:3.20
RUN apk add --no-cache \
ca-certificates \
chromium nss freetype harfbuzz ttf-freefont \
mesa-dri-gallium mesa-gl \
dbus \
xvfb-run xorg-server \
pulseaudio pulseaudio-utils \
ffmpeg
ENV CHROME_PATH=/usr/bin/chromium-browser
COPY --from=builder /f1-stream /f1-stream
COPY static/ /static/
EXPOSE 8080
ENTRYPOINT ["/f1-stream"]

View file

@ -1,45 +0,0 @@
module f1-stream
go 1.24.1
require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-webauthn/webauthn v0.15.0
github.com/gobwas/ws v1.4.0
github.com/pion/webrtc/v4 v4.2.9
)
require (
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.1 // indirect
github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.1 // indirect
github.com/pion/sctp v1.9.2 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.10.0 // indirect
)

View file

@ -1,89 +0,0 @@
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
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/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY=
github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE=
github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,293 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>F1 Streams</title>
</head>
<body>
<h3>Use the players below to watch ad-free</h3>
<h3>If on android - I highly recomment <a href="https://sportzxtv.com/">SportzX</a>!</h3>
<!-- <h3>
If none work go to
<a href="https://f1livegp.me/f1/live.html">https://f1livegp.me/f1/live.html</a>
<a href="http://mx.freestreams-live1.com/f1-live-stream1/">http://mx.freestreams-live1.com/f1-live-stream1/</a> or
<a href="http://freestreams-live1.com/f1-live-streams/">http://freestreams-live1.com/f1-live-streams/</a>
</h3> -->
<!-- <h3>
If you don't see <span id="numstreams"></span> streams try accessing the
page via http*(try incognito if browser keeps opening https):
<a href="http://f1.viktorbarzin.me">http://f1.viktorbarzin.me</a>
</h3>
<h5>
*Some of the stream sources are http and browsers disallow loading mixed
content e.g you loaded the page over https but you are trying to connect
to http stream source which is insecure hence blocked
</h5>
<h5>
When possible, use https as otherwise streams could be monitored, altered
and blocked by upstream ISPs, firewalls etc.
</h5> -->
<div id="root">
<h2><a href="https://wac.rip/streams-pages/motorsports">https://wac.rip/streams-pages/motorsports</a></h2>
<iframe
id="s0-iframe"
width="1000"
height="600"
src="https://wearechecking.live/streams-pages/motorsports"
frameborder="0"
scrolling="yes"
gesture="media"
allow="encrypted-media"
allowfullscreen=""
sandbox="allow-top-navigation=false allow-scripts allow-same-origin allow-forms allow-modals allow-presentation"
></iframe>
<h2><a href="https://vipleague.im/formula-1-schedule-streaming-links">https://vipleague.im/formula-1-schedule-streaming-links</a></h2>
<iframe
id="s1-iframe"
width="1000"
height="600"
src="https://vipleague.im/formula-1-schedule-streaming-links"
frameborder="0"
scrolling="yes"
gesture="media"
allow="encrypted-media"
allowfullscreen=""
sandbox="allow-top-navigation=false allow-scripts allow-same-origin allow-forms allow-modals allow-presentation"
></iframe>
<h2><a href="https://www.vipbox.lc/">https://www.vipbox.lc/</a></h2>
<iframe
id="s2-iframe"
width="1000"
height="600"
src="https://www.vipbox.lc/"
frameborder="0"
scrolling="yes"
gesture="media"
allow="encrypted-media"
allowfullscreen=""
sandbox="allow-top-navigation=false allow-scripts allow-same-origin allow-forms allow-modals allow-presentation"
></iframe>
<h2><a href="https://f1box.me/">https://f1box.me/</a></h2>
<iframe
id="s3-iframe"
width="1000"
height="600"
src="https://f1box.me/"
frameborder="0"
scrolling="yes"
gesture="media"
allow="encrypted-media"
allowfullscreen=""
sandbox="allow-top-navigation=false allow-scripts allow-same-origin allow-forms allow-modals allow-presentation"
></iframe>
<h2><a href="https://1stream.vip/formula-1-streams/">https://1stream.vip/formula-1-streams/</a></h2>
<iframe
id="s4-iframe"
width="1000"
height="600"
src="https://1stream.vip/formula-1-streams/"
frameborder="0"
scrolling="yes"
gesture="media"
allow="encrypted-media"
allowfullscreen=""
sandbox="allow-top-navigation=false allow-scripts allow-same-origin allow-forms allow-modals allow-presentation"
></iframe>
<div id="root">
<h1>
F1 Race replays:
<a href="">https://f1fullraces.com/</a>
</h1>
<h1>
<a href="https://aceztrims.pages.dev/f1/">https://aceztrims.pages.dev/f1/</a>
</h1>
<h1>
<a href="https://freestreams-live.mp/f1-live-stream10/">https://freestreams-live.mp/f1-live-stream10/</a>
</h1>
<h1>
<a href="https://fmhy.net/videopiracyguide#live-sports"
>https://fmhy.net/videopiracyguide#live-sports</a
>
</h1>
<h1>
<a href="https://fmhy.net/videopiracyguide#live-sports">https://fmhy.net/videopiracyguide#live-sports</a>
</h1>
<h1><a href="https://thetvapp.to/"> https://thetvapp.to </a></h1>
<h1>
<a href="http://www.freeintertv.com/">http://www.freeintertv.com/</a>
</h1>
<h1>
<a href="https://www.bg-gledai.video/nacionalni"
>https://www.bg-gledai.video/nacionalni</a
>
</h1>
<!-- <iframe id="s1" onclick='document.getElementById("s1").src="http://mx.freestreams-live1.com/f1-live-stream1/";'
style="border-width: 10mm;" src="http://mx.freestreams-live1.com/f1-live-stream1/" class="embed-responsive-item"
frameborder="1" height="580" width="40%" allowfullscreen="" scrolling="no" allowtransparency=""
sandbox="allow-forms allow-scripts allow-same-origin allow-top-navigation"></iframe> -->
<!-- <iframe id="s1" onclick='document.getElementById("s2").src="https://f1livegp.me/f1/live.html";' -->
<!-- <iframe id="s1" onclick='document.getElementById("s2").src="https://f1livegp.me/f1/live3.html";'
class="embed-responsive-item" frameborder="1" style="border-width: 10mm;" height="580" width="40%"
allowfullscreen="" scrolling="yes" allowtransparency="" src="https://f1livegp.me/f1/live3.html"
sandbox="allow-forms allow-scripts allow-same-origin"
></iframe> -->
<!-- <iframe id="s2" onclick='document.getElementById("s2").src="http://mx.freestreams-live1.com/skysportsf1-stream/";'
class="embed-responsive-item" frameborder="1" style="border-width: 10mm;" height="580" width="40%"
allowfullscreen="" scrolling="yes" allowtransparency="" src=""
sandbox="allow-forms allow-scripts allow-same-origin allow-top-navigation"></iframe> -->
<!-- <iframe id="s3"
onclick='document.getElementById("s3").src="http://fomny.com/Video/United-kindom/Sky-sport/stream/Sky-sport-F1.php";'
class="embed-responsive-item" frameborder="1" style="border-width: 10mm;" height="580" width="40%"
allowfullscreen="" scrolling="yes" allowtransparency="" src=""
sandbox="allow-forms allow-scripts allow-same-origin allow-top-navigation"></iframe> -->
<!-- <iframe id="s4" onclick='document.getElementById("s4").src="https://cricfree.pw/sky-sports-f1-live-stream";'
class="embed-responsive-item" frameborder="1" style="border-width: 10mm;" height="580" width="40%"
allowfullscreen="" scrolling="yes" allowtransparency="" src=""
sandbox="allow-forms allow-scripts allow-same-origin allow-top-navigation"></iframe> -->
<!-- <iframe id="s6" onclick='document.getElementById("s6").src="https://en.viprow.me/sky-sports-f1-online-stream";'
class="embed-responsive-item" frameborder="1" style="border-width: 10mm;" height="580" width="40%"
allowfullscreen="" scrolling="yes" allowtransparency="" src=""
sandbox="allow-forms allow-scripts allow-same-origin "></iframe> -->
<!-- ESPN -->
<!-- <iframe src="http://freestreams-live1.com/usa/espn.php" marginwidth="0" marginheight="0" scrolling="no" width="40%"
height="580" frameborder="0" allowfullscreen="allowfullscreen" sandbox="allow-scripts allow-same-origin"></iframe>
<iframe src="http://freestreams-live1.com/usa/espn2.php" marginwidth="0" marginheight="0" scrolling="no" width="40%"
height="580" frameborder="0" allowfullscreen="allowfullscreen" sandbox="allow-scripts allow-same-origin"></iframe> -->
</div>
<!-- The ones below don't work well :/ -->
<!-- Stream 3 -->
<!-- Domain protected -->
<!-- <iframe
src="https://sportscart.xyz/ch/scplayer-60.php"
width="40%"
height="580"
frameborder="0"
marginwidth="0"
marginheight="0"
scrolling="no"
allowfullscreen="allowfullscreen"
sandbox="allow-scripts allow-same-origin"
></iframe> -->
<!-- Stream 5 -->
<!-- Ads popup not closing :/ -->
<!-- <iframe src="http://channelstream.club/stream/uk_skysport_f1.php" width="100%" height="580" frameborder="0"
marginwidth="0" marginheight="0" scrolling="no" allowfullscreen="allowfullscreen"
sandbox="allow-scripts allow-same-origin allow-forms"></iframe> -->
</body>
<!-- <script>
document.getElementById("numstreams").textContent = document.getElementById(
"root"
).childElementCount;
</script> -->
<script>
// Get a reference to the iframe
var root = document.getElementById("root");
// if (window.self !== window.top) {
// // The code is running inside an iframe
// root.style.backgroundColor = 'lightblue';
// root.innerHTML = '<iframe id="s1-iframe" width="1000" height="600" src="https://wikisport.click/strm/f1.php" frameborder="0" scrolling="no" gesture="media" allow="encrypted-media" allowfullscreen="" ></>';
// } else {
// // The code is running in the parent window
// document.body.style.backgroundColor = 'lightgreen';
// document.body.innerHTML = '<iframe width="1000" height="600" src="https://f1.viktorbarzin.me" sandbox="allow-forms allow-scripts allow-same-origin allow-top-navigation" />';
// }
// Add a 'load' event listener to the iframe
myIframe.addEventListener("load", function () {
// Set the iframe's 'contentWindow.location' property to the current URL
myIframe.contentWindow.location = myIframe.contentWindow.location.href;
});
// Add a 'beforeunload' event listener to the window to prevent redirection
myIframe.addEventListener("beforeunload", function (event) {
// If the event was triggered by a frame...
console.log("before unload");
if (event.target !== window) {
// Prevent the default action of the event
event.preventDefault();
}
});
// Add event listener to handle messages from the iframe
window.addEventListener(
"message",
function (event) {
// Check if the message is a redirect request
if (event.data.redirectTo) {
// Reject the redirect request by logging an error message
console.error(
"Iframe attempted to redirect to:",
event.data.redirectTo
);
}
},
false
);
window.onbeforeunload = function () {
// Check if an iframe attempted to redirect the parent
if (window.location.href != "about:blank") {
// Block the navigation attempt
event.returnValue = "Are you sure you want to leave this page?";
}
};
window.location = new Proxy(window.location, {
set: function (target, property, value, receiver) {
console.log("location edite");
// Check if the caller is the child iframe
if (window.frames.indexOf(receiver) != -1) {
// Block any attempts to modify the location object
console.error(
"Blocked attempt to modify parent window location:",
target,
property,
value
);
return false;
} else {
// Allow other modifications to the location object
return Reflect.set(target, property, value, receiver);
}
},
});
window.history = new Proxy(window.history, {
set: function (target, property, value, receiver) {
console.log("history edite");
// Check if the caller is the child iframe
if (window.frames.indexOf(receiver) != -1) {
// Block any attempts to modify the history object
console.error(
"Blocked attempt to modify parent window history:",
target,
property,
value
);
return false;
} else {
// Allow other modifications to the history object
return Reflect.set(target, property, value, receiver);
}
},
});
</script>
</html>

View file

@ -1,359 +0,0 @@
package auth
import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"sync"
"time"
"f1-stream/internal/models"
"f1-stream/internal/store"
"github.com/go-webauthn/webauthn/webauthn"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,30}$`)
type Auth struct {
store *store.Store
webauthn *webauthn.WebAuthn
adminUsername string
sessionTTL time.Duration
// In-memory storage for WebAuthn ceremony session data (short-lived)
regSessions map[string]*webauthn.SessionData
loginSessions map[string]*webauthn.SessionData
mu sync.Mutex
}
func New(s *store.Store, rpDisplayName, rpID string, rpOrigins []string, adminUsername string, sessionTTL time.Duration) (*Auth, error) {
wconfig := &webauthn.Config{
RPDisplayName: rpDisplayName,
RPID: rpID,
RPOrigins: rpOrigins,
}
w, err := webauthn.New(wconfig)
if err != nil {
return nil, fmt.Errorf("webauthn init: %w", err)
}
return &Auth{
store: s,
webauthn: w,
adminUsername: adminUsername,
sessionTTL: sessionTTL,
regSessions: make(map[string]*webauthn.SessionData),
loginSessions: make(map[string]*webauthn.SessionData),
}, nil
}
// BeginRegistration starts the WebAuthn registration ceremony.
func (a *Auth) BeginRegistration(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if !usernameRe.MatchString(req.Username) {
http.Error(w, `{"error":"username must be 3-30 chars, alphanumeric or underscore"}`, http.StatusBadRequest)
return
}
existing, err := a.store.GetUserByName(req.Username)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, `{"error":"username already taken"}`, http.StatusConflict)
return
}
id, err := randomID()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
isAdmin := false
if a.adminUsername != "" && req.Username == a.adminUsername {
isAdmin = true
} else if a.adminUsername == "" {
count, err := a.store.UserCount()
if err == nil && count == 0 {
isAdmin = true
}
}
user := &models.User{
ID: id,
Username: req.Username,
IsAdmin: isAdmin,
CreatedAt: time.Now(),
}
options, session, err := a.webauthn.BeginRegistration(user)
if err != nil {
log.Printf("BeginRegistration error: %v", err)
http.Error(w, `{"error":"failed to begin registration"}`, http.StatusInternalServerError)
return
}
a.mu.Lock()
a.regSessions[req.Username] = session
a.mu.Unlock()
// Clean up session after 5 minutes
go func() {
time.Sleep(5 * time.Minute)
a.mu.Lock()
delete(a.regSessions, req.Username)
a.mu.Unlock()
}()
// Store user temporarily - will be committed on finish
// We create the user now so FinishRegistration can look it up
if err := a.store.CreateUser(*user); err != nil {
http.Error(w, `{"error":"failed to create user"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
}
// FinishRegistration completes the WebAuthn registration ceremony.
func (a *Auth) FinishRegistration(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
// Username is passed as query param since body is the attestation response
username := r.URL.Query().Get("username")
if username == "" {
// Try to decode from a wrapper
http.Error(w, `{"error":"username required"}`, http.StatusBadRequest)
return
}
req.Username = username
a.mu.Lock()
session, ok := a.regSessions[req.Username]
if ok {
delete(a.regSessions, req.Username)
}
a.mu.Unlock()
if !ok {
http.Error(w, `{"error":"no registration in progress"}`, http.StatusBadRequest)
return
}
user, err := a.store.GetUserByName(req.Username)
if err != nil || user == nil {
http.Error(w, `{"error":"user not found"}`, http.StatusBadRequest)
return
}
credential, err := a.webauthn.FinishRegistration(user, *session, r)
if err != nil {
log.Printf("FinishRegistration error: %v", err)
http.Error(w, `{"error":"registration failed"}`, http.StatusBadRequest)
return
}
user.Credentials = append(user.Credentials, *credential)
if err := a.store.UpdateUserCredentials(user.ID, user.Credentials); err != nil {
http.Error(w, `{"error":"failed to save credential"}`, http.StatusInternalServerError)
return
}
// Create session
token, err := a.store.CreateSession(user.ID, a.sessionTTL)
if err != nil {
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil,
MaxAge: int(a.sessionTTL.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"is_admin": user.IsAdmin,
})
}
// BeginLogin starts the WebAuthn login ceremony.
func (a *Auth) BeginLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
user, err := a.store.GetUserByName(req.Username)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
if len(user.Credentials) == 0 {
http.Error(w, `{"error":"no credentials registered"}`, http.StatusBadRequest)
return
}
options, session, err := a.webauthn.BeginLogin(user)
if err != nil {
log.Printf("BeginLogin error: %v", err)
http.Error(w, `{"error":"failed to begin login"}`, http.StatusInternalServerError)
return
}
a.mu.Lock()
a.loginSessions[req.Username] = session
a.mu.Unlock()
go func() {
time.Sleep(5 * time.Minute)
a.mu.Lock()
delete(a.loginSessions, req.Username)
a.mu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
}
// FinishLogin completes the WebAuthn login ceremony.
func (a *Auth) FinishLogin(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("username")
if username == "" {
http.Error(w, `{"error":"username required"}`, http.StatusBadRequest)
return
}
a.mu.Lock()
session, ok := a.loginSessions[username]
if ok {
delete(a.loginSessions, username)
}
a.mu.Unlock()
if !ok {
http.Error(w, `{"error":"no login in progress"}`, http.StatusBadRequest)
return
}
user, err := a.store.GetUserByName(username)
if err != nil || user == nil {
http.Error(w, `{"error":"user not found"}`, http.StatusBadRequest)
return
}
credential, err := a.webauthn.FinishLogin(user, *session, r)
if err != nil {
log.Printf("FinishLogin error: %v", err)
http.Error(w, `{"error":"login failed"}`, http.StatusUnauthorized)
return
}
// Update credential sign count
for i, c := range user.Credentials {
if string(c.ID) == string(credential.ID) {
user.Credentials[i].Authenticator.SignCount = credential.Authenticator.SignCount
break
}
}
if err := a.store.UpdateUserCredentials(user.ID, user.Credentials); err != nil {
log.Printf("Failed to update credential sign count: %v", err)
}
token, err := a.store.CreateSession(user.ID, a.sessionTTL)
if err != nil {
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil,
MaxAge: int(a.sessionTTL.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"is_admin": user.IsAdmin,
})
}
// Logout clears the session.
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
a.store.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
// Me returns the current user info.
func (a *Auth) Me(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil {
http.Error(w, `{"error":"not authenticated"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"is_admin": user.IsAdmin,
})
}
// GetSessionUser returns the user for a session token.
func (a *Auth) GetSessionUser(token string) (*models.User, error) {
sess, err := a.store.GetSession(token)
if err != nil || sess == nil {
return nil, err
}
return a.store.GetUserByID(sess.UserID)
}
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%x", b), nil
}

View file

@ -1,20 +0,0 @@
package auth
import (
"context"
"f1-stream/internal/models"
)
type contextKey string
const userKey contextKey = "user"
func ContextWithUser(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, userKey, user)
}
func UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(userKey).(*models.User)
return user
}

View file

@ -1,38 +0,0 @@
package extractor
import (
"log"
"os/exec"
)
const maxConcurrentSessions = 10
var sessionSem chan struct{}
// Init starts dbus, PulseAudio, and prepares the session semaphore.
func Init() {
// Start dbus (Chrome needs it for accessibility/service queries)
if err := exec.Command("mkdir", "-p", "/var/run/dbus").Run(); err == nil {
if err := exec.Command("dbus-daemon", "--system", "--nofork").Start(); err != nil {
log.Printf("extractor: warning: failed to start dbus: %v", err)
}
}
if err := exec.Command("pulseaudio", "--start", "--exit-idle-time=-1").Run(); err != nil {
log.Printf("extractor: warning: failed to start PulseAudio: %v", err)
}
// Create a null-sink as the default audio target for all sessions
exec.Command("pactl", "load-module", "module-null-sink",
"sink_name=virtual_sink",
"sink_properties=device.description=VirtualSink").Run()
exec.Command("pactl", "set-default-sink", "virtual_sink").Run()
sessionSem = make(chan struct{}, maxConcurrentSessions)
log.Println("extractor: initialized")
}
// Stop kills PulseAudio.
func Stop() {
exec.Command("pulseaudio", "--kill").Run()
log.Println("extractor: stopped")
}

View file

@ -1,167 +0,0 @@
package extractor
import (
"fmt"
"log"
"os"
"os/exec"
"sync/atomic"
"time"
)
var displayCounter int64 = 99
func nextDisplay() int {
return int(atomic.AddInt64(&displayCounter, 1))
}
// Capture manages an Xvfb display and separate ffmpeg pipelines for video and audio.
// Audio capture is best-effort — if PulseAudio is unavailable, video still works.
type Capture struct {
display int
xvfbCmd *exec.Cmd
videoCmd *exec.Cmd
audioCmd *exec.Cmd
videoR *os.File // IVF pipe reader (VP8 frames)
audioR *os.File // OGG pipe reader (Opus frames)
}
// NewCapture starts Xvfb on the given display and two ffmpeg processes:
// one for video (x11grab → VP8/IVF) and one for audio (pulse → Opus/OGG).
// Audio is best-effort — if it fails to start, video still works and audioR
// is set to a pipe that will return EOF immediately.
func NewCapture(display, width, height int) (*Capture, error) {
c := &Capture{display: display}
// Start Xvfb
screen := fmt.Sprintf("%dx%dx24", width, height)
c.xvfbCmd = exec.Command("Xvfb", fmt.Sprintf(":%d", display),
"-screen", "0", screen, "-ac", "-nolisten", "tcp")
if err := c.xvfbCmd.Start(); err != nil {
return nil, fmt.Errorf("capture: failed to start Xvfb: %w", err)
}
// Wait for Xvfb to be ready (X11 socket must exist)
ready := false
for i := 0; i < 50; i++ {
socketPath := fmt.Sprintf("/tmp/.X11-unix/X%d", display)
if _, err := os.Stat(socketPath); err == nil {
ready = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !ready {
c.xvfbCmd.Process.Kill()
c.xvfbCmd.Wait()
return nil, fmt.Errorf("capture: Xvfb did not start in time for display :%d", display)
}
// --- Video pipeline (required) ---
videoR, videoW, err := os.Pipe()
if err != nil {
c.cleanup()
return nil, fmt.Errorf("capture: video pipe: %w", err)
}
c.videoCmd = exec.Command("ffmpeg",
"-loglevel", "warning",
"-f", "x11grab", "-framerate", "30",
"-video_size", fmt.Sprintf("%dx%d", width, height),
"-i", fmt.Sprintf(":%d", display),
"-c:v", "libvpx",
"-quality", "realtime", "-cpu-used", "8",
"-deadline", "realtime", "-b:v", "2M", "-g", "30",
"-f", "ivf", "pipe:3",
)
c.videoCmd.ExtraFiles = []*os.File{videoW}
c.videoCmd.Stdout = os.Stderr
c.videoCmd.Stderr = os.Stderr
if err := c.videoCmd.Start(); err != nil {
videoR.Close()
videoW.Close()
c.cleanup()
return nil, fmt.Errorf("capture: failed to start video ffmpeg: %w", err)
}
videoW.Close()
c.videoR = videoR
go func() {
if err := c.videoCmd.Wait(); err != nil {
log.Printf("capture: video ffmpeg exited on display :%d: %v", display, err)
}
}()
// --- Audio pipeline (best-effort) ---
audioR, audioW, err := os.Pipe()
if err != nil {
log.Printf("capture: audio pipe failed on display :%d: %v (continuing without audio)", display, err)
// Provide a closed pipe so StreamAudio gets EOF immediately
r, w, _ := os.Pipe()
w.Close()
c.audioR = r
log.Printf("capture: started display :%d (%dx%d) (video only)", display, width, height)
return c, nil
}
c.audioCmd = exec.Command("ffmpeg",
"-loglevel", "warning",
"-f", "pulse", "-i", "virtual_sink.monitor",
"-c:a", "libopus",
"-b:a", "128k", "-application", "lowdelay",
"-f", "ogg", "pipe:3",
)
c.audioCmd.ExtraFiles = []*os.File{audioW}
c.audioCmd.Stdout = os.Stderr
c.audioCmd.Stderr = os.Stderr
if err := c.audioCmd.Start(); err != nil {
log.Printf("capture: audio ffmpeg failed to start on display :%d: %v (continuing without audio)", display, err)
audioR.Close()
audioW.Close()
// Provide a closed pipe so StreamAudio gets EOF immediately
r, w, _ := os.Pipe()
w.Close()
c.audioR = r
c.audioCmd = nil
log.Printf("capture: started display :%d (%dx%d) (video only)", display, width, height)
return c, nil
}
audioW.Close()
c.audioR = audioR
go func() {
if err := c.audioCmd.Wait(); err != nil {
log.Printf("capture: audio ffmpeg exited on display :%d: %v", display, err)
}
}()
log.Printf("capture: started display :%d (%dx%d) (video + audio)", display, width, height)
return c, nil
}
func (c *Capture) cleanup() {
if c.xvfbCmd != nil && c.xvfbCmd.Process != nil {
c.xvfbCmd.Process.Kill()
c.xvfbCmd.Wait()
}
}
// Close stops ffmpeg processes, Xvfb, and releases pipe resources.
func (c *Capture) Close() {
if c.videoCmd != nil && c.videoCmd.Process != nil {
c.videoCmd.Process.Kill()
}
if c.audioCmd != nil && c.audioCmd.Process != nil {
c.audioCmd.Process.Kill()
}
if c.videoR != nil {
c.videoR.Close()
}
if c.audioR != nil {
c.audioR.Close()
}
c.cleanup()
log.Printf("capture: stopped display :%d", c.display)
}

View file

@ -1,383 +0,0 @@
package extractor
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"github.com/pion/webrtc/v4"
)
const (
sessionTimeout = 5 * time.Minute
defaultViewportW = 1280
defaultViewportH = 720
turnCredentialTTL = 24 * time.Hour
)
var (
turnURL string
turnSharedSecret string
turnInternalURL string
)
// SetTURNConfig sets the TURN server URL, shared secret, and optional internal URL.
// The internal URL is used by pion (server-side) to avoid hairpin NAT issues.
// The public URL is sent to the browser client.
func SetTURNConfig(url, secret, internalURL string) {
turnURL = url
turnSharedSecret = secret
turnInternalURL = internalURL
if turnInternalURL == "" {
turnInternalURL = "turn:coturn.coturn.svc.cluster.local:3478"
}
log.Printf("extractor: TURN configured: public=%s internal=%s", url, turnInternalURL)
}
var adDomains = []string{
"doubleclick.net", "googlesyndication.com", "googleadservices.com",
"google-analytics.com", "adnxs.com", "criteo.com", "outbrain.com",
"taboola.com", "amazon-adsystem.com", "popads.net", "popcash.net",
"juicyads.com", "exoclick.com", "trafficjunky.com", "propellerads.com",
"adsterra.com", "hilltopads.net", "revcontent.com", "mgid.com",
}
type inputMsg struct {
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Button int `json:"button"`
DeltaX float64 `json:"deltaX"`
DeltaY float64 `json:"deltaY"`
Key string `json:"key"`
Code string `json:"code"`
Mods int `json:"modifiers"`
Width int `json:"width"`
Height int `json:"height"`
SDP string `json:"sdp"`
Candidate *webrtc.ICECandidateInit `json:"candidate"`
}
// HandleBrowserSession upgrades to WebSocket and runs a remote browser session
// with WebRTC video/audio streaming and CDP input relay.
func HandleBrowserSession(w http.ResponseWriter, r *http.Request, pageURL string) {
// Check session capacity
select {
case sessionSem <- struct{}{}:
defer func() { <-sessionSem }()
default:
http.Error(w, `{"error":"too many active browser sessions"}`, http.StatusServiceUnavailable)
return
}
conn, _, _, err := ws.UpgradeHTTP(r, w)
if err != nil {
log.Printf("extractor: session: ws upgrade failed: %v", err)
return
}
defer conn.Close()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Allocate display and start capture pipeline
display := nextDisplay()
viewW, viewH := defaultViewportW, defaultViewportH
cap, err := NewCapture(display, viewW, viewH)
if err != nil {
sendWSError(conn, "failed to start capture: "+err.Error())
log.Printf("extractor: session: capture error: %v", err)
return
}
defer cap.Close()
// Start Chrome on the virtual display
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-software-rasterizer", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("disable-extensions", true),
chromedp.Flag("disable-background-networking", true),
chromedp.ModifyCmdFunc(func(cmd *exec.Cmd) {
cmd.Env = append(os.Environ(), fmt.Sprintf("DISPLAY=:%d", display))
}),
chromedp.Flag("autoplay-policy", "no-user-gesture-required"),
chromedp.Flag("window-size", fmt.Sprintf("%d,%d", viewW, viewH)),
chromedp.WSURLReadTimeout(30 * time.Second),
)
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
defer allocCancel()
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
defer tabCancel()
var wsMu sync.Mutex
// Build ICE servers for pion (server-side) — uses internal TURN URL to avoid hairpin NAT
iceServers := []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
}
var turnCreds *TURNCredentials
if turnURL != "" && turnSharedSecret != "" {
// Server-side: use internal k8s DNS for TURN to bypass NAT
internalCreds := GenerateTURNCredentials(turnInternalURL, turnSharedSecret, turnCredentialTTL)
turnCreds = &internalCreds
iceServers = append(iceServers, webrtc.ICEServer{
URLs: internalCreds.URLs,
Username: internalCreds.Username,
Credential: internalCreds.Credential,
CredentialType: webrtc.ICECredentialTypePassword,
})
}
// Build ad-blocking fetch patterns
adPatterns := make([]*fetch.RequestPattern, 0, len(adDomains))
for _, domain := range adDomains {
adPatterns = append(adPatterns, &fetch.RequestPattern{
URLPattern: fmt.Sprintf("*://*.%s/*", domain),
})
}
// Set up event listeners before navigation
chromedp.ListenTarget(tabCtx, func(ev interface{}) {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
go chromedp.Run(tabCtx, fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient))
case *page.EventFrameNavigated:
if e.Frame.ParentID == "" {
go sendURLUpdate(tabCtx, conn, &wsMu, e.Frame.URL)
}
case *page.EventNavigatedWithinDocument:
go sendURLUpdate(tabCtx, conn, &wsMu, e.URL)
}
})
// Enable fetch interception (ad blocking) and navigate
if err := chromedp.Run(tabCtx,
fetch.Enable().WithPatterns(adPatterns),
chromedp.Navigate(pageURL),
chromedp.WaitReady("body"),
); err != nil {
sendWSError(conn, "navigation failed")
log.Printf("extractor: session: navigate error for %s: %v", pageURL, err)
return
}
// Create WebRTC media stream
mediaStream, err := NewMediaStream(iceServers, func(c *webrtc.ICECandidate) {
data, _ := json.Marshal(map[string]interface{}{
"type": "ice",
"candidate": c.ToJSON(),
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, data)
wsMu.Unlock()
}, cancel)
if err != nil {
sendWSError(conn, "WebRTC setup failed")
log.Printf("extractor: session: webrtc error: %v", err)
return
}
defer mediaStream.Close()
// Create and send SDP offer
sdp, err := mediaStream.Offer()
if err != nil {
sendWSError(conn, "WebRTC offer failed")
log.Printf("extractor: session: offer error: %v", err)
return
}
// Send ICE config to client — uses PUBLIC TURN URL (for browser to reach from internet)
clientICE := []map[string]interface{}{
{"urls": []string{"stun:stun.l.google.com:19302"}},
}
if turnCreds != nil {
// Client-side: use public IP for TURN (browser connects from internet)
publicCreds := GenerateTURNCredentials(turnURL, turnSharedSecret, turnCredentialTTL)
clientICE = append(clientICE, map[string]interface{}{
"urls": publicCreds.URLs,
"username": publicCreds.Username,
"credential": publicCreds.Credential,
})
}
iceMsg, _ := json.Marshal(map[string]interface{}{
"type": "iceServers",
"iceServers": clientICE,
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, iceMsg)
wsMu.Unlock()
offerMsg, _ := json.Marshal(map[string]interface{}{
"type": "offer",
"sdp": sdp,
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, offerMsg)
wsMu.Unlock()
// Send ready message with viewport dimensions
readyMsg, _ := json.Marshal(map[string]interface{}{
"type": "ready",
"width": viewW,
"height": viewH,
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, readyMsg)
wsMu.Unlock()
// Start streaming video and audio from capture pipes
go mediaStream.StreamVideo(cap.videoR, ctx)
go mediaStream.StreamAudio(cap.audioR, ctx)
log.Printf("extractor: session: started for %s (display :%d)", pageURL, display)
// Inactivity timer — cancels session after no client input
inactivity := time.NewTimer(sessionTimeout)
defer inactivity.Stop()
go func() {
select {
case <-inactivity.C:
log.Printf("extractor: session: inactivity timeout for %s", pageURL)
cancel()
case <-ctx.Done():
}
}()
// Read loop — process signaling and input messages
for {
msgs, err := wsutil.ReadClientMessage(conn, nil)
if err != nil {
break
}
for _, m := range msgs {
if m.OpCode != ws.OpText {
continue
}
// Reset inactivity timer
if !inactivity.Stop() {
select {
case <-inactivity.C:
default:
}
}
inactivity.Reset(sessionTimeout)
var msg inputMsg
if err := json.Unmarshal(m.Payload, &msg); err != nil {
continue
}
switch msg.Type {
case "answer":
if err := mediaStream.SetAnswer(msg.SDP); err != nil {
log.Printf("extractor: session: set answer error: %v", err)
}
case "ice":
if msg.Candidate != nil {
if err := mediaStream.AddICECandidate(*msg.Candidate); err != nil {
log.Printf("extractor: session: add ICE error: %v", err)
}
}
case "back":
chromedp.Run(tabCtx, chromedp.NavigateBack())
case "forward":
chromedp.Run(tabCtx, chromedp.NavigateForward())
default:
handleInput(tabCtx, &msg)
}
}
}
log.Printf("extractor: session: ended for %s", pageURL)
}
func handleInput(ctx context.Context, msg *inputMsg) {
switch msg.Type {
case "mousemove":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MouseMoved, msg.X, msg.Y))
case "mousedown":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MousePressed, msg.X, msg.Y).
WithButton(mapButton(msg.Button)).WithClickCount(1))
case "mouseup":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MouseReleased, msg.X, msg.Y).
WithButton(mapButton(msg.Button)))
case "scroll":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MouseWheel, msg.X, msg.Y).
WithDeltaX(msg.DeltaX).WithDeltaY(msg.DeltaY))
case "keydown":
chromedp.Run(ctx,
input.DispatchKeyEvent(input.KeyDown).
WithKey(msg.Key).WithCode(msg.Code).
WithModifiers(input.Modifier(msg.Mods)))
case "keyup":
chromedp.Run(ctx,
input.DispatchKeyEvent(input.KeyUp).
WithKey(msg.Key).WithCode(msg.Code).
WithModifiers(input.Modifier(msg.Mods)))
}
}
func mapButton(jsButton int) input.MouseButton {
switch jsButton {
case 1:
return input.Middle
case 2:
return input.Right
default:
return input.Left
}
}
func sendURLUpdate(tabCtx context.Context, conn net.Conn, mu *sync.Mutex, currentURL string) {
var canBack, canForward bool
var entries []*page.NavigationEntry
var currentIndex int64
if err := chromedp.Run(tabCtx, chromedp.ActionFunc(func(ctx context.Context) error {
var err error
currentIndex, entries, err = page.GetNavigationHistory().Do(ctx)
return err
})); err == nil {
canBack = currentIndex > 0
canForward = int(currentIndex) < len(entries)-1
}
data, _ := json.Marshal(map[string]interface{}{
"type": "url",
"url": currentURL,
"canBack": canBack,
"canForward": canForward,
})
mu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, data)
mu.Unlock()
}
func sendWSError(conn net.Conn, msg string) {
data, _ := json.Marshal(map[string]string{"type": "error", "message": msg})
wsutil.WriteServerMessage(conn, ws.OpText, data)
}

View file

@ -1,248 +0,0 @@
package extractor
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"io"
"log"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
"github.com/pion/webrtc/v4/pkg/media/oggreader"
)
// TURNCredentials holds ephemeral TURN credentials generated from a shared secret.
type TURNCredentials struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}
// GenerateTURNCredentials creates time-limited TURN credentials using the
// shared secret (TURN REST API / coturn --use-auth-secret).
func GenerateTURNCredentials(turnURL, sharedSecret string, ttl time.Duration) TURNCredentials {
expiry := time.Now().Add(ttl).Unix()
username := fmt.Sprintf("%d", expiry)
mac := hmac.New(sha1.New, []byte(sharedSecret))
mac.Write([]byte(username))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return TURNCredentials{
URLs: []string{turnURL},
Username: username,
Credential: credential,
}
}
// MediaStream wraps a pion WebRTC PeerConnection with VP8 video and Opus audio tracks.
type MediaStream struct {
pc *webrtc.PeerConnection
videoTrack *webrtc.TrackLocalStaticSample
audioTrack *webrtc.TrackLocalStaticSample
}
// NewMediaStream creates a PeerConnection with VP8 + Opus tracks and an ICE callback.
// The cancel function is called when ICE fails to trigger session cleanup.
func NewMediaStream(iceServers []webrtc.ICEServer, onICE func(*webrtc.ICECandidate), cancel context.CancelFunc) (*MediaStream, error) {
config := webrtc.Configuration{
ICEServers: iceServers,
}
pc, err := webrtc.NewPeerConnection(config)
if err != nil {
return nil, err
}
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
"video", "stream",
)
if err != nil {
pc.Close()
return nil, err
}
audioTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio", "stream",
)
if err != nil {
pc.Close()
return nil, err
}
if _, err = pc.AddTrack(videoTrack); err != nil {
pc.Close()
return nil, err
}
if _, err = pc.AddTrack(audioTrack); err != nil {
pc.Close()
return nil, err
}
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Printf("webrtc: ICE connection state: %s", state.String())
if state == webrtc.ICEConnectionStateFailed {
log.Printf("webrtc: ICE failed, cancelling session")
cancel()
return
}
if state == webrtc.ICEConnectionStateConnected {
// Log selected candidate pair
if stats := pc.GetStats(); stats != nil {
for _, s := range stats {
if cp, ok := s.(webrtc.ICECandidatePairStats); ok && cp.Nominated {
log.Printf("webrtc: selected candidate pair: local=%s remote=%s",
cp.LocalCandidateID, cp.RemoteCandidateID)
}
}
}
// Start periodic stats logging
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
if pc.ICEConnectionState() != webrtc.ICEConnectionStateConnected &&
pc.ICEConnectionState() != webrtc.ICEConnectionStateCompleted {
return
}
stats := pc.GetStats()
for _, s := range stats {
if out, ok := s.(webrtc.OutboundRTPStreamStats); ok {
log.Printf("webrtc: outbound-rtp kind=%s bytes=%d packets=%d",
out.Kind, out.BytesSent, out.PacketsSent)
}
}
}
}()
}
})
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
log.Printf("webrtc: peer connection state: %s", state.String())
})
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c != nil {
log.Printf("webrtc: gathered ICE candidate: type=%s addr=%s:%d",
c.Typ.String(), c.Address, c.Port)
if onICE != nil {
onICE(c)
}
}
})
return &MediaStream{
pc: pc,
videoTrack: videoTrack,
audioTrack: audioTrack,
}, nil
}
// Offer creates an SDP offer, sets it as local description, and returns the SDP string.
func (m *MediaStream) Offer() (string, error) {
offer, err := m.pc.CreateOffer(nil)
if err != nil {
return "", err
}
if err := m.pc.SetLocalDescription(offer); err != nil {
return "", err
}
return offer.SDP, nil
}
// SetAnswer sets the remote SDP answer.
func (m *MediaStream) SetAnswer(sdp string) error {
return m.pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: sdp,
})
}
// AddICECandidate adds a remote ICE candidate.
func (m *MediaStream) AddICECandidate(init webrtc.ICECandidateInit) error {
return m.pc.AddICECandidate(init)
}
// StreamVideo reads VP8 frames from an IVF stream and writes them to the video track.
// Blocks until the reader returns an error or the context is cancelled.
func (m *MediaStream) StreamVideo(r io.Reader, ctx context.Context) {
ivf, _, err := ivfreader.NewWith(r)
if err != nil {
log.Printf("webrtc: ivf reader error: %v", err)
return
}
duration := time.Second / 30
for {
select {
case <-ctx.Done():
return
default:
}
frame, _, err := ivf.ParseNextFrame()
if err != nil {
if err != io.EOF {
log.Printf("webrtc: video frame error: %v", err)
}
return
}
if err := m.videoTrack.WriteSample(media.Sample{
Data: frame,
Duration: duration,
}); err != nil {
log.Printf("webrtc: video write error: %v", err)
return
}
}
}
// StreamAudio reads Opus pages from an OGG stream and writes them to the audio track.
// Blocks until the reader returns an error or the context is cancelled.
func (m *MediaStream) StreamAudio(r io.Reader, ctx context.Context) {
ogg, _, err := oggreader.NewWith(r)
if err != nil {
log.Printf("webrtc: ogg reader error: %v", err)
return
}
for {
select {
case <-ctx.Done():
return
default:
}
page, _, err := ogg.ParseNextPage()
if err != nil {
if err != io.EOF {
log.Printf("webrtc: audio page error: %v", err)
}
return
}
if err := m.audioTrack.WriteSample(media.Sample{
Data: page,
Duration: 20 * time.Millisecond,
}); err != nil {
log.Printf("webrtc: audio write error: %v", err)
return
}
}
}
// Close closes the underlying PeerConnection.
func (m *MediaStream) Close() {
if m.pc != nil {
m.pc.Close()
}
}

View file

@ -1,188 +0,0 @@
package healthcheck
import (
"context"
"log"
"net/http"
"sync"
"time"
"f1-stream/internal/models"
"f1-stream/internal/store"
)
const unhealthyThreshold = 5
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
// isReachable sends a GET request and returns true if the server responds with
// an HTTP 2xx or 3xx status code.
func isReachable(client *http.Client, rawURL string) bool {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 400
}
type HealthChecker struct {
store *store.Store
interval time.Duration
timeout time.Duration
client *http.Client
mu sync.Mutex
}
func New(s *store.Store, interval, timeout time.Duration) *HealthChecker {
return &HealthChecker{
store: s,
interval: interval,
timeout: timeout,
client: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
},
}
}
func (hc *HealthChecker) Run(ctx context.Context) {
log.Printf("healthcheck: starting with interval=%v timeout=%v", hc.interval, hc.timeout)
hc.checkAll()
ticker := time.NewTicker(hc.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("healthcheck: shutting down")
return
case <-ticker.C:
hc.checkAll()
}
}
}
func (hc *HealthChecker) checkAll() {
hc.mu.Lock()
defer hc.mu.Unlock()
start := time.Now()
urls := hc.collectURLs()
log.Printf("healthcheck: checking %d URLs", len(urls))
existing, err := hc.store.LoadHealthStates()
if err != nil {
log.Printf("healthcheck: failed to load health states: %v", err)
existing = nil
}
stateMap := make(map[string]*models.HealthState, len(existing))
for i := range existing {
stateMap[existing[i].URL] = &existing[i]
}
now := time.Now()
var recovered, newlyUnhealthy int
for _, url := range urls {
st, exists := stateMap[url]
if !exists {
st = &models.HealthState{
URL: url,
Healthy: true,
}
stateMap[url] = st
}
ok := isReachable(hc.client, url)
if ok {
if !st.Healthy {
log.Printf("healthcheck: recovered %s", truncate(url, 80))
recovered++
}
st.ConsecutiveFailures = 0
st.Healthy = true
} else {
st.ConsecutiveFailures++
if st.ConsecutiveFailures >= unhealthyThreshold && st.Healthy {
st.Healthy = false
log.Printf("healthcheck: marking unhealthy after %d failures: %s", st.ConsecutiveFailures, truncate(url, 80))
newlyUnhealthy++
}
}
st.LastCheckTime = now
}
// Prune orphaned entries: only keep states whose URL is in the current set
urlSet := make(map[string]bool, len(urls))
for _, u := range urls {
urlSet[u] = true
}
var finalStates []models.HealthState
healthyCount := 0
for _, st := range stateMap {
if urlSet[st.URL] {
finalStates = append(finalStates, *st)
if st.Healthy {
healthyCount++
}
}
}
if err := hc.store.SaveHealthStates(finalStates); err != nil {
log.Printf("healthcheck: failed to save health states: %v", err)
}
log.Printf("healthcheck: done in %v, checked=%d healthy=%d recovered=%d newly_unhealthy=%d",
time.Since(start).Round(time.Millisecond), len(urls), healthyCount, recovered, newlyUnhealthy)
}
func (hc *HealthChecker) collectURLs() []string {
seen := make(map[string]bool)
streams, err := hc.store.LoadStreams()
if err != nil {
log.Printf("healthcheck: failed to load streams: %v", err)
} else {
for _, s := range streams {
seen[s.URL] = true
}
}
scraped, err := hc.store.LoadScrapedLinks()
if err != nil {
log.Printf("healthcheck: failed to load scraped links: %v", err)
} else {
for _, l := range scraped {
seen[l.URL] = true
}
}
urls := make([]string, 0, len(seen))
for u := range seen {
urls = append(urls, u)
}
return urls
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -1,209 +0,0 @@
package hlsproxy
import (
"bufio"
"encoding/base64"
"io"
"log"
"net/http"
"net/url"
"strings"
)
// NewHandler returns an http.Handler for /hls/{base64url_encoded_full_url}.
// It proxies HLS playlists and segments, rewriting m3u8 URLs to route
// through the proxy and forwarding X-Hls-Forward-* headers upstream.
func NewHandler() http.Handler {
client := &http.Client{
Timeout: 30_000_000_000, // 30s
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return http.ErrUseLastResponse
}
return nil
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
setCORS(w)
w.WriteHeader(http.StatusNoContent)
return
}
// Parse: /hls/{base64url_encoded_full_url}
trimmed := strings.TrimPrefix(r.URL.Path, "/hls/")
if trimmed == "" || trimmed == r.URL.Path {
http.Error(w, "bad hls proxy URL", http.StatusBadRequest)
return
}
// Decode the full upstream URL from base64url
upstreamURL, err := base64.RawURLEncoding.DecodeString(trimmed)
if err != nil {
http.Error(w, "invalid base64url", http.StatusBadRequest)
return
}
target := string(upstreamURL)
parsed, err := url.Parse(target)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
http.Error(w, "invalid upstream URL", http.StatusBadRequest)
return
}
log.Printf("hlsproxy: %s -> %s", r.URL.Path, target)
upReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
// Set Referer and Origin. If the URL has a ?domain= param (CDN segments),
// use that domain as the origin so the CDN accepts the request.
refererOrigin := parsed.Scheme + "://" + parsed.Host
if domainParam := parsed.Query().Get("domain"); domainParam != "" {
refererOrigin = "https://" + domainParam
}
upReq.Header.Set("Referer", refererOrigin+"/")
upReq.Header.Set("Origin", refererOrigin)
upReq.Header.Set("User-Agent", r.Header.Get("User-Agent"))
// Forward X-Hls-Forward-* headers (strip prefix)
for key, vals := range r.Header {
if strings.HasPrefix(key, "X-Hls-Forward-") {
realKey := strings.TrimPrefix(key, "X-Hls-Forward-")
for _, v := range vals {
upReq.Header.Set(realKey, v)
}
}
}
resp, err := client.Do(upReq)
if err != nil {
log.Printf("hlsproxy: upstream fetch failed: %v", err)
http.Error(w, "upstream fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
log.Printf("hlsproxy: %s <- %d (%s)", truncPath(r.URL.Path, 60), resp.StatusCode, resp.Header.Get("Content-Type"))
setCORS(w)
ct := resp.Header.Get("Content-Type")
isM3U8 := strings.Contains(ct, "mpegurl") ||
strings.Contains(ct, "x-mpegURL") ||
strings.HasSuffix(parsed.Path, ".m3u8")
if isM3U8 {
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.WriteHeader(resp.StatusCode)
rewriteM3U8(w, resp.Body, target)
return
}
// Stream segment or other content directly
for key, vals := range resp.Header {
lk := strings.ToLower(key)
if lk == "content-type" || lk == "content-length" || lk == "cache-control" || lk == "accept-ranges" {
for _, v := range vals {
w.Header().Add(key, v)
}
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
})
}
// rewriteM3U8 reads an m3u8 playlist from r, rewrites segment/playlist URLs
// to route through /hls/{b64}, and writes the result to w.
func rewriteM3U8(w io.Writer, r io.Reader, playlistURL string) {
base, err := url.Parse(playlistURL)
if err != nil {
io.Copy(w, r)
return
}
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") {
// Rewrite URI="..." in directives like #EXT-X-KEY, #EXT-X-MAP
rewritten := rewriteURIAttribute(line, base)
w.Write([]byte(rewritten))
w.Write([]byte("\n"))
continue
}
// Non-comment, non-empty lines are URLs
trimmed := strings.TrimSpace(line)
if trimmed == "" {
w.Write([]byte("\n"))
continue
}
resolved := resolveURL(base, trimmed)
encoded := encodeHLSURL(resolved)
w.Write([]byte(encoded))
w.Write([]byte("\n"))
}
}
// rewriteURIAttribute rewrites URI="..." attributes in HLS directives.
func rewriteURIAttribute(line string, base *url.URL) string {
// Look for URI="..." (case insensitive)
uriIdx := strings.Index(strings.ToUpper(line), "URI=\"")
if uriIdx == -1 {
return line
}
// Find the actual position (preserving original case)
prefix := line[:uriIdx+5] // everything up to and including URI="
rest := line[uriIdx+5:]
endQuote := strings.Index(rest, "\"")
if endQuote == -1 {
return line
}
uri := rest[:endQuote]
suffix := rest[endQuote:] // closing quote and anything after
resolved := resolveURL(base, uri)
encoded := encodeHLSURL(resolved)
return prefix + encoded + suffix
}
// resolveURL resolves a potentially relative URL against a base URL.
func resolveURL(base *url.URL, ref string) string {
refURL, err := url.Parse(ref)
if err != nil {
return ref
}
return base.ResolveReference(refURL).String()
}
// encodeHLSURL encodes a full URL into /hls/{base64url} format.
func encodeHLSURL(fullURL string) string {
encoded := base64.RawURLEncoding.EncodeToString([]byte(fullURL))
return "/hls/" + encoded
}
func setCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
func truncPath(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View file

@ -1,53 +0,0 @@
package models
import (
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
Credentials []webauthn.Credential `json:"credentials"`
CreatedAt time.Time `json:"created_at"`
}
// WebAuthn interface implementation
func (u *User) WebAuthnID() []byte { return []byte(u.ID) }
func (u *User) WebAuthnName() string { return u.Username }
func (u *User) WebAuthnDisplayName() string { return u.Username }
func (u *User) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
type Stream struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
SubmittedBy string `json:"submitted_by"`
Published bool `json:"published"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
}
type ScrapedLink struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Source string `json:"source"`
ScrapedAt time.Time `json:"scraped_at"`
Stale bool `json:"stale"`
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type HealthState struct {
URL string `json:"url"`
ConsecutiveFailures int `json:"consecutive_failures"`
LastCheckTime time.Time `json:"last_check_time"`
Healthy bool `json:"healthy"`
}

View file

@ -1,512 +0,0 @@
package playerconfig
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// PlayerConfig is returned by the /api/streams/{id}/player-config endpoint.
type PlayerConfig struct {
Type string `json:"type"`
HLSURL string `json:"hls_url,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
ChannelKey string `json:"channel_key,omitempty"`
ChannelSalt string `json:"channel_salt,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
AuthModURL string `json:"auth_mod_url,omitempty"`
ServerKey string `json:"server_key,omitempty"`
Error string `json:"error,omitempty"`
}
type cacheEntry struct {
config *PlayerConfig
expiresAt time.Time
}
// Service handles stream type detection and DaddyLive config extraction.
type Service struct {
client *http.Client
mu sync.RWMutex
cache map[string]*cacheEntry
}
// New creates a new playerconfig Service.
func New() *Service {
return &Service{
client: &http.Client{
Timeout: 15 * time.Second,
},
cache: make(map[string]*cacheEntry),
}
}
// DetectStreamType returns "hls", "daddylive", "vipleague", or "proxy" based on the URL.
func DetectStreamType(rawURL string) string {
lower := strings.ToLower(rawURL)
if strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ".m3u8") {
return "hls"
}
daddyPatterns := []string{"dlhd.link", "dlhd.sx", "dlhd.dad", "daddylive", "ksohls.ru"}
for _, p := range daddyPatterns {
if strings.Contains(lower, p) {
return "daddylive"
}
}
vipPatterns := []string{"vipleague.io", "vipleague.im", "vipleague.cc", "casthill.net"}
for _, p := range vipPatterns {
if strings.Contains(lower, p) {
return "vipleague"
}
}
return "proxy"
}
// GetConfig returns a PlayerConfig for the given stream URL.
func (s *Service) GetConfig(ctx context.Context, rawURL string) *PlayerConfig {
streamType := DetectStreamType(rawURL)
switch streamType {
case "hls":
encoded := base64.RawURLEncoding.EncodeToString([]byte(rawURL))
return &PlayerConfig{
Type: "hls",
HLSURL: "/hls/" + encoded,
}
case "daddylive":
return s.getDaddyLiveConfig(ctx, rawURL)
case "vipleague":
return s.getVIPLeagueConfig(ctx, rawURL)
default:
return &PlayerConfig{Type: "proxy"}
}
}
// Channel ID extraction patterns
var channelIDPatterns = []*regexp.Regexp{
regexp.MustCompile(`stream-(\d+)\.php`),
regexp.MustCompile(`[?&]id=(\d+)`),
regexp.MustCompile(`/(\d+)\.php`),
}
// Page content extraction patterns
var (
iframeRe = regexp.MustCompile(`<iframe[^>]*src=["'](https?://[^"']*ksohls\.ru[^"']*)["']`)
authTokenRe = regexp.MustCompile(`authToken\s*[:=]\s*['"]([^'"]+)['"]`)
channelKeyRe = regexp.MustCompile(`channelKey\s*[:=]\s*['"]([^'"]+)['"]`)
channelSaltRe = regexp.MustCompile(`channelSalt\s*[:=]\s*['"]([^'"]+)['"]`)
timestampRe = regexp.MustCompile(`timestamp\s*[:=]\s*['"]?(\d+)['"]?`)
authModRe = regexp.MustCompile(`<script[^>]*src=["'](https?://[^"']*aiaged\.fun[^"']*obfuscated[^"']*)["']`)
)
func (s *Service) getDaddyLiveConfig(ctx context.Context, rawURL string) *PlayerConfig {
// Check cache
s.mu.RLock()
if entry, ok := s.cache[rawURL]; ok && time.Now().Before(entry.expiresAt) {
s.mu.RUnlock()
return entry.config
}
s.mu.RUnlock()
config := s.fetchDaddyLiveConfig(ctx, rawURL)
// Cache the result (even errors, to avoid hammering)
s.mu.Lock()
s.cache[rawURL] = &cacheEntry{
config: config,
expiresAt: time.Now().Add(1 * time.Hour),
}
s.mu.Unlock()
return config
}
func (s *Service) fetchDaddyLiveConfig(ctx context.Context, rawURL string) *PlayerConfig {
// Step 1: Extract channel ID from URL
channelID := ""
for _, re := range channelIDPatterns {
if m := re.FindStringSubmatch(rawURL); len(m) > 1 {
channelID = m[1]
break
}
}
if channelID == "" {
return &PlayerConfig{Type: "proxy", Error: "could not extract channel ID"}
}
log.Printf("playerconfig: DaddyLive channel=%s from %s", channelID, rawURL)
return s.fetchDaddyLiveConfigByID(ctx, channelID)
}
func (s *Service) fetchDaddyLiveConfigByID(ctx context.Context, channelID string) *PlayerConfig {
// Step 2: Fetch the cast page to find the ksohls iframe
castURL := fmt.Sprintf("https://dlhd.link/cast/stream-%s.php", channelID)
castBody, err := s.fetchPage(ctx, castURL, "https://dlhd.link/")
if err != nil {
log.Printf("playerconfig: failed to fetch cast page: %v", err)
return &PlayerConfig{Type: "proxy", Error: "failed to fetch cast page"}
}
// Step 3: Extract ksohls iframe URL
iframeMatch := iframeRe.FindStringSubmatch(castBody)
if iframeMatch == nil {
log.Printf("playerconfig: no ksohls iframe found in cast page")
return &PlayerConfig{Type: "proxy", Error: "no ksohls iframe found"}
}
ksohURL := iframeMatch[1]
// Step 4: Fetch the ksohls page
referer := fmt.Sprintf("https://dlhd.link/stream/stream-%s.php", channelID)
ksohBody, err := s.fetchPage(ctx, ksohURL, referer)
if err != nil {
log.Printf("playerconfig: failed to fetch ksohls page: %v", err)
return &PlayerConfig{Type: "proxy", Error: "failed to fetch ksohls page"}
}
// Step 5: Extract auth params from ksohls page
config := &PlayerConfig{Type: "daddylive"}
if m := authTokenRe.FindStringSubmatch(ksohBody); len(m) > 1 {
config.AuthToken = m[1]
}
if m := channelKeyRe.FindStringSubmatch(ksohBody); len(m) > 1 {
config.ChannelKey = m[1]
}
if m := channelSaltRe.FindStringSubmatch(ksohBody); len(m) > 1 {
config.ChannelSalt = m[1]
}
if m := timestampRe.FindStringSubmatch(ksohBody); len(m) > 1 {
config.Timestamp = m[1]
}
if m := authModRe.FindStringSubmatch(ksohBody); len(m) > 1 {
config.AuthModURL = m[1]
}
if config.ChannelKey == "" {
log.Printf("playerconfig: no channelKey found in ksohls page")
return &PlayerConfig{Type: "proxy", Error: "no channelKey found"}
}
// Step 6: Server lookup
lookupURL := fmt.Sprintf("https://chevy.soyspace.cyou/server_lookup?channel_id=%s", config.ChannelKey)
lookupBody, err := s.fetchPage(ctx, lookupURL, "")
if err != nil {
log.Printf("playerconfig: server lookup failed: %v", err)
return &PlayerConfig{Type: "proxy", Error: "server lookup failed"}
}
var lookupResp struct {
ServerKey string `json:"server_key"`
}
if err := json.Unmarshal([]byte(lookupBody), &lookupResp); err != nil || lookupResp.ServerKey == "" {
log.Printf("playerconfig: failed to parse server lookup: %v body=%s", err, lookupBody)
return &PlayerConfig{Type: "proxy", Error: "server lookup parse failed"}
}
config.ServerKey = lookupResp.ServerKey
// Step 7: Build m3u8 URL
m3u8URL := fmt.Sprintf("https://chevy.soyspace.cyou/proxy/%s/%s/mono.m3u8",
config.ServerKey, config.ChannelKey)
encoded := base64.RawURLEncoding.EncodeToString([]byte(m3u8URL))
config.HLSURL = "/hls/" + encoded
log.Printf("playerconfig: DaddyLive config ready channel=%s server=%s", config.ChannelKey, config.ServerKey)
return config
}
// VIPLeague/casthill resolution
var zmidRe = regexp.MustCompile(`(?:const|var|let)\s+zmid\s*=\s*["']([^"']+)["']`)
var casthillVRe = regexp.MustCompile(`[?&]v=([^&]+)`)
func (s *Service) getVIPLeagueConfig(ctx context.Context, rawURL string) *PlayerConfig {
// Check cache using normalized URL
s.mu.RLock()
if entry, ok := s.cache[rawURL]; ok && time.Now().Before(entry.expiresAt) {
s.mu.RUnlock()
return entry.config
}
s.mu.RUnlock()
config := s.fetchVIPLeagueConfig(ctx, rawURL)
s.mu.Lock()
s.cache[rawURL] = &cacheEntry{
config: config,
expiresAt: time.Now().Add(1 * time.Hour),
}
s.mu.Unlock()
return config
}
func (s *Service) fetchVIPLeagueConfig(ctx context.Context, rawURL string) *PlayerConfig {
lower := strings.ToLower(rawURL)
var zmid string
if strings.Contains(lower, "casthill.net") {
// Extract zmid from casthill URL query param ?v=...
if m := casthillVRe.FindStringSubmatch(rawURL); len(m) > 1 {
zmid = m[1]
}
}
if zmid == "" {
// Try to fetch VIPLeague page and extract zmid from JavaScript
body, err := s.fetchPage(ctx, rawURL, "")
if err != nil {
log.Printf("playerconfig: failed to fetch VIPLeague page: %v, trying URL-based extraction", err)
} else {
if m := zmidRe.FindStringSubmatch(body); len(m) > 1 {
zmid = m[1]
}
}
}
if zmid == "" {
// Fallback: extract slug from URL path and use it directly for channel matching
// e.g. /f-1/sky-sports-f1-streaming → "sky sports f1"
zmid = extractSlugFromURL(rawURL)
if zmid != "" {
log.Printf("playerconfig: extracted slug %q from URL path", zmid)
}
}
if zmid == "" {
log.Printf("playerconfig: no zmid found for VIPLeague URL %s", rawURL)
return &PlayerConfig{Type: "proxy", Error: "no zmid found in VIPLeague page"}
}
log.Printf("playerconfig: VIPLeague zmid=%q from %s", zmid, rawURL)
channelID, err := s.resolveChannelID(ctx, zmid)
if err != nil {
log.Printf("playerconfig: failed to resolve zmid %q: %v", zmid, err)
return &PlayerConfig{Type: "proxy", Error: fmt.Sprintf("failed to resolve zmid: %v", err)}
}
log.Printf("playerconfig: resolved zmid=%q to DaddyLive channel=%s", zmid, channelID)
return s.fetchDaddyLiveConfigByID(ctx, channelID)
}
// extractSlugFromURL extracts a channel-matching slug from a VIPLeague URL path.
// e.g. "https://vipleague.io/f-1/sky-sports-f1-streaming" → "sky sports f1"
// Strips common suffixes like "-streaming", "-live-stream", "-live", etc.
func extractSlugFromURL(rawURL string) string {
// Get the last path segment
path := rawURL
if idx := strings.Index(path, "?"); idx != -1 {
path = path[:idx]
}
path = strings.TrimRight(path, "/")
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 {
return ""
}
slug := path[lastSlash+1:]
// Strip common suffixes
for _, suffix := range []string{"-streaming", "-live-stream", "-stream", "-live", "-online", "-free"} {
slug = strings.TrimSuffix(slug, suffix)
}
// Replace hyphens with spaces for matching against channel names
slug = strings.ReplaceAll(slug, "-", " ")
slug = strings.TrimSpace(slug)
if slug == "" || len(slug) < 3 {
return ""
}
return slug
}
var channelLinkRe = regexp.MustCompile(`<a[^>]*href=["'][^"']*watch\.php\?id=(\d+)["'][^>]*data-title=["']([^"']+)["']`)
var channelLinkRe2 = regexp.MustCompile(`<a[^>]*data-title=["']([^"']+)["'][^>]*href=["'][^"']*watch\.php\?id=(\d+)["']`)
func (s *Service) resolveChannelID(ctx context.Context, zmid string) (string, error) {
channels, err := s.getChannelIndex(ctx)
if err != nil {
return "", err
}
zmidLower := strings.ToLower(zmid)
// Build tokens: if zmid contains spaces, split on spaces; otherwise use tokenizer
var tokens []string
if strings.Contains(zmidLower, " ") {
for _, word := range strings.Fields(zmidLower) {
if len(word) >= 2 {
tokens = append(tokens, word)
}
}
} else {
tokens = tokenize(zmidLower)
}
bestID := ""
bestScore := 0
bestNameLen := 0
for id, name := range channels {
score := 0
for _, tok := range tokens {
if strings.Contains(name, tok) {
score++
}
}
// Tiebreaker: prefer shorter names (more specific match) and
// English/UK channels which tend to have shorter names
if score > bestScore || (score == bestScore && score > 0 && len(name) < bestNameLen) {
bestScore = score
bestID = id
bestNameLen = len(name)
}
}
if bestID == "" || bestScore == 0 {
return "", fmt.Errorf("no channel matched zmid %q (tried %d channels)", zmid, len(channels))
}
log.Printf("playerconfig: zmid=%q matched channel %s (%s) with score %d/%d",
zmid, bestID, channels[bestID], bestScore, len(tokens))
return bestID, nil
}
func (s *Service) getChannelIndex(ctx context.Context) (map[string]string, error) {
const cacheKey = "__channel_index__"
s.mu.RLock()
if entry, ok := s.cache[cacheKey]; ok && time.Now().Before(entry.expiresAt) {
s.mu.RUnlock()
// Decode from the Error field (ab)used as storage
var idx map[string]string
if err := json.Unmarshal([]byte(entry.config.Error), &idx); err == nil {
return idx, nil
}
}
s.mu.RUnlock()
body, err := s.fetchPage(ctx, "https://dlhd.link/24-7-channels.php", "https://dlhd.link/")
if err != nil {
return nil, fmt.Errorf("failed to fetch channel index: %w", err)
}
channels := make(map[string]string)
// Try both attribute orderings
for _, m := range channelLinkRe.FindAllStringSubmatch(body, -1) {
channels[m[1]] = strings.ToLower(strings.TrimSpace(m[2]))
}
for _, m := range channelLinkRe2.FindAllStringSubmatch(body, -1) {
channels[m[2]] = strings.ToLower(strings.TrimSpace(m[1]))
}
if len(channels) == 0 {
return nil, fmt.Errorf("no channels found in 24/7 page (%d bytes)", len(body))
}
log.Printf("playerconfig: loaded %d channels from DaddyLive 24/7 page", len(channels))
// Cache as JSON in a fake PlayerConfig entry
encoded, _ := json.Marshal(channels)
s.mu.Lock()
s.cache[cacheKey] = &cacheEntry{
config: &PlayerConfig{Error: string(encoded)},
expiresAt: time.Now().Add(6 * time.Hour),
}
s.mu.Unlock()
return channels, nil
}
// tokenize splits a zmid slug into meaningful tokens.
// e.g. "skyf1" -> ["sky", "f1"], "daznf1" -> ["dazn", "f1"]
func tokenize(zmid string) []string {
// Common known prefixes/suffixes in sports streaming slugs
knownTokens := []string{
"sky", "sports", "f1", "dazn", "espn", "fox", "bein", "bt",
"star", "nbc", "cbs", "tnt", "abc", "tsn", "supersport",
"canal", "rtl", "viaplay", "premier", "main", "event",
"arena", "action", "cricket", "football", "tennis", "golf",
"racing", "news", "extra", "max", "hd", "uhd",
}
var tokens []string
remaining := zmid
for len(remaining) > 0 {
matched := false
for _, tok := range knownTokens {
if strings.HasPrefix(remaining, tok) {
tokens = append(tokens, tok)
remaining = remaining[len(tok):]
matched = true
break
}
}
if !matched {
// Try numeric suffix (like channel numbers)
i := 0
for i < len(remaining) && remaining[i] >= '0' && remaining[i] <= '9' {
i++
}
if i > 0 {
tokens = append(tokens, remaining[:i])
remaining = remaining[i:]
} else {
// Skip single character and try again
remaining = remaining[1:]
}
}
}
// If tokenization produced nothing useful, use the whole zmid as a single token
if len(tokens) == 0 {
tokens = []string{zmid}
}
return tokens
}
func (s *Service) fetchPage(ctx context.Context, pageURL, referer string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
if referer != "" {
req.Header.Set("Referer", referer)
}
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d from %s", resp.StatusCode, pageURL)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB max
if err != nil {
return "", err
}
return string(body), nil
}

View file

@ -1,473 +0,0 @@
package proxy
import (
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
)
// hopHeaders are headers that should not be forwarded by proxies.
var hopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Te": true,
"Trailers": true,
"Transfer-Encoding": true,
"Upgrade": true,
}
// antiFrameHeaders are headers we strip to allow iframe embedding.
var antiFrameHeaders = []string{
"X-Frame-Options",
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"X-Content-Type-Options",
}
// forwardHeaders are request headers we copy from the client to the upstream.
// NOTE: Accept-Encoding is intentionally omitted so Go's Transport handles
// compression transparently (adds gzip, auto-decompresses response body).
// This ensures we can do text replacements on HTML/CSS bodies.
var forwardHeaders = []string{
"User-Agent",
"Accept",
"Accept-Language",
"Cookie",
"Referer",
"Range",
"If-None-Match",
"If-Modified-Since",
"Cache-Control",
}
// jsShimTemplate is injected into HTML responses to intercept JS-initiated requests.
// It patches fetch, XMLHttpRequest, WebSocket, and EventSource to route through the proxy.
const jsShimTemplate = `<script data-proxy-shim="1">(function(){
var P='/proxy/%s';
var O='%s';
var H=location.origin;
function b64(s){return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/g,'');}
function rw(u){
if(!u||typeof u!=='string')return u;
if(u.startsWith('/proxy/'))return u;
if(u.startsWith(H+'/proxy/'))return u.slice(H.length);
if(u.startsWith(H+'/')){var hp=u.slice(H.length);return P+hp;}
if(u.startsWith(H))return P+'/';
if(u.startsWith('/'))return P+u;
if(u.startsWith(O))return P+u.slice(O.length);
try{var p=new URL(u);if(p.protocol==='http:'||p.protocol==='https:'){return'/proxy/'+b64(p.origin)+p.pathname+p.search+p.hash;}}catch(e){}
return u;
}
var _f=window.fetch;
window.fetch=function(i,o){
if(typeof i==='string')i=rw(i);
else if(i&&i.url)i=new Request(rw(i.url),i);
return _f.call(this,i,o);
};
var _xo=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(m,u){var a=[].slice.call(arguments);a[1]=rw(u);return _xo.apply(this,a);};
var _ws=window.WebSocket;
window.WebSocket=function(u,p){return new _ws(rw(u),p);};
window.WebSocket.prototype=_ws.prototype;
window.WebSocket.CONNECTING=_ws.CONNECTING;
window.WebSocket.OPEN=_ws.OPEN;
window.WebSocket.CLOSING=_ws.CLOSING;
window.WebSocket.CLOSED=_ws.CLOSED;
if(window.EventSource){var _es=window.EventSource;window.EventSource=function(u,o){return new _es(rw(u),o);};window.EventSource.prototype=_es.prototype;}
var _ce=document.createElement.bind(document);
document.createElement=function(t){
var el=_ce(t);
var tag=t.toLowerCase();
if(tag==='script'||tag==='img'||tag==='link'||tag==='source'||tag==='video'||tag==='audio'){
var _ss=Object.getOwnPropertyDescriptor(HTMLElement.prototype,'src')||Object.getOwnPropertyDescriptor(el.__proto__,'src');
if(_ss&&_ss.set){Object.defineProperty(el,'src',{get:function(){return _ss.get?_ss.get.call(this):'';},set:function(v){_ss.set.call(this,rw(v));},configurable:true});}
}
return el;
};
/* Neutralize anti-debug: override setInterval/setTimeout to skip debugger-based detection */
var _si=window.setInterval;
window.setInterval=function(fn,ms){
if(typeof fn==='function'){var s=fn.toString();if(s.indexOf('debugger')!==-1||s.indexOf('devtool')!==-1)return 0;}
if(typeof fn==='string'&&(fn.indexOf('debugger')!==-1||fn.indexOf('devtool')!==-1))return 0;
return _si.apply(this,arguments);
};
var _st=window.setTimeout;
window.setTimeout=function(fn,ms){
if(typeof fn==='function'){var s=fn.toString();if(s.indexOf('debugger')!==-1||s.indexOf('devtool')!==-1)return 0;}
if(typeof fn==='string'&&(fn.indexOf('debugger')!==-1||fn.indexOf('devtool')!==-1))return 0;
return _st.apply(this,arguments);
};
/* Override eval and Function to strip debugger statements */
var _eval=window.eval;
window.eval=function(s){if(typeof s==='string')s=s.replace(/\bdebugger\b\s*;?/g,'');return _eval.call(this,s);};
var _Fn=Function;
window.Function=function(){var a=[].slice.call(arguments);if(a.length>0){var last=a.length-1;if(typeof a[last]==='string')a[last]=a[last].replace(/\bdebugger\b\s*;?/g,'');}return _Fn.apply(this,a);};
window.Function.prototype=_Fn.prototype;
/* Block loading of known anti-debug scripts */
var _ael=HTMLScriptElement.prototype.setAttribute;
HTMLScriptElement.prototype.setAttribute=function(n,v){
if(n==='src'&&typeof v==='string'&&(v.indexOf('disable-devtool')!==-1||v.indexOf('devtools-detect')!==-1)){return;}
return _ael.apply(this,arguments);
};
})();</script>`
// NewHandler returns an http.Handler that serves the reverse proxy at /proxy/.
// URL structure: /proxy/{base64_origin}/{path...}
func NewHandler() http.Handler {
client := &http.Client{
Timeout: 30 * 1000000000, // 30s
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // don't follow redirects
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse: /proxy/{base64_origin}/{path...}
trimmed := strings.TrimPrefix(r.URL.Path, "/proxy/")
if trimmed == "" || trimmed == r.URL.Path {
http.Error(w, "bad proxy URL", http.StatusBadRequest)
return
}
// Split into base64 segment and remaining path
slashIdx := strings.Index(trimmed, "/")
var b64Origin, pathAndQuery string
if slashIdx == -1 {
b64Origin = trimmed
pathAndQuery = "/"
} else {
b64Origin = trimmed[:slashIdx]
pathAndQuery = trimmed[slashIdx:]
}
originBytes, err := base64.RawURLEncoding.DecodeString(b64Origin)
if err != nil {
// Try standard encoding with padding
originBytes, err = base64.StdEncoding.DecodeString(b64Origin)
if err != nil {
http.Error(w, "invalid base64 origin", http.StatusBadRequest)
return
}
}
origin := string(originBytes)
// Validate origin is a valid URL
originURL, err := url.Parse(origin)
if err != nil || (originURL.Scheme != "http" && originURL.Scheme != "https") {
http.Error(w, "invalid origin URL", http.StatusBadRequest)
return
}
// Build upstream URL
targetURL := origin + pathAndQuery
if r.URL.RawQuery != "" {
targetURL += "?" + r.URL.RawQuery
}
log.Printf("proxy: %s %s -> %s", r.Method, r.URL.Path, targetURL)
// Create upstream request
upReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
// Copy selected headers
for _, h := range forwardHeaders {
if v := r.Header.Get(h); v != "" {
upReq.Header.Set(h, v)
}
}
// Reconstruct the original Referer from the client's proxy-rewritten Referer.
// The client sends e.g. "https://f1.viktorbarzin.me/proxy/{b64origin}/path"
// and we need to decode that back to "https://original.com/path".
upReq.Header.Set("Referer", decodeProxyReferer(r.Header.Get("Referer"), origin))
// Fetch upstream
resp, err := client.Do(upReq)
if err != nil {
log.Printf("proxy: upstream fetch failed: %v", err)
http.Error(w, "upstream fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
log.Printf("proxy: %s %s <- %d (%s)", r.Method, r.URL.Path, resp.StatusCode, resp.Header.Get("Content-Type"))
// Handle redirects: rewrite Location header through proxy
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc != "" {
rewritten := rewriteRedirect(loc, origin, b64Origin)
w.Header().Set("Location", rewritten)
log.Printf("proxy: redirect %s -> %s", loc, rewritten)
}
w.WriteHeader(resp.StatusCode)
return
}
// Copy response headers, stripping anti-frame, hop-by-hop, and encoding headers.
// Content-Encoding is stripped because Go's Transport already decompressed the body.
// Content-Length is stripped because we may rewrite the body (changing its length).
for key, vals := range resp.Header {
if hopHeaders[key] {
continue
}
if strings.EqualFold(key, "Content-Encoding") || strings.EqualFold(key, "Content-Length") {
continue
}
skip := false
for _, ah := range antiFrameHeaders {
if strings.EqualFold(key, ah) {
skip = true
break
}
}
if skip {
continue
}
for _, v := range vals {
w.Header().Add(key, v)
}
}
// Add permissive CORS headers so cross-origin XHR/fetch from the iframe works
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.WriteHeader(resp.StatusCode)
// For HTML responses, rewrite URLs and inject JS shim
ct := resp.Header.Get("Content-Type")
if strings.Contains(ct, "text/html") {
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("proxy: failed to read HTML body: %v", err)
return
}
rewritten := rewriteHTML(string(body), origin, b64Origin)
w.Write([]byte(rewritten))
return
}
// For CSS responses, rewrite url() references
if strings.Contains(ct, "text/css") {
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("proxy: failed to read CSS body: %v", err)
return
}
rewritten := rewriteCSS(string(body), origin, b64Origin)
w.Write([]byte(rewritten))
return
}
// For JavaScript responses, strip debugger statements
if strings.Contains(ct, "javascript") || strings.Contains(ct, "ecmascript") {
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("proxy: failed to read JS body: %v", err)
return
}
cleaned := debuggerStmtRe.ReplaceAllString(string(body), "/* */")
w.Write([]byte(cleaned))
return
}
// Stream other responses directly
io.Copy(w, resp.Body)
})
}
// rewriteRedirect rewrites a Location header value to route through the proxy.
func rewriteRedirect(loc, origin, b64Origin string) string {
// Absolute URL on the same origin
if strings.HasPrefix(loc, origin) {
path := strings.TrimPrefix(loc, origin)
return "/proxy/" + b64Origin + path
}
// Absolute URL on a different origin — proxy it too
parsed, err := url.Parse(loc)
if err != nil {
return loc
}
if parsed.IsAbs() {
newOrigin := parsed.Scheme + "://" + parsed.Host
newB64 := base64.RawURLEncoding.EncodeToString([]byte(newOrigin))
return "/proxy/" + newB64 + parsed.RequestURI()
}
// Relative URL — it will resolve naturally
return loc
}
// Precompiled regexes for root-relative URL rewriting in HTML attributes.
// Matches src="/...", href="/...", action="/...", poster="/..." but NOT "//..." (protocol-relative).
var rootRelativeAttrRe = regexp.MustCompile(`((?:src|href|action|poster|data)\s*=\s*["'])/([^/"'][^"']*)`)
// Matches url("/...") or url('/...') or url(/...) in inline styles — but NOT url("//...")
var rootRelativeCSSRe = regexp.MustCompile(`(url\(\s*["']?)/([^/"')[^"')]*)(["']?\s*\))`)
// disableDevtoolRe matches <script> tags that load disable-devtool or similar anti-debug libraries.
var disableDevtoolRe = regexp.MustCompile(`(?i)<script[^>]*(?:disable-devtool|devtools-detect)[^>]*>(?:</script>)?`)
// adScriptRe matches <script> tags that load common ad/popup libraries.
var adScriptRe = regexp.MustCompile(`(?i)<script[^>]*(?:acscdn\.com|popunder|popads|juicyads)[^>]*>\s*(?:</script>)?`)
// adInlineRe matches inline <script> blocks that call ad popup functions.
var adInlineRe = regexp.MustCompile(`(?i)<script[^>]*>\s*(?:aclib\.run|popunder|pop_)\w*\([^)]*\);\s*</script>`)
// contextMenuBlockRe matches inline scripts that block right-click and dev tools shortcuts.
var contextMenuBlockRe = regexp.MustCompile(`(?i)<script[^>]*>\s*document\.addEventListener\(\s*'contextmenu'[\s\S]{0,500}?</script>`)
// debuggerStmtRe matches debugger statements in JavaScript.
var debuggerStmtRe = regexp.MustCompile(`\bdebugger\b\s*;?`)
// rewriteHTML replaces URLs and injects the JS shim to intercept runtime requests.
func rewriteHTML(body, origin, b64Origin string) string {
proxyPrefix := "/proxy/" + b64Origin
// 1. Rewrite absolute URLs matching the target origin
escaped := regexp.QuoteMeta(origin)
absRe := regexp.MustCompile(escaped + `(/[^"'\s>)]*)?`)
body = absRe.ReplaceAllStringFunc(body, func(match string) string {
path := strings.TrimPrefix(match, origin)
if path == "" {
path = "/"
}
return proxyPrefix + path
})
// 2. Rewrite root-relative URLs in HTML attributes (src="/...", href="/...", etc.)
// Skip URLs already rewritten by step 1 (starting with /proxy/)
body = rootRelativeAttrRe.ReplaceAllStringFunc(body, func(match string) string {
m := rootRelativeAttrRe.FindStringSubmatch(match)
if len(m) < 3 {
return match
}
// m[2] is the path after the leading "/", skip if already proxied
if strings.HasPrefix(m[2], "proxy/") {
return match
}
return m[1] + proxyPrefix + "/" + m[2]
})
// 3. Rewrite root-relative URLs in inline CSS url() references
// Skip URLs already rewritten by step 1 (starting with /proxy/)
body = rootRelativeCSSRe.ReplaceAllStringFunc(body, func(match string) string {
m := rootRelativeCSSRe.FindStringSubmatch(match)
if len(m) < 4 {
return match
}
if strings.HasPrefix(m[2], "proxy/") {
return match
}
return m[1] + proxyPrefix + "/" + m[2] + m[3]
})
// 4. Strip anti-debugging scripts (disable-devtool, devtools-detect)
body = disableDevtoolRe.ReplaceAllString(body, "")
// 4b. Strip ad/popup scripts and context menu blockers
body = adScriptRe.ReplaceAllString(body, "")
body = adInlineRe.ReplaceAllString(body, "")
body = contextMenuBlockRe.ReplaceAllString(body, "")
// 4c. Strip debugger statements from inline scripts
body = debuggerStmtRe.ReplaceAllString(body, "/* */")
// 5. Inject JS shim right after <head> to intercept fetch/XHR/WebSocket
shim := fmt.Sprintf(jsShimTemplate, b64Origin, origin)
headIdx := strings.Index(strings.ToLower(body), "<head>")
if headIdx != -1 {
insertPos := headIdx + len("<head>")
body = body[:insertPos] + shim + body[insertPos:]
} else {
// No <head> tag — prepend to body
body = shim + body
}
return body
}
// rewriteCSS replaces root-relative url() references in CSS to route through the proxy.
func rewriteCSS(body, origin, b64Origin string) string {
proxyPrefix := "/proxy/" + b64Origin
// Rewrite absolute URLs matching origin
escaped := regexp.QuoteMeta(origin)
absRe := regexp.MustCompile(escaped + `(/[^"'\s)]*)?`)
body = absRe.ReplaceAllStringFunc(body, func(match string) string {
path := strings.TrimPrefix(match, origin)
if path == "" {
path = "/"
}
return proxyPrefix + path
})
// Rewrite root-relative url() references, skip already-proxied
body = rootRelativeCSSRe.ReplaceAllStringFunc(body, func(match string) string {
m := rootRelativeCSSRe.FindStringSubmatch(match)
if len(m) < 4 {
return match
}
if strings.HasPrefix(m[2], "proxy/") {
return match
}
return m[1] + proxyPrefix + "/" + m[2] + m[3]
})
return body
}
// decodeProxyReferer takes the client's Referer (which points to a proxy URL)
// and decodes it back to the original upstream URL. This is critical for
// cross-origin requests where the upstream checks the Referer (e.g. HLS servers).
// Falls back to origin+"/" if decoding fails.
func decodeProxyReferer(clientReferer, fallbackOrigin string) string {
if clientReferer == "" {
return fallbackOrigin + "/"
}
// Find /proxy/ in the Referer URL path
idx := strings.Index(clientReferer, "/proxy/")
if idx == -1 {
return fallbackOrigin + "/"
}
// Extract everything after /proxy/
rest := clientReferer[idx+len("/proxy/"):]
if rest == "" {
return fallbackOrigin + "/"
}
// Split into base64 segment and remaining path
slashIdx := strings.Index(rest, "/")
var b64Seg, pathPart string
if slashIdx == -1 {
b64Seg = rest
pathPart = "/"
} else {
b64Seg = rest[:slashIdx]
pathPart = rest[slashIdx:]
}
// Decode the base64 origin
originBytes, err := base64.RawURLEncoding.DecodeString(b64Seg)
if err != nil {
originBytes, err = base64.StdEncoding.DecodeString(b64Seg)
if err != nil {
return fallbackOrigin + "/"
}
}
return string(originBytes) + pathPart
}

View file

@ -1,327 +0,0 @@
package scraper
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"f1-stream/internal/models"
)
const (
subredditURL = "https://www.reddit.com/r/motorsportsstreams2/new.json?limit=25"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
requestDelay = 1 * time.Second
)
var (
urlRe = regexp.MustCompile(`https?://[^\s\)\]\>"]+`)
// Keywords in post title that indicate F1 content (matched case-insensitively)
f1Keywords = []string{
"f1",
"formula 1",
"formula one",
"formula1",
"grand prix",
"gp qualifying",
"gp race",
"gp sprint",
"gp practice",
}
f1NegativeKeywords = []string{
"f1 key",
"function 1",
"help f1",
}
// URLs to filter out (not stream sources)
filteredDomains = map[string]bool{
"reddit.com": true,
"www.reddit.com": true,
"imgur.com": true,
"i.imgur.com": true,
"redd.it": true,
"i.redd.it": true,
"v.redd.it": true,
"youtu.be": true,
"youtube.com": true,
"twitter.com": true,
"x.com": true,
}
)
type redditListing struct {
Data struct {
Children []struct {
Data struct {
Title string `json:"title"`
SelfText string `json:"selftext"`
Permalink string `json:"permalink"`
CreatedUTC float64 `json:"created_utc"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
type redditComments []struct {
Data struct {
Children []struct {
Data struct {
Body string `json:"body"`
Replies json.RawMessage `json:"replies"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
func scrapeReddit() ([]models.ScrapedLink, error) {
client := &http.Client{Timeout: 15 * time.Second}
var allLinks []models.ScrapedLink
seen := make(map[string]bool)
log.Printf("scraper: fetching listing from %s", subredditURL)
listing, err := fetchJSON[redditListing](client, subredditURL)
if err != nil {
return nil, fmt.Errorf("fetch listing: %w", err)
}
totalPosts := len(listing.Data.Children)
matchedPosts := 0
log.Printf("scraper: got %d posts from listing", totalPosts)
for _, child := range listing.Data.Children {
post := child.Data
if !isF1Post(post.Title) {
log.Printf("scraper: skipped post: %s", truncate(post.Title, 60))
continue
}
matchedPosts++
log.Printf("scraper: matched post: %s", truncate(post.Title, 60))
selftextLinks := extractURLs(post.SelfText, post.Title)
log.Printf("scraper: extracted %d URLs from selftext of %q", len(selftextLinks), truncate(post.Title, 40))
for _, link := range selftextLinks {
norm := normalizeURL(link.URL)
if !seen[norm] {
seen[norm] = true
allLinks = append(allLinks, link)
}
}
time.Sleep(requestDelay)
commentsURL := fmt.Sprintf("https://www.reddit.com%s.json", post.Permalink)
comments, err := fetchJSONWithRetry[redditComments](client, commentsURL, 3)
if err != nil {
log.Printf("scraper: failed to fetch comments for %s: %v", post.Permalink, err)
continue
}
commentURLCount := 0
walkComments(*comments, func(body string) {
links := extractURLs(body, post.Title)
commentURLCount += len(links)
for _, link := range links {
norm := normalizeURL(link.URL)
if !seen[norm] {
seen[norm] = true
allLinks = append(allLinks, link)
}
}
})
log.Printf("scraper: extracted %d URLs from comments of %q", commentURLCount, truncate(post.Title, 40))
time.Sleep(requestDelay)
}
log.Printf("scraper: summary — matched %d/%d posts, extracted %d unique URLs", matchedPosts, totalPosts, len(allLinks))
return allLinks, nil
}
func fetchJSON[T any](client *http.Client, rawURL string) (*T, error) {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
log.Printf("scraper: GET %s -> %d", truncate(rawURL, 80), resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, err
}
var result T
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return &result, nil
}
func fetchJSONWithRetry[T any](client *http.Client, rawURL string, maxRetries int) (*T, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
result, err := fetchJSON[T](client, rawURL)
if err == nil {
return result, nil
}
lastErr = err
errMsg := err.Error()
if strings.Contains(errMsg, "status 429") {
log.Printf("scraper: rate limited on %s, backing off 30s", truncate(rawURL, 60))
time.Sleep(30 * time.Second)
continue
}
if strings.Contains(errMsg, "status 502") || strings.Contains(errMsg, "status 503") {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
log.Printf("scraper: server error on %s, retry %d/%d in %v", truncate(rawURL, 60), attempt+1, maxRetries, backoff)
time.Sleep(backoff)
continue
}
return nil, err
}
return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}
// deobfuscateText normalises obfuscated URLs commonly posted on Reddit to
// evade auto-moderation. Examples:
// - "pitsport . xyz/watch/f1" → "https://pitsport.xyz/watch/f1"
// - "dlhd dot link" → "https://dlhd.link"
func deobfuscateText(text string) string {
// Common TLDs used in streaming links.
tlds := `(?:com|net|org|xyz|link|info|live|tv|me|cc|to|io|co|stream|site|fun|top|club|watch|racing)`
// 1. Replace " dot " (case-insensitive) between word-like parts that
// look like domain components: "dlhd dot link" → "dlhd.link"
dotWord := regexp.MustCompile(`(?i)(\b\w[\w-]*)\s+dot\s+(` + tlds + `\b)`)
text = dotWord.ReplaceAllString(text, "${1}.${2}")
// 2. Collapse spaces around dots in domain-like strings:
// "pitsport . xyz" → "pitsport.xyz"
spaceDot := regexp.MustCompile(`(\b\w[\w-]*)\s*\.\s*(` + tlds + `\b)`)
text = spaceDot.ReplaceAllString(text, "${1}.${2}")
// 3. Prepend https:// to bare domain-like strings that the URL regex
// would otherwise miss (no scheme present).
bareDomain := regexp.MustCompile(`(?:^|[\s(>\[])(\w[\w-]*\.` + tlds + `(?:/[^\s)\]<"]*)?)`)
text = bareDomain.ReplaceAllStringFunc(text, func(m string) string {
// Preserve the leading whitespace/punctuation character.
trimmed := strings.TrimLeft(m, " \t\n(>[")
prefix := m[:len(m)-len(trimmed)]
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return m
}
return prefix + "https://" + trimmed
})
return text
}
func extractURLs(text, postTitle string) []models.ScrapedLink {
text = deobfuscateText(text)
matches := urlRe.FindAllString(text, -1)
var links []models.ScrapedLink
filtered := 0
for _, u := range matches {
u = strings.TrimRight(u, ".,;:!?)")
parsed, err := url.Parse(u)
if err != nil {
continue
}
if filteredDomains[parsed.Hostname()] {
filtered++
continue
}
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
continue
}
links = append(links, models.ScrapedLink{
ID: fmt.Sprintf("%x", id),
URL: u,
Title: postTitle,
Source: "r/motorsportsstreams2",
ScrapedAt: time.Now(),
})
}
if filtered > 0 {
log.Printf("scraper: filtered %d URLs from known domains in %q", filtered, truncate(postTitle, 40))
}
return links
}
func walkComments(comments redditComments, fn func(string)) {
for _, listing := range comments {
for _, child := range listing.Data.Children {
if child.Data.Body != "" {
fn(child.Data.Body)
}
// Recurse into replies
if len(child.Data.Replies) > 0 && child.Data.Replies[0] == '{' {
var nested redditComments
if err := json.Unmarshal([]byte("["+string(child.Data.Replies)+"]"), &nested); err == nil {
walkComments(nested, fn)
}
}
}
}
}
func normalizeURL(u string) string {
parsed, err := url.Parse(u)
if err != nil {
return strings.ToLower(u)
}
parsed.Host = strings.ToLower(parsed.Host)
path := strings.TrimRight(parsed.Path, "/")
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, path)
}
func isF1Post(title string) bool {
lower := strings.ToLower(title)
for _, neg := range f1NegativeKeywords {
if strings.Contains(lower, neg) {
return false
}
}
for _, kw := range f1Keywords {
if strings.Contains(lower, kw) {
return true
}
}
return false
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -1,105 +0,0 @@
package scraper
import (
"context"
"log"
"sync"
"time"
"f1-stream/internal/models"
"f1-stream/internal/store"
)
type Scraper struct {
store *store.Store
interval time.Duration
validateTimeout time.Duration
mu sync.Mutex
}
func New(s *store.Store, interval time.Duration, validateTimeout time.Duration) *Scraper {
return &Scraper{store: s, interval: interval, validateTimeout: validateTimeout}
}
func (s *Scraper) Run(ctx context.Context) {
log.Printf("scraper: starting with interval %v", s.interval)
// Run immediately on start
s.scrape()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("scraper: shutting down")
return
case <-ticker.C:
s.scrape()
}
}
}
func (s *Scraper) TriggerScrape() {
go s.scrape()
}
func (s *Scraper) scrape() {
s.mu.Lock()
defer s.mu.Unlock()
start := time.Now()
log.Println("scraper: starting scrape")
links, err := scrapeReddit()
if err != nil {
log.Printf("scraper: error after %v: %v", time.Since(start).Round(time.Millisecond), err)
return
}
log.Printf("scraper: reddit scrape completed in %v, got %d links", time.Since(start).Round(time.Millisecond), len(links))
// Merge with existing links, filtering out non-F1 entries
existing, err := s.store.LoadScrapedLinks()
if err != nil {
log.Printf("scraper: failed to load existing links: %v", err)
existing = nil
}
seen := make(map[string]bool)
var filtered []models.ScrapedLink
for _, l := range existing {
if !isF1Post(l.Title) {
continue
}
norm := normalizeURL(l.URL)
seen[norm] = true
filtered = append(filtered, l)
}
existing = filtered
added := 0
for _, l := range links {
norm := normalizeURL(l.URL)
if !seen[norm] {
existing = append(existing, l)
seen[norm] = true
added++
}
}
if err := s.store.SaveScrapedLinks(existing); err != nil {
log.Printf("scraper: failed to save: %v", err)
return
}
// Auto-publish newly validated links as streams
for _, l := range links {
if err := s.store.PublishScrapedStream(l.URL, l.Title); err != nil {
u := l.URL
if len(u) > 80 {
u = u[:80] + "..."
}
log.Printf("scraper: failed to auto-publish %s: %v", u, err)
}
}
log.Printf("scraper: done in %v, added %d new links (total: %d)", time.Since(start).Round(time.Millisecond), added, len(existing))
}

View file

@ -1,142 +0,0 @@
package scraper
import (
"io"
"log"
"net/http"
"strings"
"time"
"f1-stream/internal/models"
)
// videoMarkers are substrings checked (case-insensitively) against the HTML
// body to detect the presence of a video player or streaming manifest.
var videoMarkers = []string{
// HTML5 video element
"<video",
// HLS manifests
".m3u8",
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
// DASH manifests
".mpd",
"application/dash+xml",
// Player libraries
"hls.js",
"hls.min.js",
"dash.js",
"dash.all.min.js",
"video.js",
"video.min.js",
"videojs",
"jwplayer",
"clappr",
"flowplayer",
"plyr",
"shaka-player",
"mediaelement",
"fluidplayer",
}
// videoContentTypes are Content-Type prefixes/substrings that indicate a
// direct video response (no HTML inspection needed).
var videoContentTypes = []string{
"video/",
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
"application/dash+xml",
}
// validateBodyLimit caps how much HTML we read when looking for markers.
const validateBodyLimit = 2 * 1024 * 1024 // 2 MB
// validateLinks fetches each link and keeps only those whose response
// contains video/player content markers.
func validateLinks(links []models.ScrapedLink, timeout time.Duration) []models.ScrapedLink {
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
}
var kept []models.ScrapedLink
for _, link := range links {
if HasVideoContent(client, link.URL) {
kept = append(kept, link)
} else {
log.Printf("scraper: discarded %s (no video markers)", truncate(link.URL, 60))
}
}
return kept
}
// HasVideoContent performs a GET request for rawURL and returns true if the
// response is a direct video file (by Content-Type) or an HTML page that
// contains at least one video marker substring.
func HasVideoContent(client *http.Client, rawURL string) bool {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
log.Printf("scraper: validate request error for %s: %v", truncate(rawURL, 60), err)
return false
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
log.Printf("scraper: validate fetch error for %s: %v", truncate(rawURL, 60), err)
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return false
}
ct := strings.ToLower(resp.Header.Get("Content-Type"))
// Direct video content type — no need to inspect body.
if isDirectVideoContentType(ct) {
return true
}
// Only inspect HTML pages for markers.
if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "application/xhtml") {
return false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, validateBodyLimit))
if err != nil {
log.Printf("scraper: validate read error for %s: %v", truncate(rawURL, 60), err)
return false
}
return containsVideoMarkers(strings.ToLower(string(body)))
}
// containsVideoMarkers returns true if loweredBody contains any known video
// player or streaming marker substring.
func containsVideoMarkers(loweredBody string) bool {
for _, marker := range videoMarkers {
if strings.Contains(loweredBody, marker) {
return true
}
}
return false
}
// isDirectVideoContentType returns true if ct (already lowercased) matches a
// known video content type.
func isDirectVideoContentType(ct string) bool {
ct = strings.ToLower(ct)
for _, vct := range videoContentTypes {
if strings.Contains(ct, vct) {
return true
}
}
return false
}

View file

@ -1,124 +0,0 @@
package scraper
import "testing"
func TestContainsVideoMarkers(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
// Positive cases
{
name: "video tag",
body: `<div><video src="stream.mp4"></video></div>`,
want: true,
},
{
name: "HLS manifest reference",
body: `var url = "https://cdn.example.com/live.m3u8";`,
want: true,
},
{
name: "DASH manifest reference",
body: `<source src="stream.mpd" type="application/dash+xml">`,
want: true,
},
{
name: "HLS.js library",
body: `<script src="/js/hls.min.js"></script>`,
want: true,
},
{
name: "Video.js library",
body: `<script src="https://cdn.example.com/video.js"></script>`,
want: true,
},
{
name: "JW Player",
body: `<div id="jwplayer-container"></div><script>jwplayer("jwplayer-container")</script>`,
want: true,
},
{
name: "Clappr player",
body: `<script src="clappr.min.js"></script>`,
want: true,
},
{
name: "Flowplayer",
body: `<script>flowplayer("#player")</script>`,
want: true,
},
{
name: "Plyr player",
body: `<link rel="stylesheet" href="plyr.css"><script src="plyr.js"></script>`,
want: true,
},
{
name: "Shaka Player",
body: `<script src="shaka-player.compiled.js"></script>`,
want: true,
},
// Negative cases
{
name: "plain HTML",
body: `<html><body><p>Hello world</p></body></html>`,
want: false,
},
{
name: "reddit link page",
body: `<html><body><a href="https://example.com">Click here</a></body></html>`,
want: false,
},
{
name: "blog post",
body: `<html><body><article>F1 race results and analysis...</article></body></html>`,
want: false,
},
{
name: "empty string",
body: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := containsVideoMarkers(tt.body)
if got != tt.want {
t.Errorf("containsVideoMarkers(%q) = %v, want %v", truncate(tt.body, 60), got, tt.want)
}
})
}
}
func TestIsDirectVideoContentType(t *testing.T) {
tests := []struct {
name string
ct string
want bool
}{
// Positive cases
{name: "video/mp4", ct: "video/mp4", want: true},
{name: "video/webm", ct: "video/webm", want: true},
{name: "HLS content type", ct: "application/x-mpegurl", want: true},
{name: "Apple HLS content type", ct: "application/vnd.apple.mpegurl", want: true},
{name: "DASH content type", ct: "application/dash+xml", want: true},
{name: "video with params", ct: "video/mp4; charset=utf-8", want: true},
// Negative cases
{name: "text/html", ct: "text/html", want: false},
{name: "application/json", ct: "application/json", want: false},
{name: "image/png", ct: "image/png", want: false},
{name: "text/plain", ct: "text/plain", want: false},
{name: "empty string", ct: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isDirectVideoContentType(tt.ct)
if got != tt.want {
t.Errorf("isDirectVideoContentType(%q) = %v, want %v", tt.ct, got, tt.want)
}
})
}
}

View file

@ -1,93 +0,0 @@
package server
import (
"log"
"net/http"
"strings"
"f1-stream/internal/auth"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// AuthMiddleware injects user into context if session cookie is present.
func AuthMiddleware(a *auth.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil && cookie.Value != "" {
user, err := a.GetSessionUser(cookie.Value)
if err == nil && user != nil {
r = r.WithContext(auth.ContextWithUser(r.Context(), user))
}
}
next.ServeHTTP(w, r)
})
}
}
// RequireAuth rejects unauthenticated requests.
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
next(w, r)
}
}
// RequireAdmin rejects non-admin requests.
func RequireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil || !user.IsAdmin {
http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden)
return
}
next(w, r)
}
}
// OriginCheck validates Origin header on mutation requests (CSRF protection).
func OriginCheck(allowedOrigins []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
origin := r.Header.Get("Origin")
if origin != "" {
allowed := false
for _, o := range allowedOrigins {
if strings.EqualFold(origin, o) {
allowed = true
break
}
}
if !allowed {
http.Error(w, `{"error":"origin not allowed"}`, http.StatusForbidden)
return
}
}
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -1,338 +0,0 @@
package server
import (
"encoding/json"
"html"
"log"
"net/http"
"strings"
"f1-stream/internal/auth"
"f1-stream/internal/extractor"
"f1-stream/internal/hlsproxy"
"f1-stream/internal/playerconfig"
"f1-stream/internal/proxy"
"f1-stream/internal/scraper"
"f1-stream/internal/store"
)
type Server struct {
store *store.Store
auth *auth.Auth
scraper *scraper.Scraper
playerConfig *playerconfig.Service
mux *http.ServeMux
headlessEnabled bool
}
func New(s *store.Store, a *auth.Auth, sc *scraper.Scraper, pc *playerconfig.Service, origins []string, headlessEnabled bool) *Server {
srv := &Server{
store: s,
auth: a,
scraper: sc,
playerConfig: pc,
mux: http.NewServeMux(),
headlessEnabled: headlessEnabled,
}
srv.registerRoutes(origins)
return srv
}
func (s *Server) Handler() http.Handler {
return s.mux
}
func (s *Server) registerRoutes(origins []string) {
// Apply middleware chain
authMw := AuthMiddleware(s.auth)
originMw := OriginCheck(origins)
// Static files
fs := http.FileServer(http.Dir("static"))
s.mux.Handle("GET /static/", http.StripPrefix("/static/", fs))
s.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, "static/index.html")
})
// Health
s.mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Reverse proxy for iframe embedding (strips anti-framing headers)
proxyHandler := proxy.NewHandler()
s.mux.Handle("GET /proxy/", proxyHandler)
s.mux.Handle("POST /proxy/", proxyHandler)
s.mux.Handle("HEAD /proxy/", proxyHandler)
s.mux.Handle("OPTIONS /proxy/", proxyHandler)
// HLS proxy for native video playback
hlsHandler := hlsproxy.NewHandler()
s.mux.Handle("GET /hls/", hlsHandler)
s.mux.Handle("OPTIONS /hls/", hlsHandler)
// Public API - wrap with middleware
wrapAll := func(h http.HandlerFunc) http.Handler {
return RecoveryMiddleware(LoggingMiddleware(originMw(authMw(h))))
}
// Auth endpoints
s.mux.Handle("POST /api/auth/register/begin", wrapAll(s.auth.BeginRegistration))
s.mux.Handle("POST /api/auth/register/finish", wrapAll(s.auth.FinishRegistration))
s.mux.Handle("POST /api/auth/login/begin", wrapAll(s.auth.BeginLogin))
s.mux.Handle("POST /api/auth/login/finish", wrapAll(s.auth.FinishLogin))
s.mux.Handle("POST /api/auth/logout", wrapAll(s.auth.Logout))
s.mux.Handle("GET /api/auth/me", wrapAll(s.auth.Me))
// Public streams
s.mux.Handle("GET /api/streams/public", wrapAll(s.handlePublicStreams))
s.mux.Handle("GET /api/streams/{id}/browse", wrapAll(s.handleBrowseStream))
s.mux.Handle("GET /api/streams/{id}/player-config", wrapAll(s.handlePlayerConfig))
// Scraped links
s.mux.Handle("GET /api/scraped", wrapAll(s.handleScrapedLinks))
s.mux.Handle("POST /api/scraped/refresh", wrapAll(s.handleTriggerScrape))
s.mux.Handle("POST /api/scraped/{id}/import", wrapAll(s.handleImportScraped))
// Authenticated endpoints
s.mux.Handle("GET /api/streams/mine", wrapAll(RequireAuth(s.handleMyStreams)))
s.mux.Handle("POST /api/streams", wrapAll(s.handleSubmitStream))
s.mux.Handle("DELETE /api/streams/{id}", wrapAll(s.handleDeleteStream))
// Admin endpoints
s.mux.Handle("PUT /api/streams/{id}/publish", wrapAll(RequireAdmin(s.handleTogglePublish)))
s.mux.Handle("GET /api/admin/streams", wrapAll(RequireAdmin(s.handleAllStreams)))
s.mux.Handle("POST /api/admin/scrape", wrapAll(RequireAdmin(s.handleTriggerScrape)))
}
func (s *Server) handlePublicStreams(w http.ResponseWriter, r *http.Request) {
streams, err := s.store.PublicStreams()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(streams)
}
func (s *Server) handleScrapedLinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetActiveScrapedLinks()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if links == nil {
w.Write([]byte("[]"))
return
}
json.NewEncoder(w).Encode(links)
}
func (s *Server) handleImportScraped(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
link, err := s.store.GetScrapedLinkByID(id)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
if err := s.store.PublishScrapedStream(link.URL, link.Title); err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleMyStreams(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
streams, err := s.store.UserStreams(user.ID)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if streams == nil {
w.Write([]byte("[]"))
return
}
json.NewEncoder(w).Encode(streams)
}
func (s *Server) handleSubmitStream(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
var req struct {
URL string `json:"url"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
req.URL = strings.TrimSpace(req.URL)
req.Title = strings.TrimSpace(req.Title)
if req.URL == "" {
http.Error(w, `{"error":"url required"}`, http.StatusBadRequest)
return
}
if len(req.URL) > 2048 {
http.Error(w, `{"error":"url too long"}`, http.StatusBadRequest)
return
}
if !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "http://") {
http.Error(w, `{"error":"url must start with http:// or https://"}`, http.StatusBadRequest)
return
}
if req.Title == "" {
req.Title = req.URL
}
req.Title = html.EscapeString(req.Title)
submittedBy := "anonymous"
published := true
if user != nil {
submittedBy = user.ID
published = false
}
stream, err := s.store.AddStream(req.URL, req.Title, submittedBy, published, "user")
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(stream)
}
func (s *Server) handleDeleteStream(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
var userID string
var isAdmin bool
if user != nil {
userID = user.ID
isAdmin = user.IsAdmin
}
if err := s.store.DeleteStream(id, userID, isAdmin); err != nil {
if strings.Contains(err.Error(), "not authorized") {
http.Error(w, `{"error":"not authorized"}`, http.StatusForbidden)
return
}
if strings.Contains(err.Error(), "not found") {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleTogglePublish(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.TogglePublish(id); err != nil {
http.Error(w, `{"error":"stream not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleAllStreams(w http.ResponseWriter, r *http.Request) {
streams, err := s.store.LoadStreams()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(streams)
}
func (s *Server) handleTriggerScrape(w http.ResponseWriter, r *http.Request) {
s.scraper.TriggerScrape()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"message":"scrape triggered"}`))
}
func (s *Server) handleBrowseStream(w http.ResponseWriter, r *http.Request) {
if !s.headlessEnabled {
http.Error(w, `{"error":"browser sessions not available"}`, http.StatusNotFound)
return
}
id := r.PathValue("id")
streams, err := s.store.LoadStreams()
if err != nil {
log.Printf("server: browse: failed to load streams: %v", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
var streamURL string
var found bool
for _, st := range streams {
if st.ID == id {
if !st.Published {
http.Error(w, `{"error":"stream not found"}`, http.StatusNotFound)
return
}
streamURL = st.URL
found = true
break
}
}
if !found {
http.Error(w, `{"error":"stream not found"}`, http.StatusNotFound)
return
}
extractor.HandleBrowserSession(w, r, streamURL)
}
func (s *Server) handlePlayerConfig(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
streams, err := s.store.LoadStreams()
if err != nil {
log.Printf("server: player-config: failed to load streams: %v", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(playerconfig.PlayerConfig{Type: "proxy"})
return
}
var streamURL string
var found bool
for _, st := range streams {
if st.ID == id {
if !st.Published {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(playerconfig.PlayerConfig{Type: "proxy"})
return
}
streamURL = st.URL
found = true
break
}
}
if !found {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(playerconfig.PlayerConfig{Type: "proxy"})
return
}
config := s.playerConfig.GetConfig(r.Context(), streamURL)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}

View file

@ -1,37 +0,0 @@
package store
import (
"f1-stream/internal/models"
)
func (s *Store) LoadHealthStates() ([]models.HealthState, error) {
s.healthMu.RLock()
defer s.healthMu.RUnlock()
var states []models.HealthState
if err := readJSON(s.filePath("health_state.json"), &states); err != nil {
return nil, err
}
return states, nil
}
func (s *Store) SaveHealthStates(states []models.HealthState) error {
s.healthMu.Lock()
defer s.healthMu.Unlock()
return writeJSON(s.filePath("health_state.json"), states)
}
// HealthMap returns a map of URL -> Healthy status. It reads the health state
// file directly without acquiring healthMu to avoid deadlock when called from
// methods that already hold other locks (e.g., PublicStreams, GetActiveScrapedLinks).
// URLs not present in the map are implicitly healthy.
func (s *Store) HealthMap() map[string]bool {
var states []models.HealthState
if err := readJSON(s.filePath("health_state.json"), &states); err != nil {
return make(map[string]bool)
}
m := make(map[string]bool, len(states))
for _, st := range states {
m[st.URL] = st.Healthy
}
return m
}

View file

@ -1,63 +0,0 @@
package store
import (
"fmt"
"time"
"f1-stream/internal/models"
)
func (s *Store) LoadScrapedLinks() ([]models.ScrapedLink, error) {
s.scrapedMu.RLock()
defer s.scrapedMu.RUnlock()
var links []models.ScrapedLink
if err := readJSON(s.filePath("scraped_links.json"), &links); err != nil {
return nil, err
}
return links, nil
}
func (s *Store) SaveScrapedLinks(links []models.ScrapedLink) error {
s.scrapedMu.Lock()
defer s.scrapedMu.Unlock()
return writeJSON(s.filePath("scraped_links.json"), links)
}
func (s *Store) GetScrapedLinkByID(id string) (models.ScrapedLink, error) {
s.scrapedMu.RLock()
defer s.scrapedMu.RUnlock()
var links []models.ScrapedLink
if err := readJSON(s.filePath("scraped_links.json"), &links); err != nil {
return models.ScrapedLink{}, err
}
for _, l := range links {
if l.ID == id {
return l, nil
}
}
return models.ScrapedLink{}, fmt.Errorf("not found")
}
func (s *Store) GetActiveScrapedLinks() ([]models.ScrapedLink, error) {
s.scrapedMu.RLock()
defer s.scrapedMu.RUnlock()
var links []models.ScrapedLink
if err := readJSON(s.filePath("scraped_links.json"), &links); err != nil {
return nil, err
}
healthMap := s.HealthMap()
now := time.Now()
var active []models.ScrapedLink
for _, l := range links {
l.Stale = now.Sub(l.ScrapedAt) > 7*24*time.Hour
if l.Stale {
continue
}
// Filter unhealthy scraped links. URLs not in healthMap are assumed healthy.
if healthy, exists := healthMap[l.URL]; exists && !healthy {
continue
}
active = append(active, l)
}
return active, nil
}

View file

@ -1,98 +0,0 @@
package store
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"f1-stream/internal/models"
)
func (s *Store) LoadSessions() ([]models.Session, error) {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return nil, err
}
return sessions, nil
}
func (s *Store) CreateSession(userID string, ttl time.Duration) (string, error) {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return "", err
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
sess := models.Session{
Token: token,
UserID: userID,
ExpiresAt: time.Now().Add(ttl),
}
sessions = append(sessions, sess)
if err := writeJSON(s.filePath("sessions.json"), sessions); err != nil {
return "", err
}
return token, nil
}
func (s *Store) GetSession(token string) (*models.Session, error) {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return nil, err
}
for _, sess := range sessions {
if sess.Token == token && time.Now().Before(sess.ExpiresAt) {
return &sess, nil
}
}
return nil, nil
}
func (s *Store) DeleteSession(token string) error {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return err
}
var updated []models.Session
found := false
for _, sess := range sessions {
if sess.Token == token {
found = true
continue
}
updated = append(updated, sess)
}
if !found {
return fmt.Errorf("session not found")
}
return writeJSON(s.filePath("sessions.json"), updated)
}
func (s *Store) CleanExpiredSessions() error {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return err
}
now := time.Now()
var valid []models.Session
for _, sess := range sessions {
if now.Before(sess.ExpiresAt) {
valid = append(valid, sess)
}
}
return writeJSON(s.filePath("sessions.json"), valid)
}

View file

@ -1,53 +0,0 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
type Store struct {
dir string
streamsMu sync.RWMutex
usersMu sync.RWMutex
scrapedMu sync.RWMutex
sessionsMu sync.RWMutex
healthMu sync.RWMutex
}
func New(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
func (s *Store) filePath(name string) string {
return filepath.Join(s.dir, name)
}
// readJSON reads a JSON file into the target. Returns nil if file doesn't exist.
func readJSON(path string, target interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, target)
}
// writeJSON atomically writes target as JSON to path using temp-file-then-rename.
func writeJSON(path string, data interface{}) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View file

@ -1,176 +0,0 @@
package store
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"f1-stream/internal/models"
)
func (s *Store) LoadStreams() ([]models.Stream, error) {
s.streamsMu.RLock()
defer s.streamsMu.RUnlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return nil, err
}
return streams, nil
}
func (s *Store) PublicStreams() ([]models.Stream, error) {
s.streamsMu.RLock()
defer s.streamsMu.RUnlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return nil, err
}
healthMap := s.HealthMap()
var pub []models.Stream
for _, st := range streams {
if !st.Published {
continue
}
// Filter unhealthy streams. URLs not in healthMap are assumed healthy (new/unchecked).
if healthy, exists := healthMap[st.URL]; exists && !healthy {
continue
}
pub = append(pub, st)
}
return pub, nil
}
func (s *Store) UserStreams(userID string) ([]models.Stream, error) {
s.streamsMu.RLock()
defer s.streamsMu.RUnlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return nil, err
}
var result []models.Stream
for _, st := range streams {
if st.SubmittedBy == userID {
result = append(result, st)
}
}
return result, nil
}
func (s *Store) AddStream(url, title, submittedBy string, published bool, source string) (models.Stream, error) {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return models.Stream{}, err
}
id, err := randomID()
if err != nil {
return models.Stream{}, err
}
st := models.Stream{
ID: id,
URL: url,
Title: title,
SubmittedBy: submittedBy,
Published: published,
Source: source,
CreatedAt: time.Now(),
}
streams = append(streams, st)
if err := writeJSON(s.filePath("streams.json"), streams); err != nil {
return models.Stream{}, err
}
return st, nil
}
func (s *Store) PublishScrapedStream(url, title string) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return err
}
// Deduplicate: skip if URL already exists in streams
for _, st := range streams {
if st.URL == url {
return nil
}
}
id, err := randomID()
if err != nil {
return err
}
streams = append(streams, models.Stream{
ID: id,
URL: url,
Title: title,
SubmittedBy: "scraper",
Published: true,
Source: "scraped",
CreatedAt: time.Now(),
})
return writeJSON(s.filePath("streams.json"), streams)
}
func (s *Store) DeleteStream(id, userID string, isAdmin bool) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return err
}
var updated []models.Stream
found := false
for _, st := range streams {
if st.ID == id {
if userID != "" && !isAdmin && st.SubmittedBy != userID {
return fmt.Errorf("not authorized")
}
found = true
continue
}
updated = append(updated, st)
}
if !found {
return fmt.Errorf("stream not found")
}
return writeJSON(s.filePath("streams.json"), updated)
}
func (s *Store) TogglePublish(id string) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return err
}
for i, st := range streams {
if st.ID == id {
streams[i].Published = !st.Published
return writeJSON(s.filePath("streams.json"), streams)
}
}
return fmt.Errorf("stream not found")
}
func (s *Store) SeedStreams(defaults []models.Stream) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var existing []models.Stream
if err := readJSON(s.filePath("streams.json"), &existing); err != nil {
return err
}
if len(existing) > 0 {
return nil
}
return writeJSON(s.filePath("streams.json"), defaults)
}
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View file

@ -1,91 +0,0 @@
package store
import (
"fmt"
"f1-stream/internal/models"
"github.com/go-webauthn/webauthn/webauthn"
)
func (s *Store) LoadUsers() ([]models.User, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return nil, err
}
return users, nil
}
func (s *Store) GetUserByName(username string) (*models.User, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return nil, err
}
for _, u := range users {
if u.Username == username {
return &u, nil
}
}
return nil, nil
}
func (s *Store) GetUserByID(id string) (*models.User, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return nil, err
}
for _, u := range users {
if u.ID == id {
return &u, nil
}
}
return nil, nil
}
func (s *Store) CreateUser(user models.User) error {
s.usersMu.Lock()
defer s.usersMu.Unlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return err
}
for _, u := range users {
if u.Username == user.Username {
return fmt.Errorf("username already exists")
}
}
users = append(users, user)
return writeJSON(s.filePath("users.json"), users)
}
func (s *Store) UpdateUserCredentials(userID string, creds []webauthn.Credential) error {
s.usersMu.Lock()
defer s.usersMu.Unlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return err
}
for i, u := range users {
if u.ID == userID {
users[i].Credentials = creds
return writeJSON(s.filePath("users.json"), users)
}
}
return fmt.Errorf("user not found")
}
func (s *Store) UserCount() (int, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return 0, err
}
return len(users), nil
}

View file

@ -1,163 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"f1-stream/internal/auth"
"f1-stream/internal/extractor"
"f1-stream/internal/healthcheck"
"f1-stream/internal/models"
"f1-stream/internal/playerconfig"
"f1-stream/internal/scraper"
"f1-stream/internal/server"
"f1-stream/internal/store"
)
func main() {
listenAddr := envOr("LISTEN_ADDR", ":8080")
dataDir := envOr("DATA_DIR", "/data")
scrapeInterval := envDuration("SCRAPE_INTERVAL", 15*time.Minute)
validateTimeout := envDuration("SCRAPER_VALIDATE_TIMEOUT", 10*time.Second)
adminUsername := os.Getenv("ADMIN_USERNAME")
sessionTTL := envDuration("SESSION_TTL", 720*time.Hour)
headlessEnabled := os.Getenv("HEADLESS_EXTRACT_ENABLED") == "true"
rpID := envOr("WEBAUTHN_RPID", "localhost")
rpOrigin := envOr("WEBAUTHN_ORIGIN", "http://localhost:8080")
rpDisplayName := envOr("WEBAUTHN_DISPLAY_NAME", "F1 Stream")
// Initialize store
st, err := store.New(dataDir)
if err != nil {
log.Fatalf("failed to init store: %v", err)
}
// Seed default streams
if err := st.SeedStreams(defaultStreams()); err != nil {
log.Printf("warning: failed to seed streams: %v", err)
}
// Initialize auth
origins := strings.Split(rpOrigin, ",")
a, err := auth.New(st, rpDisplayName, rpID, origins, adminUsername, sessionTTL)
if err != nil {
log.Fatalf("failed to init auth: %v", err)
}
// Initialize scraper
sc := scraper.New(st, scrapeInterval, validateTimeout)
// Initialize health checker
healthInterval := envDuration("HEALTH_CHECK_INTERVAL", 5*time.Minute)
healthTimeout := envDuration("HEALTH_CHECK_TIMEOUT", 10*time.Second)
hc := healthcheck.New(st, healthInterval, healthTimeout)
// Initialize server
pc := playerconfig.New()
srv := server.New(st, a, sc, pc, origins, headlessEnabled)
// Start scraper in background
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Initialize headless browser if enabled
if headlessEnabled {
extractor.Init()
defer extractor.Stop()
// Configure TURN server if provided
if turnURL := os.Getenv("TURN_URL"); turnURL != "" {
turnSecret := os.Getenv("TURN_SHARED_SECRET")
turnInternalURL := os.Getenv("TURN_INTERNAL_URL")
extractor.SetTURNConfig(turnURL, turnSecret, turnInternalURL)
}
log.Println("headless video extraction enabled")
}
go sc.Run(ctx)
go hc.Run(ctx)
// Clean expired sessions periodically
go func() {
sessionTicker := time.NewTicker(1 * time.Hour)
defer sessionTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-sessionTicker.C:
st.CleanExpiredSessions()
}
}
}()
httpSrv := &http.Server{
Addr: listenAddr,
Handler: srv.Handler(),
}
go func() {
<-ctx.Done()
log.Println("shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
httpSrv.Shutdown(shutdownCtx)
}()
log.Printf("starting server on %s", listenAddr)
if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
log.Println("server stopped")
}
func defaultStreams() []models.Stream {
now := time.Now()
streams := []struct {
url, title string
}{
{"https://wearechecking.live/streams-pages/motorsports", "WeAreChecking - Motorsports"},
{"https://vipleague.im/formula-1-schedule-streaming-links", "VIPLeague - F1"},
{"https://www.vipbox.lc/", "VIPBox"},
{"https://f1box.me/", "F1Box"},
{"https://1stream.vip/formula-1-streams/", "1Stream - F1"},
}
var result []models.Stream
for i, s := range streams {
result = append(result, models.Stream{
ID: fmt.Sprintf("default-%d", i),
URL: s.url,
Title: s.title,
SubmittedBy: "system",
Published: true,
Source: "system",
CreatedAt: now,
})
}
return result
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
log.Printf("warning: invalid %s=%q, using default %v", key, v, fallback)
return fallback
}
return d
}
return fallback
}

View file

@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -e
docker build -t viktorbarzin/f1-stream .
docker push viktorbarzin/f1-stream
kubectl -n f1-stream rollout restart deployment f1-stream

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,205 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>F1 Streams</title>
<meta name="description" content="Live F1 streaming links aggregated from Reddit and user submissions">
<meta property="og:title" content="F1 Streams">
<meta property="og:description" content="Live F1 streaming links aggregated from Reddit and user submissions">
<meta property="og:type" content="website">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23e10600' rx='12' width='100' height='100'/><text x='50' y='72' font-size='60' font-weight='900' text-anchor='middle' fill='white' font-family='sans-serif'>F1</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Titillium+Web:wght@400;600;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/pico.min.css">
<link rel="stylesheet" href="/static/css/custom.css">
</head>
<body>
<header>
<div class="header-left">
<span class="f1-logo">F1</span>
<div>
<h1 class="brand-title">Streams</h1>
<div class="brand-subtitle">Live Racing Hub</div>
</div>
<span class="live-indicator" id="live-badge" hidden>
<span class="live-dot"></span>
LIVE
</span>
</div>
<div class="auth-section" id="auth-section">
<button id="login-btn" onclick="showAuthDialog()">Login / Register</button>
</div>
</header>
<div class="racing-stripe"></div>
<nav class="tabs" id="tabs">
<button class="hamburger" id="hamburger" onclick="toggleMobileNav()" aria-label="Toggle navigation">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="tab-btn active" data-tab="streams" onclick="switchTab('streams')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Streams
</button>
<button class="tab-btn" data-tab="reddit" onclick="switchTab('reddit')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(90 12 12)"/></svg>
Reddit Links
</button>
<button class="tab-btn hidden" data-tab="mine" onclick="switchTab('mine')" id="tab-mine">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
My Streams
</button>
<button class="tab-btn hidden" data-tab="admin" onclick="switchTab('admin')" id="tab-admin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Admin
</button>
</nav>
<main>
<section class="tab-content active" id="content-streams">
<div class="submit-form-card">
<div class="submit-form">
<input type="url" id="public-submit-url" placeholder="https://stream-url.com/..." required>
<input type="text" id="public-submit-title" placeholder="Stream title (optional)">
<button onclick="addPublicStream()">Add Stream</button>
</div>
</div>
<div class="stream-grid" id="stream-grid"></div>
<div class="empty-state" id="streams-empty" style="display:none">
<span class="empty-icon">&#127937;</span>
<div class="empty-title">No Streams Yet</div>
<p class="empty-desc">Add a stream URL above to get the race started.</p>
</div>
</section>
<section class="tab-content" id="content-reddit">
<div class="section-header">
<h3>Reddit Links</h3>
<button onclick="refreshRedditLinks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Refresh
</button>
</div>
<ul class="link-list" id="reddit-list"></ul>
<div class="empty-state" id="reddit-empty" style="display:none">
<span class="empty-icon">&#128225;</span>
<div class="empty-title">No Links Found</div>
<p class="empty-desc">No Reddit links scraped yet. Check back closer to race time.</p>
</div>
</section>
<section class="tab-content" id="content-mine">
<div class="submit-form-card">
<div class="submit-form">
<input type="url" id="submit-url" placeholder="https://stream-url.com/..." required>
<input type="text" id="submit-title" placeholder="Stream title (optional)">
<button onclick="submitStream()">Submit Stream</button>
</div>
</div>
<div class="stream-grid" id="my-stream-grid"></div>
<div class="empty-state" id="mine-empty" style="display:none">
<span class="empty-icon">&#127918;</span>
<div class="empty-title">Your Pit Lane is Empty</div>
<p class="empty-desc">Submit a stream URL above to join the grid.</p>
</div>
</section>
<section class="tab-content" id="content-admin">
<div class="section-header">
<h3>All Streams</h3>
<button onclick="triggerScrape()">Trigger Scrape</button>
</div>
<div class="admin-stats" id="admin-stats"></div>
<div id="admin-stream-list"></div>
</section>
<!-- Browser Session Viewer (inline within main) -->
<section id="browser-viewer" class="browser-viewer hidden">
<div class="browser-viewer-bar">
<div class="browser-url-bar">
<svg class="browser-url-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="browser-url-text" id="browser-url"></span>
</div>
<span class="browser-viewer-status"></span>
<a id="browser-open-original" href="#" target="_blank" rel="noopener" class="browser-open-btn" title="Open original in new tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<button class="browser-viewer-close" onclick="closeBrowserSession()" title="Close viewer">&times;</button>
</div>
<div class="browser-viewer-content">
<div class="loading-overlay" id="browser-viewer-loader">
<div class="spinner"></div>
</div>
</div>
</section>
</main>
<footer>
<p>Stream links are user-submitted and scraped from Reddit. No streams are hosted on this site.</p>
</footer>
<!-- Auth Dialog -->
<dialog id="auth-dialog">
<article>
<button class="dialog-close" onclick="document.getElementById('auth-dialog').close()" aria-label="Close">&times;</button>
<div class="dialog-logo"><span class="f1-logo">F1</span></div>
<h3 class="dialog-title">Welcome</h3>
<p class="dialog-subtitle">Sign in with your passkey to manage streams</p>
<div class="dialog-tabs">
<button class="dialog-tab-btn active" onclick="switchAuthTab('login', event)">Login</button>
<button class="dialog-tab-btn" onclick="switchAuthTab('register', event)">Register</button>
</div>
<div id="auth-login-form" class="auth-form-group">
<label for="login-username">Username</label>
<input type="text" id="login-username" placeholder="Username" autocomplete="username webauthn">
<div class="error-msg" id="login-error"></div>
<button onclick="doLogin()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Login with Passkey
</button>
</div>
<div id="auth-register-form" class="auth-form-group" style="display:none">
<label for="register-username">Username</label>
<input type="text" id="register-username" placeholder="Username (3-30 chars)" autocomplete="username">
<div class="error-msg" id="register-error"></div>
<button onclick="doRegister()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
Register with Passkey
</button>
</div>
<button onclick="document.getElementById('auth-dialog').close()" class="btn-secondary dialog-cancel">Cancel</button>
</article>
</dialog>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<!-- Reddit Viewer Overlay -->
<div id="reddit-viewer" class="reddit-viewer hidden">
<div class="reddit-viewer-bar">
<span class="reddit-viewer-title"></span>
<button class="reddit-viewer-close" onclick="closeRedditViewer()">&times;</button>
</div>
<div class="reddit-viewer-content">
<div class="loading-overlay" id="reddit-viewer-loader">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- Browser Session Viewer (inline, inside main via JS) -->
<script src="https://cdn.jsdelivr.net/npm/crypto-js@4/crypto-js.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<script src="/static/js/player.js"></script>
<script src="/static/js/utils.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/streams.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View file

@ -1,121 +0,0 @@
// Toast notification system
const TOAST_ICONS = {
success: '\u2705',
error: '\u274C',
warning: '\u26A0\uFE0F',
info: '\u2139\uFE0F'
};
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-icon">${TOAST_ICONS[type] || TOAST_ICONS.info}</span>
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close" onclick="dismissToast(this.parentElement)">&times;</button>
`;
container.appendChild(toast);
if (duration > 0) {
setTimeout(() => dismissToast(toast), duration);
}
}
function dismissToast(toast) {
if (!toast || toast.classList.contains('toast-out')) return;
toast.classList.add('toast-out');
toast.addEventListener('animationend', () => toast.remove());
}
// Confirm dialog (replaces window.confirm)
function showConfirm(message) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<div class="confirm-msg">${escapeHtml(message)}</div>
<div class="confirm-actions">
<button class="btn-secondary" id="confirm-cancel">Cancel</button>
<button class="btn-primary" id="confirm-ok">Confirm</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#confirm-ok').addEventListener('click', () => {
overlay.remove();
resolve(true);
});
overlay.querySelector('#confirm-cancel').addEventListener('click', () => {
overlay.remove();
resolve(false);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
resolve(false);
}
});
});
}
// Mobile nav hamburger toggle
function toggleMobileNav() {
const tabs = document.getElementById('tabs');
tabs.classList.toggle('open');
}
// Tab switching
function switchTab(tab) {
closeRedditViewer();
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
document.querySelectorAll('.tab-content').forEach(c => {
c.classList.toggle('active', c.id === 'content-' + tab);
});
// Close mobile nav
document.getElementById('tabs').classList.remove('open');
// Load data for the tab
switch (tab) {
case 'streams':
loadPublicStreams();
break;
case 'reddit':
loadRedditLinks();
break;
case 'mine':
loadMyStreams();
break;
case 'admin':
loadAdminStreams();
break;
}
}
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
checkAuth();
await loadPublicStreams();
const grid = document.getElementById('stream-grid');
const badge = document.getElementById('live-badge');
if (badge && grid && grid.children.length > 0) {
badge.hidden = false;
}
});
// Close Reddit viewer on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const viewer = document.getElementById('reddit-viewer');
if (viewer && !viewer.classList.contains('hidden')) {
closeRedditViewer();
}
}
});

View file

@ -1,219 +0,0 @@
// WebAuthn helper: base64url encode/decode
function bufToBase64url(buf) {
const bytes = new Uint8Array(buf);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function base64urlToBuf(b64) {
const pad = b64.length % 4;
if (pad) b64 += '='.repeat(4 - pad);
const str = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const buf = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
return buf.buffer;
}
let currentUser = null;
function showAuthDialog() {
document.getElementById('auth-dialog').showModal();
}
function switchAuthTab(tab, evt) {
const btns = document.querySelectorAll('.dialog-tab-btn');
btns.forEach(b => b.classList.remove('active'));
evt.target.classList.add('active');
document.getElementById('auth-login-form').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('auth-register-form').style.display = tab === 'register' ? 'block' : 'none';
document.getElementById('login-error').textContent = '';
document.getElementById('register-error').textContent = '';
}
async function doRegister() {
const username = document.getElementById('register-username').value.trim();
const errEl = document.getElementById('register-error');
errEl.textContent = '';
if (!username || username.length < 3) {
errEl.textContent = 'Username must be at least 3 characters';
return;
}
try {
// Step 1: Begin registration
const beginResp = await fetch('/api/auth/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
if (!beginResp.ok) {
const err = await beginResp.json();
errEl.textContent = err.error || 'Registration failed';
return;
}
const options = await beginResp.json();
// Convert base64url fields to ArrayBuffers
options.publicKey.challenge = base64urlToBuf(options.publicKey.challenge);
options.publicKey.user.id = base64urlToBuf(options.publicKey.user.id);
if (options.publicKey.excludeCredentials) {
options.publicKey.excludeCredentials = options.publicKey.excludeCredentials.map(c => ({
...c,
id: base64urlToBuf(c.id)
}));
}
// Step 2: Create credential via browser
const credential = await navigator.credentials.create(options);
// Step 3: Finish registration
const attestation = {
id: credential.id,
rawId: bufToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufToBase64url(credential.response.attestationObject),
clientDataJSON: bufToBase64url(credential.response.clientDataJSON)
}
};
const finishResp = await fetch(`/api/auth/register/finish?username=${encodeURIComponent(username)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestation)
});
if (!finishResp.ok) {
const err = await finishResp.json();
errEl.textContent = err.error || 'Registration failed';
return;
}
const user = await finishResp.json();
setLoggedIn(user);
document.getElementById('auth-dialog').close();
} catch (e) {
console.error('Registration error:', e);
errEl.textContent = e.message || 'Registration failed';
}
}
async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const errEl = document.getElementById('login-error');
errEl.textContent = '';
if (!username) {
errEl.textContent = 'Username required';
return;
}
try {
// Step 1: Begin login
const beginResp = await fetch('/api/auth/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
if (!beginResp.ok) {
const err = await beginResp.json();
errEl.textContent = err.error || 'Login failed';
return;
}
const options = await beginResp.json();
// Convert base64url fields
options.publicKey.challenge = base64urlToBuf(options.publicKey.challenge);
if (options.publicKey.allowCredentials) {
options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(c => ({
...c,
id: base64urlToBuf(c.id)
}));
}
// Step 2: Get assertion via browser
const assertion = await navigator.credentials.get(options);
// Step 3: Finish login
const assertionData = {
id: assertion.id,
rawId: bufToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufToBase64url(assertion.response.clientDataJSON),
signature: bufToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle ? bufToBase64url(assertion.response.userHandle) : ''
}
};
const finishResp = await fetch(`/api/auth/login/finish?username=${encodeURIComponent(username)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionData)
});
if (!finishResp.ok) {
const err = await finishResp.json();
errEl.textContent = err.error || 'Login failed';
return;
}
const user = await finishResp.json();
setLoggedIn(user);
document.getElementById('auth-dialog').close();
} catch (e) {
console.error('Login error:', e);
errEl.textContent = e.message || 'Login failed';
}
}
async function doLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
setLoggedOut();
}
function setLoggedIn(user) {
currentUser = user;
const section = document.getElementById('auth-section');
section.innerHTML = `
<span>Hi, ${escapeHtml(user.username)}</span>
<button onclick="doLogout()">Logout</button>
`;
document.getElementById('tab-mine').classList.remove('hidden');
if (user.is_admin) {
document.getElementById('tab-admin').classList.remove('hidden');
}
}
function setLoggedOut() {
currentUser = null;
const section = document.getElementById('auth-section');
section.innerHTML = '<button id="login-btn" onclick="showAuthDialog()">Login / Register</button>';
document.getElementById('tab-mine').classList.add('hidden');
document.getElementById('tab-admin').classList.add('hidden');
// Switch to streams tab if on a protected tab
const activeTab = document.querySelector('.tab-btn.active');
if (activeTab && (activeTab.dataset.tab === 'mine' || activeTab.dataset.tab === 'admin')) {
switchTab('streams');
}
}
async function checkAuth() {
try {
const resp = await fetch('/api/auth/me');
if (resp.ok) {
const user = await resp.json();
setLoggedIn(user);
}
} catch (e) {
// Not logged in
}
}

View file

@ -1,216 +0,0 @@
// player.js — Native HLS player management using HLS.js directly
var _hlsInstance = null;
var _videoElement = null;
/**
* Fetch player config for a stream from the backend.
* Returns {type: "hls"|"daddylive"|"proxy", hls_url, auth_token, ...}
*/
async function getPlayerConfig(streamId) {
try {
const resp = await fetch('/api/streams/' + streamId + '/player-config');
if (!resp.ok) return { type: 'proxy' };
return await resp.json();
} catch (e) {
console.error('Failed to fetch player config:', e);
return { type: 'proxy' };
}
}
/**
* Decode a /hls/{b64} URL back to the original upstream URL.
*/
function decodeHLSURL(proxyURL) {
if (!proxyURL || typeof proxyURL !== 'string') return proxyURL;
var m = proxyURL.match(/\/hls\/([A-Za-z0-9_-]+)/);
if (!m) return proxyURL;
try {
// base64url decode
var b64 = m[1].replace(/-/g, '+').replace(/_/g, '/');
// pad
while (b64.length % 4 !== 0) b64 += '=';
return atob(b64);
} catch (e) {
return proxyURL;
}
}
/**
* Create an HLS.js player for a plain HLS stream.
*/
function createHLSPlayer(containerSelector, hlsURL) {
destroyNativePlayer();
_buildPlayer(containerSelector, hlsURL, {});
}
/**
* Create an HLS.js player for DaddyLive streams with auth module integration.
*/
function createDaddyLivePlayer(containerSelector, config) {
destroyNativePlayer();
if (config.auth_mod_url) {
_loadAuthModAndPlay(containerSelector, config);
} else {
_buildPlayer(containerSelector, config.hls_url, {});
}
}
function _loadAuthModAndPlay(containerSelector, config) {
var script = document.createElement('script');
script.src = config.auth_mod_url;
script.onload = function () {
_createDaddyLivePlayerWithAuth(containerSelector, config);
};
script.onerror = function () {
console.warn('Failed to load auth module, falling back to direct HLS');
_buildPlayer(containerSelector, config.hls_url, {});
};
document.head.appendChild(script);
}
function _createDaddyLivePlayerWithAuth(containerSelector, config) {
var hlsConfig = {};
// If EPlayerAuth is available, set up xhr wrapping
if (typeof EPlayerAuth !== 'undefined' && typeof EPlayerAuth.init === 'function') {
try {
EPlayerAuth.init({
authToken: config.auth_token,
channelKey: config.channel_key,
channelSalt: config.channel_salt,
timestamp: config.timestamp,
serverKey: config.server_key
});
if (typeof EPlayerAuth.getXhrSetup === 'function') {
var origSetup = EPlayerAuth.getXhrSetup();
hlsConfig.xhrSetup = function (xhr, url) {
// Decode the real upstream URL from our /hls/{b64} proxy path
var realURL = decodeHLSURL(url);
// Create interceptor to capture headers the auth module sets
var captured = {};
var fakeXHR = {
setRequestHeader: function (k, v) { captured[k] = v; }
};
try {
origSetup(fakeXHR, realURL);
} catch (e) {
console.warn('Auth xhrSetup error:', e);
}
// Re-set captured headers with forwarding prefix
for (var k in captured) {
if (captured.hasOwnProperty(k)) {
xhr.setRequestHeader('X-Hls-Forward-' + k, captured[k]);
}
}
};
}
} catch (e) {
console.warn('EPlayerAuth init failed:', e);
}
}
_buildPlayer(containerSelector, config.hls_url, hlsConfig);
}
/**
* Build an HLS.js player with a <video> element.
*/
function _buildPlayer(containerSelector, hlsURL, extraConfig) {
var container = document.querySelector(containerSelector);
if (!container) return;
// Create video element
var video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.style.width = '100%';
video.style.height = '100%';
video.style.backgroundColor = '#000';
container.appendChild(video);
_videoElement = video;
if (Hls.isSupported()) {
var config = {
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60
};
// Merge extra config (e.g. xhrSetup for auth)
for (var k in extraConfig) {
if (extraConfig.hasOwnProperty(k)) {
config[k] = extraConfig[k];
}
}
var hls = new Hls(config);
hls.loadSource(hlsURL);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
video.play().catch(function(e) {
console.warn('Autoplay blocked:', e);
});
});
hls.on(Hls.Events.ERROR, function (event, data) {
console.error('HLS.js error:', data.type, data.details, data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.warn('HLS network error, attempting recovery...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.warn('HLS media error, attempting recovery...');
hls.recoverMediaError();
break;
default:
console.error('HLS fatal error, cannot recover');
hls.destroy();
break;
}
}
});
_hlsInstance = hls;
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS
video.src = hlsURL;
video.addEventListener('loadedmetadata', function () {
video.play().catch(function(e) {
console.warn('Autoplay blocked:', e);
});
});
} else {
container.textContent = 'HLS playback is not supported in this browser.';
}
}
/**
* Destroy the current native player instance.
*/
function destroyNativePlayer() {
if (_hlsInstance) {
try {
_hlsInstance.destroy();
} catch (e) {
console.warn('Error destroying HLS instance:', e);
}
_hlsInstance = null;
}
if (_videoElement) {
try {
_videoElement.pause();
_videoElement.removeAttribute('src');
_videoElement.load();
_videoElement.remove();
} catch (e) {
console.warn('Error removing video element:', e);
}
_videoElement = null;
}
}

View file

@ -1,419 +0,0 @@
async function loadPublicStreams() {
const grid = document.getElementById('stream-grid');
const empty = document.getElementById('streams-empty');
try {
const resp = await fetch('/api/streams/public');
const streams = await resp.json();
if (!streams || streams.length === 0) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
grid.innerHTML = streams.map(s => streamCard(s, !!currentUser)).join('');
} catch (e) {
console.error('Failed to load streams:', e);
grid.innerHTML = '';
empty.style.display = '';
}
}
async function loadMyStreams() {
const grid = document.getElementById('my-stream-grid');
const empty = document.getElementById('mine-empty');
try {
const resp = await fetch('/api/streams/mine');
const streams = await resp.json();
if (!streams || streams.length === 0) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
grid.innerHTML = streams.map(s => streamCard(s, true)).join('');
} catch (e) {
console.error('Failed to load my streams:', e);
}
}
async function loadRedditLinks() {
const list = document.getElementById('reddit-list');
const empty = document.getElementById('reddit-empty');
try {
const [scrapedResp, streamsResp] = await Promise.all([
fetch('/api/scraped'),
fetch('/api/streams/public')
]);
const links = await scrapedResp.json();
const streams = await streamsResp.json();
const importedURLs = new Set((streams || []).map(s => s.url));
if (!links || links.length === 0) {
list.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
list.innerHTML = links.map(l => {
const imported = importedURLs.has(l.url);
const actionHtml = imported
? `<span class="badge badge-imported">Imported</span>`
: `<button class="btn-import" onclick="importRedditLink('${escapeHtml(l.id)}')">Import</button>`;
return `
<li>
<span class="link-source-badge">${escapeHtml(l.source)}</span>
<div class="link-title">
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener">${escapeHtml(l.title || l.url)}</a>
</div>
${actionHtml}
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener" class="link-open-icon-wrap" title="Open in new tab">
<svg class="link-open-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</li>
`;
}).join('');
} catch (e) {
console.error('Failed to load Reddit links:', e);
}
}
async function importRedditLink(id) {
try {
const resp = await fetch(`/api/scraped/${id}/import`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json();
showToast(err.error || 'Failed to import', 'error');
return;
}
showToast('Stream imported', 'success');
loadRedditLinks();
loadPublicStreams();
} catch (e) {
showToast('Failed to import stream', 'error');
}
}
async function loadAdminStreams() {
const container = document.getElementById('admin-stream-list');
const statsContainer = document.getElementById('admin-stats');
try {
const resp = await fetch('/api/admin/streams');
const streams = await resp.json();
if (!streams || streams.length === 0) {
statsContainer.innerHTML = '';
container.innerHTML = '<div class="empty-state"><span class="empty-icon">&#128203;</span><div class="empty-title">No Streams</div><p class="empty-desc">No streams have been submitted yet.</p></div>';
return;
}
const total = streams.length;
const published = streams.filter(s => s.published).length;
const drafts = total - published;
statsContainer.innerHTML = `
<div class="stat-card">
<div class="stat-number">${total}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-number">${published}</div>
<div class="stat-label">Published</div>
</div>
<div class="stat-card">
<div class="stat-number">${drafts}</div>
<div class="stat-label">Drafts</div>
</div>
`;
container.innerHTML = streams.map(s => `
<div class="admin-stream">
<div class="info">
<span class="status-dot ${s.published ? 'published' : 'draft'}"></span>
<div class="stream-details">
<div class="stream-title">
${escapeHtml(s.title)}
<span class="badge ${s.published ? 'badge-published' : 'badge-draft'}">
${s.published ? 'Published' : 'Draft'}
</span>
</div>
<div class="stream-url">${escapeHtml(s.url)}</div>
${s.submitted_by ? `<div class="stream-submitter">by ${escapeHtml(s.submitted_by)}</div>` : ''}
</div>
</div>
<div class="actions">
<button onclick="togglePublish('${s.id}')" class="${s.published ? 'btn-secondary-sm' : 'btn-primary-sm'}">
${s.published ? 'Unpublish' : 'Publish'}
</button>
<button onclick="deleteStream('${s.id}', true)" class="btn-danger-sm">Delete</button>
</div>
</div>
`).join('');
} catch (e) {
console.error('Failed to load admin streams:', e);
}
}
function streamCard(stream, canDelete) {
const deleteBtn = canDelete
? `<button onclick="event.stopPropagation(); deleteStream('${stream.id}', false)" class="icon-btn danger" title="Delete stream">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>`
: '';
return `
<div class="stream-card" data-stream-id="${stream.id}"
onclick="openBrowserSession('${stream.id}', '${escapeAttr(stream.title)}', '${escapeAttr(stream.url)}')">
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="card-title">${escapeHtml(stream.title)}</div>
<div class="card-url">${escapeHtml(stream.url)}</div>
</div>
<div class="card-bar">
<div class="card-actions">
<a href="${escapeHtml(stream.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" class="icon-btn" title="Open original">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
${deleteBtn}
</div>
</div>
</div>
`;
}
async function _submitStreamCommon(urlId, titleId, successMsg, reloadFn) {
const urlInput = document.getElementById(urlId);
const titleInput = document.getElementById(titleId);
const url = urlInput.value.trim();
const title = titleInput.value.trim();
if (!url) {
showToast('URL is required', 'warning');
return;
}
try {
new URL(url);
} catch {
showToast('Please enter a valid URL', 'warning');
return;
}
try {
const resp = await fetch('/api/streams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, title })
});
if (!resp.ok) {
const err = await resp.json();
showToast(err.error || 'Failed to add stream', 'error');
return;
}
urlInput.value = '';
titleInput.value = '';
showToast(successMsg, 'success');
reloadFn();
} catch (e) {
showToast('Failed to add stream', 'error');
}
}
async function addPublicStream() {
await _submitStreamCommon('public-submit-url', 'public-submit-title', 'Stream added', loadPublicStreams);
}
async function submitStream() {
await _submitStreamCommon('submit-url', 'submit-title', 'Stream submitted for review', loadMyStreams);
}
async function deleteStream(id, isAdmin) {
const confirmed = await showConfirm('Delete this stream?');
if (!confirmed) return;
try {
const resp = await fetch(`/api/streams/${id}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json();
showToast(err.error || 'Failed to delete', 'error');
return;
}
showToast('Stream deleted', 'success');
if (isAdmin) {
loadAdminStreams();
} else {
loadMyStreams();
}
loadPublicStreams();
} catch (e) {
showToast('Failed to delete stream', 'error');
}
}
async function togglePublish(id) {
try {
const resp = await fetch(`/api/streams/${id}/publish`, { method: 'PUT' });
if (!resp.ok) {
showToast('Failed to toggle publish', 'error');
return;
}
showToast('Stream updated', 'success');
loadAdminStreams();
loadPublicStreams();
} catch (e) {
showToast('Failed to toggle publish', 'error');
}
}
async function refreshRedditLinks() {
try {
const resp = await fetch('/api/scraped/refresh', { method: 'POST' });
if (!resp.ok) {
showToast('Failed to trigger refresh', 'error');
return;
}
showToast('Refreshing links from Reddit...', 'info');
let attempts = 0;
const maxAttempts = 15;
const poll = setInterval(async () => {
attempts++;
await loadRedditLinks();
if (attempts >= maxAttempts) {
clearInterval(poll);
}
}, 2000);
} catch (e) {
showToast('Failed to trigger refresh', 'error');
}
}
async function triggerScrape() {
try {
await fetch('/api/admin/scrape', { method: 'POST' });
showToast('Scrape triggered', 'success');
} catch (e) {
showToast('Failed to trigger scrape', 'error');
}
}
function closeRedditViewer() {
const viewer = document.getElementById('reddit-viewer');
if (!viewer) return;
viewer.classList.add('hidden');
const contentEl = viewer.querySelector('.reddit-viewer-content');
contentEl.querySelectorAll(':scope > :not(#reddit-viewer-loader)').forEach(el => el.remove());
}
// --- Browser Session Viewer (Iframe Proxy + Native Player) ---
async function openBrowserSession(streamId, streamTitle, streamURL) {
const viewer = document.getElementById('browser-viewer');
const statusEl = viewer.querySelector('.browser-viewer-status');
const contentEl = viewer.querySelector('.browser-viewer-content');
const loader = document.getElementById('browser-viewer-loader');
const urlText = document.getElementById('browser-url');
const openOriginal = document.getElementById('browser-open-original');
statusEl.textContent = 'Loading...';
statusEl.classList.remove('connected');
loader.classList.remove('hidden');
if (urlText) urlText.textContent = streamURL;
if (openOriginal) openOriginal.href = streamURL;
// Hide all tab content sections and show the viewer
document.querySelectorAll('.tab-content').forEach(s => s.classList.remove('active'));
viewer.classList.remove('hidden');
viewer.classList.add('active');
// Remove any existing iframe or player
contentEl.querySelectorAll('.browser-iframe').forEach(el => el.remove());
contentEl.querySelectorAll('#clappr-player').forEach(el => el.remove());
destroyNativePlayer();
// Fetch player config to determine stream type
const config = await getPlayerConfig(streamId);
if (config.type === 'hls' || config.type === 'daddylive') {
// Native player mode
const playerDiv = document.createElement('div');
playerDiv.id = 'clappr-player';
contentEl.appendChild(playerDiv);
loader.classList.add('hidden');
statusEl.textContent = 'Playing';
statusEl.classList.add('connected');
if (config.type === 'daddylive') {
createDaddyLivePlayer('#clappr-player', config);
} else {
createHLSPlayer('#clappr-player', config.hls_url);
}
return;
}
// Fallback: iframe proxy mode
let parsed;
try {
parsed = new URL(streamURL);
} catch (e) {
statusEl.textContent = 'Invalid URL';
loader.classList.add('hidden');
showToast('Invalid stream URL', 'error');
return;
}
const origin = parsed.origin;
const pathAndSearch = parsed.pathname + parsed.search + parsed.hash;
const b64Origin = btoa(origin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const proxyURL = '/proxy/' + b64Origin + pathAndSearch;
const iframe = document.createElement('iframe');
iframe.src = proxyURL;
iframe.className = 'browser-iframe';
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation');
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
iframe.setAttribute('allowfullscreen', '');
iframe.onload = function() {
loader.classList.add('hidden');
statusEl.textContent = 'Connected';
statusEl.classList.add('connected');
};
contentEl.appendChild(iframe);
}
function closeBrowserSession() {
destroyNativePlayer();
const viewer = document.getElementById('browser-viewer');
viewer.classList.add('hidden');
viewer.classList.remove('active');
const contentEl = viewer.querySelector('.browser-viewer-content');
contentEl.querySelectorAll('.browser-iframe').forEach(el => el.remove());
contentEl.querySelectorAll('#clappr-player').forEach(el => el.remove());
const statusEl = viewer.querySelector('.browser-viewer-status');
statusEl.textContent = '';
statusEl.classList.remove('connected');
const urlText = document.getElementById('browser-url');
if (urlText) urlText.textContent = '';
// Restore the previously active tab
const activeTab = document.querySelector('.tab-btn.active');
if (activeTab) {
const tabName = activeTab.dataset.tab;
const content = document.getElementById('content-' + tabName);
if (content) content.classList.add('active');
}
}

View file

@ -1,9 +0,0 @@
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeAttr(str) {
return str.replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View file

@ -1,135 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "turn_secret" { type = string }
variable "public_ip" { type = string }
resource "kubernetes_namespace" "f1-stream" {
metadata {
name = "f1-stream"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
resource "kubernetes_deployment" "f1-stream" {
metadata {
name = "f1-stream"
namespace = kubernetes_namespace.f1-stream.metadata[0].name
labels = {
app = "f1-stream"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "f1-stream"
}
}
template {
metadata {
labels = {
app = "f1-stream"
}
}
spec {
container {
image = "viktorbarzin/f1-stream:v1.3.1"
name = "f1-stream"
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
requests = {
cpu = "50m"
memory = "128Mi"
}
}
port {
container_port = 8080
}
env {
name = "WEBAUTHN_RPID"
value = "f1.viktorbarzin.me"
}
env {
name = "WEBAUTHN_ORIGIN"
value = "https://f1.viktorbarzin.me"
}
env {
name = "WEBAUTHN_DISPLAY_NAME"
value = "F1 Stream"
}
env {
name = "HEADLESS_EXTRACT_ENABLED"
value = "true"
}
env {
name = "TURN_URL"
value = "turn:${var.public_ip}:3478"
}
env {
name = "TURN_SHARED_SECRET"
value = var.turn_secret
}
env {
name = "TURN_INTERNAL_URL"
value = "turn:coturn.coturn.svc.cluster.local:3478"
}
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/f1-stream"
}
}
}
}
}
}
resource "kubernetes_service" "f1-stream" {
metadata {
name = "f1"
namespace = kubernetes_namespace.f1-stream.metadata[0].name
labels = {
"app" = "f1-stream"
}
}
spec {
selector = {
app = "f1-stream"
}
port {
port = "80"
target_port = "8080"
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.f1-stream.metadata[0].name
tls_secret_name = var.tls_secret_name
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.f1-stream.metadata[0].name
name = "f1"
tls_secret_name = var.tls_secret_name
rybbit_site_id = "7e69786f66d5"
exclude_crowdsec = true
}

View file

@ -1,103 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "forgejo" {
metadata {
name = "forgejo"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.forgejo.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "forgejo" {
metadata {
name = "forgejo"
namespace = kubernetes_namespace.forgejo.metadata[0].name
labels = {
app = "forgejo"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate" # DB is external so we can roll
}
selector {
match_labels = {
app = "forgejo"
}
}
template {
metadata {
labels = {
app = "forgejo"
}
}
spec {
container {
name = "forgejo"
image = "codeberg.org/forgejo/forgejo:11"
env {
name = "USER_UID"
value = 1000
}
env {
name = "USER_GID"
value = 1000
}
volume_mount {
name = "data"
mount_path = "/data"
}
port {
name = "http"
container_port = 3000
protocol = "TCP"
}
}
volume {
name = "data"
nfs {
path = "/mnt/main/forgejo"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "forgejo" {
metadata {
name = "forgejo"
namespace = kubernetes_namespace.forgejo.metadata[0].name
labels = {
"app" = "forgejo"
}
}
spec {
selector = {
app = "forgejo"
}
port {
port = 80
target_port = 3000
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.forgejo.metadata[0].name
name = "forgejo"
tls_secret_name = var.tls_secret_name
}

View file

@ -1,149 +0,0 @@
variable "tls_secret_name" {}
variable "name" {}
variable "tag" {
default = "latest"
}
variable "tier" { type = string }
variable "protected" {
type = bool
default = false
}
variable "listenbrainz_token" {
type = string
default = null
}
variable "genius_token" {
type = string
default = null
}
variable "dab_visitor_id" {
type = string
default = null
}
variable "dab_session" {
type = string
default = null
}
variable "gemini_api_key" {
type = string
default = null
}
variable "cpu_limit" {
type = string
default = "500m"
}
variable "memory_limit" {
type = string
default = "512Mi"
}
variable "cpu_request" {
type = string
default = "100m"
}
variable "memory_request" {
type = string
default = "256Mi"
}
resource "kubernetes_deployment" "freedify" {
metadata {
name = "music-${var.name}"
namespace = "freedify"
labels = {
app = "music-${var.name}"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate"
}
selector {
match_labels = {
app = "music-${var.name}"
}
}
template {
metadata {
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^${var.tag}$"
}
labels = {
app = "music-${var.name}"
}
}
spec {
container {
image = "viktorbarzin/freedify:${var.tag}"
name = "freedify"
port {
container_port = 8000
}
env {
name = "LISTENBRAINZ_TOKEN"
value = var.listenbrainz_token
}
env {
name = "GENIUS_ACCESS_TOKEN"
value = var.genius_token
}
env {
name = "DAB_SESSION"
value = var.dab_session
}
env {
name = "DAB_VISITOR_ID"
value = var.dab_visitor_id
}
env {
name = "GEMINI_API_KEY"
value = var.gemini_api_key
}
resources {
limits = {
cpu = var.cpu_limit
memory = var.memory_limit
}
requests = {
cpu = var.cpu_request
memory = var.memory_request
}
}
}
}
}
}
}
resource "kubernetes_service" "freedify" {
metadata {
name = "music-${var.name}"
namespace = "freedify"
labels = {
app = "music-${var.name}"
}
}
spec {
selector = {
app = "music-${var.name}"
}
port {
name = "http"
port = 80
target_port = 8000
}
}
}
module "ingress" {
source = "../../ingress_factory"
namespace = "freedify"
name = "music-${var.name}"
tls_secret_name = var.tls_secret_name
protected = var.protected
}

View file

@ -1,55 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "additional_credentials" { type = map(any) }
# To create a new deployment:
/**
1. Export a new nfs share with {name} in truenas at /mnt/main/freedify/{name}
2. Add {name} as proxied cloudflare route (tfvars)
3. Add module here
*/
resource "kubernetes_namespace" "freedify" {
metadata {
name = "freedify"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.freedify.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# https://music-viktor.viktorbarzin.me/
module "viktor" {
source = "./factory"
name = "viktor"
tag = "latest"
tls_secret_name = var.tls_secret_name
depends_on = [kubernetes_namespace.freedify]
tier = var.tier
protected = true
listenbrainz_token = lookup(var.additional_credentials["viktor"], "listenbrainz_token", null)
genius_token = lookup(var.additional_credentials["viktor"], "genius_token", null)
dab_session = lookup(var.additional_credentials["viktor"], "dab_session", null)
dab_visitor_id = lookup(var.additional_credentials["viktor"], "dab_visitor_id", null)
gemini_api_key = lookup(var.additional_credentials["viktor"], "gemini_api_key", null)
}
# https://music-emo.viktorbarzin.me/
module "emo" {
source = "./factory"
name = "emo"
tag = "latest"
tls_secret_name = var.tls_secret_name
depends_on = [kubernetes_namespace.freedify]
tier = var.tier
protected = true
genius_token = lookup(var.additional_credentials["emo"], "genius_token", null)
gemini_api_key = lookup(var.additional_credentials["emo"], "gemini_api_key", null)
}

View file

@ -1,122 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = "freshrss"
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "immich" {
metadata {
name = "freshrss"
labels = {
tier = var.tier
}
}
}
resource "kubernetes_deployment" "freshrss" {
metadata {
name = "freshrss"
namespace = "freshrss"
labels = {
app = "freshrss"
"kubernetes.io/cluster-service" = "true"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "freshrss"
}
}
template {
metadata {
labels = {
app = "freshrss"
"kubernetes.io/cluster-service" = "true"
}
}
spec {
container {
name = "freshrss"
image = "freshrss/freshrss"
env {
name = "CRON_MIN"
value = "0,30"
}
env {
name = "BASE_URL"
value = "https://rss.viktorbarzin.me"
}
env {
name = "PUBLISHED_PORT"
value = 80
}
volume_mount {
name = "data"
mount_path = "/var/www/FreshRSS/data"
}
volume_mount {
name = "extensions"
mount_path = "/var/www/FreshRSS/extensions"
}
port {
name = "http"
container_port = 80
protocol = "TCP"
}
}
volume {
name = "data"
nfs {
path = "/mnt/main/freshrss/data"
server = "10.0.10.15"
}
}
volume {
name = "extensions"
nfs {
path = "/mnt/main/freshrss/extensions"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "freshrss" {
metadata {
name = "freshrss"
namespace = "freshrss"
labels = {
"app" = "freshrss"
}
}
spec {
selector = {
app = "freshrss"
}
port {
port = "80"
target_port = "80"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = "freshrss"
name = "rss"
service_name = "freshrss"
tls_secret_name = var.tls_secret_name
}

View file

@ -1,215 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "frigate" {
metadata {
name = "frigate"
labels = {
tier = var.tier
}
# labels = {
# "istio-injection" : "enabled"
# }
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.frigate.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "frigate" {
metadata {
name = "frigate"
namespace = kubernetes_namespace.frigate.metadata[0].name
labels = {
app = "frigate"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1 # Temporarily disabled due to high power consumption
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "frigate"
}
}
template {
metadata {
labels = {
app = "frigate"
}
}
spec {
node_selector = {
"gpu" : true
}
toleration {
key = "nvidia.com/gpu"
operator = "Equal"
value = "true"
effect = "NoSchedule"
}
container {
# image = "ghcr.io/blakeblackshear/frigate:stable"
# image = "ghcr.io/blakeblackshear/frigate:stable-tensorrt"
image = "ghcr.io/blakeblackshear/frigate:0.17.0-beta1-tensorrt"
name = "frigate"
resources {
limits = {
"nvidia.com/gpu" = "1"
}
}
env {
name = "FRIGATE_RTSP_PASSWORD"
value = "password"
}
port {
container_port = 5000
}
port {
container_port = 8554
}
port {
container_port = 8555
protocol = "TCP"
}
port {
container_port = 8555
protocol = "UDP"
}
volume_mount {
name = "config"
mount_path = "/config"
}
volume_mount {
name = "dri"
mount_path = "/dev/dri"
}
volume_mount {
name = "dshm"
mount_path = "/dev/shm"
}
volume_mount {
name = "media"
mount_path = "/media/frigate"
}
security_context {
privileged = true
}
}
volume {
name = "config"
nfs {
path = "/mnt/main/frigate/config"
server = "10.0.10.15"
}
}
volume {
name = "dshm"
empty_dir {
medium = "Memory"
size_limit = "1Gi"
}
}
volume {
name = "media"
nfs {
path = "/mnt/main/frigate/media"
server = "10.0.10.15"
}
}
volume {
name = "dri"
host_path {
path = "/dev/dri"
type = "Directory"
}
}
}
}
}
}
resource "kubernetes_service" "frigate" {
metadata {
name = "frigate"
namespace = kubernetes_namespace.frigate.metadata[0].name
labels = {
"app" = "frigate"
}
}
spec {
selector = {
app = "frigate"
}
port {
name = "http"
target_port = 5000
port = 80
protocol = "TCP"
}
}
}
resource "kubernetes_service" "frigate-rtsp" {
metadata {
name = "frigate-rtsp"
namespace = kubernetes_namespace.frigate.metadata[0].name
labels = {
"app" = "frigate"
}
}
spec {
type = "NodePort" # Should always live on node1 where the gpu is
selector = {
app = "frigate"
}
port {
name = "rtsp-tcp"
target_port = 8554
port = 8554
protocol = "TCP"
node_port = 30554
}
port {
name = "rtsp-udp"
target_port = 8554
port = 8554
protocol = "UDP"
node_port = 30554
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.frigate.metadata[0].name
name = "frigate"
tls_secret_name = var.tls_secret_name
protected = true
rybbit_site_id = "0d4044069ff5"
}
module "ingress-internal" {
source = "../ingress_factory"
namespace = kubernetes_namespace.frigate.metadata[0].name
name = "frigate-lan"
host = "frigate-lan"
root_domain = "viktorbarzin.lan"
service_name = "frigate"
tls_secret_name = var.tls_secret_name
allow_local_access_only = true
ssl_redirect = false
}

View file

@ -1,270 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "smtp_password" { type = string }
resource "kubernetes_namespace" "grampsweb" {
metadata {
name = "grampsweb"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.grampsweb.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "random_password" "secret_key" {
length = 64
special = false
}
locals {
common_env = [
{
name = "GRAMPSWEB_TREE"
value = "Gramps Web"
},
{
name = "GRAMPSWEB_SECRET_KEY"
value = random_password.secret_key.result
},
{
name = "GRAMPSWEB_CELERY_CONFIG__broker_url"
value = "redis://redis.redis.svc.cluster.local:6379/2"
},
{
name = "GRAMPSWEB_CELERY_CONFIG__result_backend"
value = "redis://redis.redis.svc.cluster.local:6379/2"
},
{
name = "GRAMPSWEB_RATELIMIT_STORAGE_URI"
value = "redis://redis.redis.svc.cluster.local:6379/3"
},
{
name = "GRAMPSWEB_BASE_URL"
value = "https://family.viktorbarzin.me"
},
{
name = "GRAMPSWEB_REGISTRATION_DISABLED"
value = "True"
},
{
name = "GRAMPSWEB_EMAIL_HOST"
value = "mail.viktorbarzin.me"
},
{
name = "GRAMPSWEB_EMAIL_PORT"
value = "587"
},
{
name = "GRAMPSWEB_EMAIL_HOST_USER"
value = "info@viktorbarzin.me"
},
{
name = "GRAMPSWEB_EMAIL_HOST_PASSWORD"
value = var.smtp_password
},
{
name = "GRAMPSWEB_EMAIL_USE_SSL"
value = "False"
},
{
name = "GRAMPSWEB_EMAIL_USE_STARTTLS"
value = "True"
},
{
name = "GRAMPSWEB_DEFAULT_FROM_EMAIL"
value = "info@viktorbarzin.me"
},
{
name = "GRAMPSWEB_LLM_BASE_URL"
value = "http://ollama.ollama.svc.cluster.local:11434/v1"
},
{
name = "GRAMPSWEB_LLM_MODEL"
value = "llama3.1"
},
]
}
resource "kubernetes_deployment" "grampsweb" {
metadata {
name = "grampsweb"
namespace = kubernetes_namespace.grampsweb.metadata[0].name
labels = {
app = "grampsweb"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "grampsweb"
}
}
template {
metadata {
labels = {
app = "grampsweb"
}
}
spec {
container {
name = "grampsweb"
image = "ghcr.io/gramps-project/grampsweb:latest"
port {
container_port = 5000
}
dynamic "env" {
for_each = local.common_env
content {
name = env.value.name
value = env.value.value
}
}
volume_mount {
name = "data"
mount_path = "/app/users"
sub_path = "users"
}
volume_mount {
name = "data"
mount_path = "/app/indexdir"
sub_path = "indexdir"
}
volume_mount {
name = "data"
mount_path = "/app/thumbnail_cache"
sub_path = "thumbnail_cache"
}
volume_mount {
name = "data"
mount_path = "/app/cache"
sub_path = "cache"
}
volume_mount {
name = "data"
mount_path = "/app/secret"
sub_path = "secret"
}
volume_mount {
name = "data"
mount_path = "/root/.gramps/grampsdb"
sub_path = "grampsdb"
}
volume_mount {
name = "data"
mount_path = "/app/media"
sub_path = "media"
}
volume_mount {
name = "data"
mount_path = "/tmp"
sub_path = "tmp"
}
}
container {
name = "grampsweb-celery"
image = "ghcr.io/gramps-project/grampsweb:latest"
command = ["celery", "-A", "gramps_webapi.celery", "worker", "--loglevel=INFO", "--concurrency=2"]
dynamic "env" {
for_each = local.common_env
content {
name = env.value.name
value = env.value.value
}
}
volume_mount {
name = "data"
mount_path = "/app/users"
sub_path = "users"
}
volume_mount {
name = "data"
mount_path = "/app/indexdir"
sub_path = "indexdir"
}
volume_mount {
name = "data"
mount_path = "/app/thumbnail_cache"
sub_path = "thumbnail_cache"
}
volume_mount {
name = "data"
mount_path = "/app/cache"
sub_path = "cache"
}
volume_mount {
name = "data"
mount_path = "/app/secret"
sub_path = "secret"
}
volume_mount {
name = "data"
mount_path = "/root/.gramps/grampsdb"
sub_path = "grampsdb"
}
volume_mount {
name = "data"
mount_path = "/app/media"
sub_path = "media"
}
volume_mount {
name = "data"
mount_path = "/tmp"
sub_path = "tmp"
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/grampsweb"
}
}
}
}
}
}
resource "kubernetes_service" "grampsweb" {
metadata {
name = "grampsweb"
namespace = kubernetes_namespace.grampsweb.metadata[0].name
labels = {
app = "grampsweb"
}
}
spec {
selector = {
app = "grampsweb"
}
port {
name = "http"
port = 80
target_port = 5000
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.grampsweb.metadata[0].name
name = "family"
service_name = "grampsweb"
tls_secret_name = var.tls_secret_name
max_body_size = "500m"
}

View file

@ -1,154 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "hackmd_db_password" {}
resource "kubernetes_namespace" "hackmd" {
metadata {
name = "hackmd"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.hackmd.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "hackmd" {
metadata {
name = "hackmd"
namespace = kubernetes_namespace.hackmd.metadata[0].name
labels = {
app = "hackmd"
"kubernetes.io/cluster-service" = "true"
tier = var.tier
}
}
spec {
replicas = 1
strategy {
type = "RollingUpdate" # DB is external so we can roll
}
selector {
match_labels = {
app = "hackmd"
}
}
template {
metadata {
labels = {
app = "hackmd"
"kubernetes.io/cluster-service" = "true"
}
}
spec {
# container {
# image = "postgres:11.6-alpine"
# name = "postgres"
# image_pull_policy = "IfNotPresent"
# env {
# name = "POSTGRES_USER"
# value = "codimd"
# }
# env {
# name = "POSTGRES_PASSWORD"
# value = var.hackmd_db_password
# }
# env {
# name = "POSTGRES_DB"
# value = "codimd"
# }
# resources {
# limits = {
# cpu = "1"
# memory = "1Gi"
# }
# requests = {
# cpu = "1"
# memory = "1Gi"
# }
# }
# port {
# container_port = 80
# }
# volume_mount {
# name = "data"
# mount_path = "/var/lib/postgresql/data"
# sub_path = "postgres"
# }
# }
container {
name = "codimd"
image = "hackmdio/hackmd"
env {
name = "CMD_DB_URL"
# value = format("%s%s%s", "postgres://codimd:", var.hackmd_db_password, "@localhost/codimd")
value = format("%s%s%s", "mysql://codimd:", var.hackmd_db_password, "@mysql.dbaas/codimd")
}
env {
name = "CMD_USECDN"
value = "false"
}
volume_mount {
name = "data"
mount_path = "/home/hackmd/app/public/uploads"
sub_path = "hackmd"
}
port {
name = "http"
container_port = 3000
protocol = "TCP"
}
}
security_context {
fs_group = "1500"
}
volume {
name = "data"
nfs {
path = "/mnt/main/hackmd"
server = "10.0.10.15"
}
# iscsi {
# target_portal = "iscsi.viktorbarzin.lan:3260"
# fs_type = "ext4"
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:hackmd"
# lun = 0
# read_only = false
# }
}
}
}
}
}
resource "kubernetes_service" "hackmd" {
metadata {
name = "hackmd"
namespace = kubernetes_namespace.hackmd.metadata[0].name
labels = {
"app" = "hackmd"
}
}
spec {
selector = {
app = "hackmd"
}
port {
port = "80"
target_port = "3000"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.hackmd.metadata[0].name
name = "hackmd"
tls_secret_name = var.tls_secret_name
}

View file

@ -1,254 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "headscale_config" {}
variable "headscale_acl" {}
resource "kubernetes_namespace" "headscale" {
metadata {
name = "headscale"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.headscale.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "headscale" {
metadata {
name = "headscale"
namespace = kubernetes_namespace.headscale.metadata[0].name
labels = {
app = "headscale"
tier = var.tier
# scare to try but probably non-http will fail
# "istio-injection" : "enabled"
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "headscale"
}
}
template {
metadata {
labels = {
app = "headscale"
}
annotations = {
# "diun.enable" = "true"
"diun.enable" = "false"
"diun.include_tags" = "^\\d+(?:\\.\\d+)?(?:\\.\\d+)?$"
}
}
spec {
container {
image = "headscale/headscale:0.23.0"
# image = "headscale/headscale:0.23.0-debug" # -debug is for debug images
name = "headscale"
command = ["headscale", "serve"]
port {
container_port = 8080
}
port {
container_port = 9090
}
port {
container_port = 41641
}
volume_mount {
name = "config-volume"
mount_path = "/etc/headscale"
}
volume_mount {
mount_path = "/mnt"
name = "nfs-config"
}
}
volume {
name = "config-volume"
config_map {
name = "headscale-config"
items {
key = "config.yaml"
path = "config.yaml"
}
items {
key = "acl.yaml"
path = "acl.yaml"
}
}
}
volume {
name = "nfs-config"
nfs {
path = "/mnt/main/headscale"
server = "10.0.10.15"
}
}
# container {
# image = "simcu/headscale-ui:0.1.4"
# name = "headscale-ui"
# port {
# container_port = 80
# }
# }
container {
image = "ghcr.io/gurucomputing/headscale-ui:latest"
# image = "ghcr.io/tale/headplane:0.3.2"
name = "headscale-ui"
port {
container_port = 8081
# container_port = 3000
}
env {
name = "HTTP_PORT"
value = "8081"
}
# env {
# name = "HTTPS_PORT"
# value = "8082"
# }
env {
name = "HEADSCALE_URL"
value = "http://localhost:8080"
}
env {
name = "COOKIE_SECRET"
value = "kekekekke"
}
env {
name = "ROOT_API_KEY"
value = "kekekekeke"
}
}
}
}
}
}
resource "kubernetes_service" "headscale" {
metadata {
name = "headscale"
namespace = kubernetes_namespace.headscale.metadata[0].name
labels = {
"app" = "headscale"
}
annotations = {
"prometheus.io/scrape" = "true"
"prometheus.io/port" = "9090"
}
# annotations = {
# "metallb.universe.tf/allow-shared-ip" : "shared"
# }
}
spec {
# type = "LoadBalancer"
# external_traffic_policy = "Cluster"
selector = {
app = "headscale"
}
port {
name = "headscale"
port = "8080"
protocol = "TCP"
}
port {
name = "headscale-ui"
port = "80"
target_port = 8081
# target_port = 3000
protocol = "TCP"
}
port {
name = "metrics"
port = "9090"
protocol = "TCP"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.headscale.metadata[0].name
name = "headscale"
port = 8080
tls_secret_name = var.tls_secret_name
}
module "ingress-ui" {
source = "../ingress_factory"
namespace = kubernetes_namespace.headscale.metadata[0].name
name = "headscale-ui"
host = "headscale"
service_name = "headscale"
port = 8081
ingress_path = ["/web"]
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_service" "headscale-server" {
metadata {
name = "headscale-server"
namespace = kubernetes_namespace.headscale.metadata[0].name
labels = {
"app" = "headscale"
}
annotations = {
"metallb.universe.tf/allow-shared-ip" : "shared"
}
}
spec {
type = "LoadBalancer"
external_traffic_policy = "Cluster"
selector = {
app = "headscale"
}
# port {
# name = "headscale-tcp"
# port = "41641"
# protocol = "TCP"
# }
port {
name = "headscale-udp"
port = "41641"
protocol = "UDP"
}
}
}
resource "kubernetes_config_map" "headscale-config" {
metadata {
name = "headscale-config"
namespace = kubernetes_namespace.headscale.metadata[0].name
annotations = {
"reloader.stakater.com/match" = "true"
}
}
data = {
"config.yaml" = var.headscale_config
"acl.yaml" = var.headscale_acl
}
}

View file

@ -1,132 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "postgresql_password" {}
variable "secret_key" { type = string }
resource "kubernetes_namespace" "health" {
metadata {
name = "health"
labels = {
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.health.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "health" {
metadata {
name = "health"
namespace = kubernetes_namespace.health.metadata[0].name
labels = {
app = "health"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "health"
}
}
template {
metadata {
labels = {
app = "health"
}
}
spec {
container {
name = "health"
image = "viktorbarzin/health:latest"
port {
container_port = 3000
}
env {
name = "DATABASE_URL"
value = "postgresql+asyncpg://health:${var.postgresql_password}@postgresql.dbaas.svc.cluster.local:5432/health"
}
env {
name = "SECRET_KEY"
value = var.secret_key
}
env {
name = "UPLOAD_DIR"
value = "/data/uploads"
}
env {
name = "WEBAUTHN_RP_ID"
value = "health.viktorbarzin.me"
}
env {
name = "WEBAUTHN_ORIGIN"
value = "https://health.viktorbarzin.me"
}
env {
name = "COOKIE_SECURE"
value = "true"
}
volume_mount {
name = "uploads"
mount_path = "/data/uploads"
}
resources {
requests = {
memory = "256Mi"
cpu = "100m"
}
limits = {
memory = "1Gi"
cpu = "1"
}
}
}
volume {
name = "uploads"
nfs {
server = "10.0.10.15"
path = "/mnt/main/health"
}
}
}
}
}
}
resource "kubernetes_service" "health" {
metadata {
name = "health"
namespace = kubernetes_namespace.health.metadata[0].name
labels = {
app = "health"
}
}
spec {
selector = {
app = "health"
}
port {
name = "http"
port = 80
target_port = 3000
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.health.metadata[0].name
name = "health"
tls_secret_name = var.tls_secret_name
max_body_size = "100m"
}

View file

@ -1,30 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.homepage.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "homepage" {
metadata {
name = "homepage"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
resource "helm_release" "homepage" {
namespace = kubernetes_namespace.homepage.metadata[0].name
create_namespace = false
name = "homepage"
atomic = true
repository = "http://jameswynn.github.io/helm-charts"
chart = "homepage"
values = [templatefile("${path.module}/values.yaml", { tls_secret_name = var.tls_secret_name })]
}

View file

@ -1,155 +0,0 @@
image:
repository: ghcr.io/gethomepage/homepage
tag: v1.8.0
# Enable RBAC. RBAC is necessary to use Kubernetes integration
enableRbac: true
extraClusterRoles:
# - apiGroups:
# - some-group
# resources:
# - some-resource
# verbs:
# - get
serviceAccount:
# Specify a different service account name. When blank it will default to the release
# name if *create* is enabled, otherwise it will refer to the default service account.
name: ""
# Create service account. Needed when RBAC is enabled.
create: false
service:
main:
ports:
http:
port: 3000
controller:
strategy: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
# Enable the ingress to expose Homepage to the network.
ingress:
main:
enabled: true
labels:
# This label will enable discover of this deployment in Homepage
gethomepage.dev/enabled: "true"
annotations:
# These annotations will configure how this deployment is shown in Homepage
gethomepage.dev/name: "Homepage"
gethomepage.dev/description: "A modern, secure, highly customizable application dashboard."
gethomepage.dev/group: "A New Group"
gethomepage.dev/icon: "homepage.png"
ingressClassName: "traefik"
hosts:
- host: &host "home.viktorbarzin.me"
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- *host
secretName: ${tls_secret_name}
# All the config files for Homepage can be specified under their relevant config block.
config:
# To use an existing ConfigMap uncomment this line and specify the name
# useExistingConfigMap: existing-homepage-configmap
bookmarks:
- Developer:
- Github:
- abbr: Viktor Barzin
href: https://github.com/viktorbarzin
services:
# - My First Group:
# - My First Service:
# href: http://localhost/
# description: Homepage is awesome
# - My Second Group:
# - My Second Service:
# href: http://localhost/
# description: Homepage is the best
# - My Third Group:
# - My Third Service:
# href: http://localhost/
# description: Homepage is 😎
widgets:
- resources:
# change backend to 'kubernetes' to use Kubernetes integration. Requires RBAC.
# backend: resources
backend: kubernetes
expanded: true
cpu: true
memory: true
- search:
provider: duckduckgo
target: _blank
## Uncomment to enable Kubernetes integration
- kubernetes:
cluster:
show: true
cpu: true
memory: true
showLabel: true
label: "cluster"
nodes:
show: true
cpu: true
memory: true
showLabel: true
kubernetes:
# change mode to 'cluster' to use RBAC service account
# mode: disable
mode: cluster
docker:
settings:
# -- Main environment variables. Template enabled.
# Syntax options:
# A) TZ: UTC
# B) PASSWD: '{{ .Release.Name }}'
# C) PASSWD:
# configMapKeyRef:
# name: config-map-name
# key: key-name
# D) PASSWD:
# valueFrom:
# secretKeyRef:
# name: secret-name
# key: key-name
# ...
# E) - name: TZ
# value: UTC
# F) - name: TZ
# value: '{{ .Release.Name }}'
env:
HOMEPAGE_ALLOWED_HOSTS: home.viktorbarzin.me
# To include environment variables from other configs or other secrets for use in
# Homepage's variable substitutions. Refer to them here.
# envFrom:
# - secretRef:
# name: my-secret
# - configMapRef:
# name: my-configmap
persistence:
logs:
enabled: true
type: emptyDir
mountPath: /app/config/logs
# resources:
# requests:
# memory: 10Mi
# cpu: 10m
# limits:
# memory: 200Mi
# cpu: 500m

View file

@ -1,75 +0,0 @@
## This chart relies on the common library chart from bjw-s
## You can find it at https://github.com/bjw-s/helm-charts/tree/main/charts/library/common
## Refer there for more detail about the supported values
# These entries are shared between all the Immich components
defaultPodOptions:
annotations:
diun.enable: "true"
env:
# REDIS_HOSTNAME: '{{ printf "%s-redis-master" .Release.Name }}'
REDIS_HOSTNAME: "redis.redis.svc.cluster.local"
# DB_HOSTNAME: "postgresql.dbaas"
# DB_USERNAME: "immich"
# DB_DATABASE_NAME: "immich"
# # # -- You should provide your own secret outside of this helm-chart and use `postgresql.global.postgresql.auth.existingSecret` to provide credentials to the postgresql instance
# DB_PASSWORD: "${postgresql_password}"
# TYPESENSE_ENABLED: "{{ .Values.typesense.enabled }}"
# TYPESENSE_ENABLED: "1"
# TYPESENSE_API_KEY: "{{ .Values.typesense.env.TYPESENSE_API_KEY }}"
# TYPESENSE_HOST: '{{ printf "%s-typesense" .Release.Name }}'
# IMMICH_WEB_URL: '{{ printf "http://%s-web:3000" .Release.Name }}'
# IMMICH_WEB_URL: "http://immich-web.immich.svc.cluster.local:3000"
# IMMICH_WEB_URL: "http://immich-server.immich.svc.cluster.local:3001"
# IMMICH_SERVER_URL: '{{ printf "http://%s-server:3001" .Release.Name }}'
# IMMICH_SERVER_URL: "http://immich-server.immich.svc.cluster.local:3001"
# IMMICH_SERVER_URL: "http://immich-server.immich.svc.cluster.local:2283"
# IMMICH_MACHINE_LEARNING_URL: '{{ printf "http://%s-machine-learning:3003" .Release.Name }}'
# IMMICH_MACHINE_LEARNING_URL: "http://immich-machine-learning.immich.svc.cluster.local:3003"
image:
tag: ${version}
immich:
persistence:
# Main data store for all photos shared between different components.
library:
# Automatically creating the library volume is not supported by this chart
# You have to specify an existing PVC to use
existingClaim: immich
redis:
enabled: false
architecture: standalone
auth:
enabled: false
# Immich components
server:
enabled: true
image:
repository: ghcr.io/immich-app/immich-server
pullPolicy: IfNotPresent
# increase liveliness and readiness checks to allow enough time for downloading models
machine-learning:
# enabled: true
enabled: false
image:
repository: ghcr.io/immich-app/immich-machine-learning
pullPolicy: IfNotPresent
env:
TRANSFORMERS_CACHE: /cache
# MACHINE_LEARNING_PRELOAD__CLIP: immich-app/ViT-H-14-378-quickgelu__dfn5b # too big(?)
# MACHINE_LEARNING_PRELOAD__CLIP: immich-app/ViT-L-16-SigLIP-384__webli # too big(?)
#MACHINE_LEARNING_PRELOAD__CLIP: ViT-B-32__openai # too big(?)
MACHINE_LEARNING_PRELOAD__CLIP: ViT-B-16-SigLIP2__webli
persistence:
cache:
enabled: true
size: 10Gi
# Optional: Set this to pvc to avoid downloading the ML models every start.
type: emptyDir
accessMode: ReadWriteMany

View file

@ -1,119 +0,0 @@
variable "frame_api_key" {
type = string
}
resource "kubernetes_config_map" "mailserver_config" {
metadata {
name = "config"
namespace = "immich"
labels = {
app = "frame-config"
}
annotations = {
"reloader.stakater.com/match" = "true"
}
}
data = {
# Actual mail settings
"Settings.yml" = <<-EOF
General:
Layout: single
Interval: 10
ImageZoom: false
ShowAlbumName: false
ShowProgressBar: false
Accounts:
- ImmichServerUrl: http://immich.viktorbarzin.me
ApiKey: ${var.frame_api_key}
Albums:
- 1aa98849-bbd5-452b-aac0-310b210a8597 # china
EOF
}
}
resource "kubernetes_deployment" "immich-frame" {
metadata {
name = "immich-frame"
namespace = "immich"
annotations = {
"reloader.stakater.com/search" = "true"
}
labels = {
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "immich-frame"
}
}
strategy {
type = "RollingUpdate"
}
template {
metadata {
labels = {
app = "immich-frame"
}
}
spec {
container {
image = "ghcr.io/immichframe/immichframe:latest"
name = "immich-frame"
port {
container_port = 8080
protocol = "TCP"
name = "http"
}
volume_mount {
name = "config"
mount_path = "/app/Config"
read_only = true
}
}
volume {
name = "config"
config_map {
name = "config"
}
}
}
}
}
}
resource "kubernetes_service" "immich-frame" {
metadata {
name = "immich-frame"
namespace = "immich"
labels = {
"app" = "immich-frame"
}
}
spec {
selector = {
app = "immich-frame"
}
port {
port = 80
target_port = 8080
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = "immich"
name = "highlights-immich"
tls_secret_name = var.tls_secret_name
service_name = "immich-frame"
rybbit_site_id = "602167601c6b"
}

View file

@ -1,650 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "postgresql_password" {}
variable "homepage_token" {}
variable "immich_version" {
type = string
# Change me to upgrade
default = "v2.5.6"
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.immich.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_namespace" "immich" {
metadata {
name = "immich"
labels = {
tier = var.tier
}
}
}
resource "kubernetes_deployment" "immich_server" {
metadata {
name = "immich-server"
namespace = kubernetes_namespace.immich.metadata[0].name
labels = {
app = "immich-server"
tier = var.tier
}
}
spec {
replicas = 1
progress_deadline_seconds = 600
selector {
match_labels = {
app = "immich-server"
}
}
strategy {
type = "RollingUpdate"
}
template {
metadata {
labels = {
app = "immich-server"
}
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^\\d+\\.\\d+\\.\\d+$"
}
}
spec {
container {
name = "immich-server"
image = "ghcr.io/immich-app/immich-server:${var.immich_version}"
port {
name = "http"
container_port = 2283
protocol = "TCP"
}
env {
name = "DB_DATABASE_NAME"
value = "immich"
}
env {
name = "DB_HOSTNAME"
value = "immich-postgresql.immich.svc.cluster.local"
}
env {
name = "DB_USERNAME"
value = "immich"
}
env {
name = "DB_PASSWORD"
value = var.postgresql_password
}
env {
name = "IMMICH_MACHINE_LEARNING_URL"
value = "http://immich-machine-learning:3003"
}
env {
name = "REDIS_HOSTNAME"
value = "redis.redis.svc.cluster.local"
}
liveness_probe {
http_get {
path = "/api/server/ping"
port = "http"
}
initial_delay_seconds = 0
period_seconds = 10
timeout_seconds = 1
failure_threshold = 3
success_threshold = 1
}
readiness_probe {
http_get {
path = "/api/server/ping"
port = "http"
}
period_seconds = 10
timeout_seconds = 1
failure_threshold = 3
success_threshold = 1
}
startup_probe {
http_get {
path = "/api/server/ping"
port = "http"
}
period_seconds = 10
timeout_seconds = 1
failure_threshold = 30
success_threshold = 1
}
# volume_mount {
# name = "library-old"
# mount_path = "/usr/src/app/upload"
# }
# Mount them 1 by 1 to enable thumbs in ssd
volume_mount {
name = "backups"
mount_path = "/usr/src/app/upload/backups"
}
volume_mount {
name = "encoded-video"
mount_path = "/usr/src/app/upload/encoded-video"
}
volume_mount {
name = "library"
mount_path = "/usr/src/app/upload/library"
}
volume_mount {
name = "profile"
mount_path = "/usr/src/app/upload/profile"
}
volume_mount {
name = "thumbs"
mount_path = "/usr/src/app/upload/thumbs"
}
volume_mount {
name = "upload"
mount_path = "/usr/src/app/upload/upload"
}
}
# volume {
# name = "library-old"
# nfs {
# server = "10.0.10.15"
# path = "/mnt/main/immich/immich/"
# }
# }
volume {
name = "backups"
nfs {
server = "10.0.10.15"
path = "/mnt/main/immich/immich/backups"
}
}
volume {
name = "encoded-video"
nfs {
server = "10.0.10.15"
path = "/mnt/main/immich/immich/encoded-video"
}
}
volume {
name = "library"
nfs {
server = "10.0.10.15"
path = "/mnt/main/immich/immich/library"
}
}
volume {
name = "profile"
nfs {
server = "10.0.10.15"
path = "/mnt/main/immich/immich/profile"
}
}
volume {
name = "thumbs"
nfs {
server = "10.0.10.15"
path = "/mnt/ssd/immich/thumbs"
}
}
volume {
name = "upload"
nfs {
server = "10.0.10.15"
path = "/mnt/main/immich/immich/upload"
}
}
}
}
}
}
resource "kubernetes_service" "immich-server" {
metadata {
name = "immich-server"
namespace = kubernetes_namespace.immich.metadata[0].name
labels = {
"app" = "immich-server"
}
}
spec {
selector = {
app = "immich-server"
}
port {
port = 2283
}
}
}
resource "kubernetes_deployment" "immich-postgres" {
metadata {
name = "immich-postgresql"
namespace = kubernetes_namespace.immich.metadata[0].name
labels = {
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "immich-postgresql"
}
}
strategy {
type = "Recreate"
}
template {
metadata {
labels = {
app = "immich-postgresql"
}
}
spec {
container {
image = "ghcr.io/immich-app/postgres:15-vectorchord0.3.0-pgvectors0.2.0"
name = "immich-postgresql"
port {
container_port = 5432
protocol = "TCP"
name = "postgresql"
}
env {
name = "POSTGRES_PASSWORD"
value = var.postgresql_password
}
env {
name = "POSTGRES_USER"
value = "immich"
}
env {
name = "POSTGRES_DB"
value = "immich"
}
env {
name = "DB_STORAGE_TYPE"
value = "HDD"
}
volume_mount {
name = "postgresql-persistent-storage"
mount_path = "/var/lib/postgresql/data"
}
}
volume {
name = "postgresql-persistent-storage"
nfs {
path = "/mnt/main/immich/data-immich-postgresql"
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "immich-postgresql" {
metadata {
name = "immich-postgresql"
namespace = kubernetes_namespace.immich.metadata[0].name
labels = {
"app" = "immich-postgresql"
}
}
spec {
selector = {
app = "immich-postgresql"
}
port {
port = 5432
}
}
}
# If you're having issuewith typesens container exiting prematurely, increase liveliness check
# resource "helm_release" "immich" {
# namespace = kubernetes_namespace.immich.metadata[0].name
# name = "immich"
# repository = "https://immich-app.github.io/immich-charts"
# chart = "immich"
# atomic = true
# version = "0.9.3"
# timeout = 6000
# values = [templatefile("${path.module}/chart_values.tpl", { postgresql_password = var.postgresql_password, version = var.immich_version })]
# }
# The helm one cannot be customized to use affinity settings to use the gpu node
resource "kubernetes_deployment" "immich-machine-learning" {
metadata {
name = "immich-machine-learning"
namespace = kubernetes_namespace.immich.metadata[0].name
labels = {
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "immich-machine-learning"
}
}
strategy {
type = "RollingUpdate"
}
template {
metadata {
labels = {
app = "immich-machine-learning"
}
}
spec {
node_selector = {
"gpu" : "true"
}
toleration {
key = "nvidia.com/gpu"
operator = "Equal"
value = "true"
effect = "NoSchedule"
}
container {
# image = "ghcr.io/immich-app/immich-machine-learning:${var.immich_version}"
image = "ghcr.io/immich-app/immich-machine-learning:${var.immich_version}-cuda"
name = "immich-machine-learning"
port {
container_port = 3003
protocol = "TCP"
name = "immich-ml"
}
env {
name = "MACHINE_LEARNING_MODEL_TTL"
value = "0"
}
env {
name = "TRANSFORMERS_CACHE"
value = "/cache"
}
env {
name = "HF_XET_CACHE"
value = "/cache/huggingface-xet"
}
env {
name = "MPLCONFIGDIR"
value = "/cache/matplotlib-config"
}
# Preload CLIP models (for smart search)
env {
name = "MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"
value = "ViT-B-16-SigLIP2__webli"
}
env {
name = "MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"
value = "ViT-B-16-SigLIP2__webli"
}
# Preload facial recognition models
env {
name = "MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"
value = "buffalo_l"
}
env {
name = "MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"
value = "buffalo_l"
}
volume_mount {
name = "cache"
mount_path = "/cache"
}
resources {
limits = {
"nvidia.com/gpu" = "1" # Used for inference
}
}
}
volume {
name = "cache"
nfs {
# path = "/mnt/main/immich/machine-learning"
path = "/mnt/ssd/immich/machine-learning" # load cache from ssd
server = "10.0.10.15"
}
}
}
}
}
}
resource "kubernetes_service" "immich-machine-learning" {
metadata {
name = "immich-machine-learning"
namespace = kubernetes_namespace.immich.metadata[0].name
labels = {
"app" = "immich-machine-learning"
}
}
spec {
selector = {
app = "immich-machine-learning"
}
port {
port = 3003
}
}
}
module "ingress-immich" {
source = "../ingress_factory"
namespace = kubernetes_namespace.immich.metadata[0].name
name = "immich"
service_name = "immich-server"
port = 2283
tls_secret_name = var.tls_secret_name
rybbit_site_id = "35eedb7a3d2b"
skip_default_rate_limit = true
extra_middlewares = ["traefik-immich-rate-limit@kubernetescrd"]
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/description" = "Photos library"
"gethomepage.dev/icon" = "immich.png"
"gethomepage.dev/name" = "Immich"
"gethomepage.dev/widget.type" = "immich"
"gethomepage.dev/widget.url" = "https://immich.viktorbarzin.me"
"gethomepage.dev/pod-selector" = ""
"gethomepage.dev/widget.key" = var.homepage_token
}
}
resource "kubernetes_cron_job_v1" "postgresql-backup" {
metadata {
name = "postgresql-backup"
namespace = kubernetes_namespace.immich.metadata[0].name
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 5
schedule = "0 0 * * *"
# schedule = "* * * * *"
starting_deadline_seconds = 10
successful_jobs_history_limit = 10
job_template {
metadata {}
spec {
backoff_limit = 3
ttl_seconds_after_finished = 10
template {
metadata {}
spec {
container {
name = "postgresql-backup"
image = "postgres:16.4-bullseye"
command = ["/bin/sh", "-c", <<-EOT
export now=$(date +"%Y_%m_%d_%H_%M")
PGPASSWORD=${var.postgresql_password} pg_dumpall -h immich-postgresql -U immich > /backup/dump_$now.sql
# Rotate - delete last log file
cd /backup
find . -name "dump_*.sql" -type f -mtime +14 -delete # 14 day retention of backups
EOT
]
volume_mount {
name = "postgresql-backup"
mount_path = "/backup"
}
}
volume {
name = "postgresql-backup"
nfs {
path = "/mnt/main/immich/data-immich-postgresql"
server = "10.0.10.15"
}
}
}
}
}
}
}
}
# POWER TOOLS
# resource "kubernetes_deployment" "powertools" {
# metadata {
# name = "immich-powertools"
# namespace = kubernetes_namespace.immich.metadata[0].name
# labels = {
# app = "immich-powertools"
# }
# annotations = {
# "reloader.stakater.com/search" = "true"
# }
# }
# spec {
# replicas = 1
# strategy {
# type = "Recreate"
# }
# selector {
# match_labels = {
# app = "immich-powertools"
# }
# }
# template {
# metadata {
# labels = {
# app = "immich-powertools"
# }
# annotations = {
# "diun.enable" = "true"
# "diun.include_tags" = "latest"
# }
# }
# spec {
# container {
# image = "ghcr.io/varun-raj/immich-power-tools:latest"
# name = "owntracks"
# port {
# name = "http"
# container_port = 3000
# }
# env {
# name = "IMMICH_API_KEY"
# value = "<change me>"
# }
# env {
# name = "IMMICH_URL"
# value = "http://immich-server.immich.svc.cluster.local"
# }
# env {
# name = "EXTERNAL_IMMICH_URL"
# value = "https://immich.viktorbarzin.me"
# }
# env {
# name = "DB_USERNAME"
# value = "immich"
# }
# env {
# name = "DB_PASSWORD"
# value = var.postgresql_password
# }
# env {
# name = "DB_HOST"
# value = "immich-postgresql.immich.svc.cluster.local"
# }
# # env {
# # name = "DB_PORT"
# # value = "5432"
# # }
# env {
# name = "DB_DATABASE_NAME"
# value = "immich"
# }
# env {
# name = "NODE_ENV"
# value = "development"
# }
# }
# }
# }
# }
# }
# resource "kubernetes_service" "powertools" {
# metadata {
# name = "immich-powertools"
# namespace = kubernetes_namespace.immich.metadata[0].name
# labels = {
# "app" = "immich-powertools"
# }
# }
# spec {
# selector = {
# app = "immich-powertools"
# }
# port {
# name = "http"
# port = 80
# target_port = 3000
# protocol = "TCP"
# }
# }
# }
# module "ingress-powertools" {
# source = "../ingress_factory"
# namespace = kubernetes_namespace.immich.metadata[0].name
# name = "immich-powertools"
# tls_secret_name = var.tls_secret_name
# protected = true
# }

View file

@ -1,212 +0,0 @@
# Module to run some infra-specific things like updating the public ip
variable "git_user" {}
variable "git_token" {}
variable "technitium_username" {}
variable "technitium_password" {}
# DISABLED WHILST USING CLOUDFLARE NS
# resource "kubernetes_cron_job_v1" "update-public-ip" {
# metadata {
# name = "update-public-ip"
# namespace = "default"
# }
# spec {
# schedule = "*/5 * * * *"
# successful_jobs_history_limit = 1
# failed_jobs_history_limit = 1
# concurrency_policy = "Forbid"
# job_template {
# metadata {
# name = "update-public-ip"
# }
# spec {
# template {
# metadata {
# name = "update-public-ip"
# }
# spec {
# priority_class_name = "system-cluster-critical"
# container {
# name = "update-public-ip"
# image = "viktorbarzin/infra"
# command = ["./infra_cli"]
# args = ["-use-case", "update-public-ip"]
# env {
# name = "GIT_USER"
# value = var.git_user
# }
# env {
# name = "GIT_TOKEN"
# value = var.git_token
# }
# env {
# name = "TECHNITIUM_USERNAME"
# value = var.technitium_username
# }
# env {
# name = "TECHNITIUM_PASSWORD"
# value = var.technitium_password
# }
# }
# restart_policy = "Never"
# # service_account_name = "descheduler-sa"
# # volume {
# # name = "policy-volume"
# # config_map {
# # name = "policy-configmap"
# # }
# # }
# }
# }
# }
# }
# }
# }
# # backup etcd
resource "kubernetes_cron_job_v1" "backup-etcd" {
metadata {
name = "backup-etcd"
namespace = "default"
}
spec {
schedule = "0 0 * * *"
successful_jobs_history_limit = 1
failed_jobs_history_limit = 1
concurrency_policy = "Forbid"
job_template {
metadata {
name = "backup-etcd"
}
spec {
template {
metadata {
name = "backup-etcd"
}
spec {
node_name = "k8s-master"
priority_class_name = "system-cluster-critical"
host_network = true
container {
name = "backup-etcd"
image = "k8s.gcr.io/etcd-amd64:3.3.15"
command = ["/bin/sh"]
args = ["-c", "etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt --key=/etc/kubernetes/pki/etcd/healthcheck-client.key snapshot save /backup/etcd-snapshot-$(date +%Y_%m_%d_%H:%M:%S_%Z).db"]
env {
name = "ETCDCTL_API"
value = "3"
}
volume_mount {
mount_path = "/backup"
name = "backup"
}
volume_mount {
mount_path = "/etc/kubernetes/pki/etcd"
name = "etcd-certs"
read_only = true
}
}
container {
name = "backup-purge"
image = "busybox:1.31.1"
command = ["/bin/sh"]
args = ["-c", "find /backup -type f -mtime +30 -name '*.db' -exec rm -- '{}' \\;"]
volume_mount {
mount_path = "/backup"
name = "backup"
}
}
volume {
name = "backup"
nfs {
path = "/mnt/main/etcd-backup"
server = "10.0.10.15"
}
}
volume {
name = "etcd-certs"
host_path {
path = "/etc/kubernetes/pki/etcd"
type = "DirectoryOrCreate"
}
}
restart_policy = "Never"
}
}
}
}
}
}
# Clean up evicted/failed pods cluster-wide daily
resource "kubernetes_cron_job_v1" "cleanup-failed-pods" {
metadata {
name = "cleanup-failed-pods"
namespace = "default"
}
spec {
schedule = "0 2 * * *"
successful_jobs_history_limit = 1
failed_jobs_history_limit = 1
concurrency_policy = "Forbid"
job_template {
metadata {
name = "cleanup-failed-pods"
}
spec {
template {
metadata {
name = "cleanup-failed-pods"
}
spec {
service_account_name = kubernetes_service_account.cleanup_sa.metadata[0].name
container {
name = "cleanup"
image = "bitnami/kubectl:latest"
command = ["/bin/sh", "-c", "kubectl delete pods -A --field-selector=status.phase=Failed --ignore-not-found"]
}
restart_policy = "Never"
}
}
}
}
}
}
resource "kubernetes_service_account" "cleanup_sa" {
metadata {
name = "failed-pod-cleanup"
namespace = "default"
}
}
resource "kubernetes_cluster_role" "cleanup_role" {
metadata {
name = "failed-pod-cleanup"
}
rule {
api_groups = [""]
resources = ["pods"]
verbs = ["list", "delete"]
}
}
resource "kubernetes_cluster_role_binding" "cleanup_binding" {
metadata {
name = "failed-pod-cleanup"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = kubernetes_cluster_role.cleanup_role.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.cleanup_sa.metadata[0].name
namespace = "default"
}
}

View file

@ -1,59 +0,0 @@
# https://github.com/dmunozv04/iSponsorBlockTV
variable "tier" { type = string }
resource "kubernetes_namespace" "isponsorblocktv" {
metadata {
name = "isponsorblocktv"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
# Before running, setup config using
# docker run --rm -it -v ./youtube:/app/data -e TERM=$TERM -e COLORTERM=$COLORTERM ghcr.io/dmunozv04/isponsorblocktv --setup
# Mute and skip ads for vermont smart tv
resource "kubernetes_deployment" "isponsorblocktv-vermont" {
metadata {
name = "isponsorblocktv-vermont"
namespace = kubernetes_namespace.isponsorblocktv.metadata[0].name
labels = {
app = "isponsorblocktv-vermont"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "isponsorblocktv-vermont"
}
}
template {
metadata {
labels = {
app = "isponsorblocktv-vermont"
}
}
spec {
container {
image = "ghcr.io/dmunozv04/isponsorblocktv"
name = "isponsorblocktv-vermont"
volume_mount {
name = "data"
mount_path = "/app/data"
}
}
volume {
name = "data"
nfs {
server = "10.0.10.15"
path = "/mnt/main/isponsorblocktv/vermont"
}
}
}
}
}
}

View file

@ -1,81 +0,0 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
resource "kubernetes_namespace" "jsoncrack" {
metadata {
name = "jsoncrack"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.jsoncrack.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "kubernetes_deployment" "jsoncrack" {
metadata {
name = "jsoncrack"
namespace = kubernetes_namespace.jsoncrack.metadata[0].name
labels = {
app = "jsoncrack"
tier = var.tier
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "jsoncrack"
}
}
template {
metadata {
labels = {
app = "jsoncrack"
}
}
spec {
container {
image = "viktorbarzin/jsoncrack:latest"
name = "jsoncrack"
port {
container_port = 8080
}
}
}
}
}
}
resource "kubernetes_service" "jsoncrack" {
metadata {
name = "json"
namespace = kubernetes_namespace.jsoncrack.metadata[0].name
labels = {
"app" = "jsoncrack"
}
}
spec {
selector = {
app = "jsoncrack"
}
port {
name = "http"
target_port = 8080
port = 80
protocol = "TCP"
}
}
}
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.jsoncrack.metadata[0].name
name = "json"
tls_secret_name = var.tls_secret_name
}

View file

@ -1,238 +0,0 @@
variable "tls_secret_name" {}
variable "client_certificate_secret_name" {}
variable "tier" { type = string }
resource "random_password" "csrf_token" {
length = 16
special = true
override_special = "_%@"
}
# instructions on deploying:
# https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/#accessing-the-dashboard-ui
# module "dashboard" {
# # source = "cookielab/dashboard/kubernetes"
# source = "ViktorBarzin/dashboard/kubernetes"
# version = "0.13.2"
# kubernetes_dashboard_csrf = random_password.csrf_token.result
# kubernetes_dashboard_deployment_args = tolist([
# "--auto-generate-certificates",
# "--token-ttl=0"
# ])
# }
resource "kubernetes_namespace" "k8s-dashboard" {
metadata {
name = "kubernetes-dashboard"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
# }
module "tls_secret" {
source = "../setup_tls_secret"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "helm_release" "kubernetes-dashboard" {
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
name = "kubernetes-dashboard"
repository = "https://kubernetes.github.io/dashboard/"
chart = "kubernetes-dashboard"
atomic = true
version = "7.12.0"
# values = [templatefile("${path.module}/chart_values.tpl", { postgresql_password = var.postgresql_password })]
}
# # locals {
# # resources = split("---\n", file("${path.module}/recommended.yaml"))
# # }
# # resource "k8s_manifest" "kubernetes-dashboard-manifests" {
# # count = length(local.resources) - 1
# # # count = 2
# # # content = local.resources[1 + count.index]
# # # content = file("${path.module}/recommended.yaml")
# # content = local.resources[1]
# # depends_on = [kubernetes_namespace.kubernetes-dashboard]
# # }
# resource "kubectl_manifest" "kubernetes-dashboard-manifests" {
# yaml_body = file("${path.module}/recommended.yaml")
# force_new = true
# depends_on = [kubernetes_namespace.kubernetes-dashboard]
# }
# resource "kubernetes_secret" "dashboard-token" {
# metadata {
# name = "dashboard-secret"
# namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
# annotations = {
# "kubernetes.io/service-account.name" : "kubernetes-dashboard"
# }
# }
# type = "kubernetes.io/service-account-token"
# }
module "ingress" {
source = "../ingress_factory"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
name = "kubernetes-dashboard"
service_name = "kubernetes-dashboard-kong-proxy"
host = "k8s"
tls_secret_name = var.tls_secret_name
protected = true
backend_protocol = "HTTPS"
port = 443
}
# create token with
# kb create token --duration=0s kubernetes-dashboard
resource "kubernetes_service_account" "kubernetes-dashboard" {
metadata {
name = "kubernetes-dashboard"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
}
}
# Give cluster-admin permissions to dashboard
resource "kubernetes_cluster_role_binding" "kubernetes-dashboard" {
metadata {
name = "admin-user"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "cluster-admin"
}
subject {
kind = "ServiceAccount"
name = "kubernetes-dashboard"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
}
# depends_on = [module.dashboard]
}
resource "kubernetes_secret" "kubernetes-dashboard-admin-token" {
metadata {
name = "kubernetes-dashboard-admin"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
annotations = {
"kubernetes.io/service-account.name" : "kubernetes-dashboard"
}
}
type = "kubernetes.io/service-account-token"
}
## Readonly RBAC
resource "kubernetes_cluster_role" "kubernetes-dashboard-viewonly" {
metadata {
name = "kubernetes-dashboard-viewonly"
}
rule {
api_groups = [""]
resources = ["configmaps", "endpoints", "persistentvolumeclaims", "pods", "replicationcontrollers", "replicationcontrollers/scale", "serviceaccounts", "services", "nodes", "persistentvolumeclaims", "persistentvolumes"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = [""]
resources = ["bindings", "events", "limitranges", "namespaces/status", "pods/log", "pods/status", "replicationcontrollers/status", "resourcequotas", "resourcequotas/status"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = [""]
resources = ["namespaces"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["apps"]
resources = ["daemonsets", "deployments", "deployments/scale", "replicasets", "replicasets/scale", "statefulsets"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["autoscaling"]
resources = ["horizontalpodautoscalers"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["batch"]
resources = ["cronjobs", "jobs"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["extensions"]
resources = ["daemonsets", "deployments", "deployments/scale", "ingresses", "networkpolicies", "replicasets", "replicasets/scale", "replicationcontrollers/scale"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["policy"]
resources = ["poddisruptionbudgets"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["networking.k8s.io"]
resources = ["networkpolicies"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["storage.k8s.io"]
resources = ["storageclasses", "volumeattachments"]
verbs = ["get", "list", "watch"]
}
rule {
api_groups = ["rbac.authorization.k8s.io"]
resources = ["clusterrolebindings", "clusterroles", "roles", "rolebindings"]
verbs = ["get", "list", "watch"]
}
}
resource "kubernetes_cluster_role_binding" "kubernetes-dashboard-viewonly" {
metadata {
name = "kubernetes-dashboard-viewonly"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "kubernetes-dashboard-viewonly"
}
subject {
kind = "ServiceAccount"
name = "kubernetes-dashboard-viewonly"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
}
}
resource "kubernetes_service_account" "kubernetes-dashboard-viewonly" {
metadata {
name = "kubernetes-dashboard-viewonly"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
}
}
resource "kubernetes_secret" "kubernetes-dashboard-viewonly-token" {
metadata {
name = "kubernetes-dashboard-viewonly"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
annotations = {
"kubernetes.io/service-account.name" : "kubernetes-dashboard-viewonly"
}
}
type = "kubernetes.io/service-account-token"
}

Some files were not shown because too many files have changed in this diff Show more