[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:
parent
73cb696f12
commit
e225e81ebf
614 changed files with 12075 additions and 352 deletions
|
|
@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
"authentik" "${password}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
# }
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# terraform {
|
||||
# required_providers {
|
||||
# kubectl = {
|
||||
# source = "gavinbunney/kubectl"
|
||||
# version = ">= 1.10.0"
|
||||
# }
|
||||
# }
|
||||
# required_version = ">= 0.13"
|
||||
# }
|
||||
|
|
@ -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", {})]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
14
modules/kubernetes/excalidraw/project/.gitignore
vendored
14
modules/kubernetes/excalidraw/project/.gitignore
vendored
|
|
@ -1,14 +0,0 @@
|
|||
# Binaries
|
||||
excalidraw-library
|
||||
*.exe
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
._*
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module excalidraw-library
|
||||
|
||||
go 1.21
|
||||
|
|
@ -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>`
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
.claude/
|
||||
.git/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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] + "..."
|
||||
}
|
||||
|
|
@ -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] + "..."
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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] + "..."
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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">🏁</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">📡</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">🎮</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">×</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">×</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()">×</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>
|
||||
|
|
@ -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)">×</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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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">📋</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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '&').replace(/'/g, ''').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 })]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
# }
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue