diff --git a/charts/mediamtx/Chart.yaml b/charts/mediamtx/Chart.yaml new file mode 100644 index 0000000..224cb05 --- /dev/null +++ b/charts/mediamtx/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: mediamtx +description: MediaMTX streaming server + zoitestream landing page/API for stream.dumpnet.chat +type: application +version: 0.1.0 diff --git a/charts/mediamtx/templates/configmap.yaml b/charts/mediamtx/templates/configmap.yaml new file mode 100644 index 0000000..13a3669 --- /dev/null +++ b/charts/mediamtx/templates/configmap.yaml @@ -0,0 +1,69 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: mediamtx-config + namespace: mediamtx +data: + mediamtx.yml: | + logLevel: info + + authMethod: internal + authInternalUsers: + - user: {{ .Values.publishUser }} + pass: __PUBLISH_PASSWORD__ + ips: [] + permissions: + - action: publish + path: "" + - action: read + path: "" + - action: playback + path: "" + - user: any + pass: "" + ips: [] + permissions: + - action: read + path: "" + - action: playback + path: "" + + rtmp: true + rtmpAddress: :{{ .Values.mediamtx.rtmpPort }} + rtmpEncryption: "no" + + hls: true + hlsAddress: :8888 + hlsVariant: lowLatency + hlsAllowOrigin: "*" + hlsSegmentCount: 7 + hlsSegmentDuration: 1s + hlsPartDuration: 200ms + + webrtc: true + webrtcAddress: :{{ .Values.mediamtx.webrtcHttpPort }} + webrtcEncryption: false + webrtcAllowOrigin: "*" + webrtcLocalUDPAddress: :{{ .Values.mediamtx.webrtcUdpPort }} + webrtcAdditionalHosts: + - {{ .Values.publicIp }} + webrtcICEServers2: + - url: stun:stun.l.google.com:19302 + - url: stun:stun1.l.google.com:19302 + + rtsp: false + srt: false + + pathDefaults: + source: publisher + runOnReady: > + wget -q -O- --post-data "path=$MTX_PATH&source_type=$MTX_SOURCE_TYPE" + "http://localhost:5000/hooks/ready?token=$HOOK_TOKEN" + runOnReadyRestart: false + runOnNotReady: > + wget -q -O- --post-data "path=$MTX_PATH" + "http://localhost:5000/hooks/not-ready?token=$HOOK_TOKEN" + runOnNotReadyRestart: false + + paths: + all_others: {} diff --git a/charts/mediamtx/templates/external-secret.yaml b/charts/mediamtx/templates/external-secret.yaml new file mode 100644 index 0000000..a8a686f --- /dev/null +++ b/charts/mediamtx/templates/external-secret.yaml @@ -0,0 +1,27 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: zoitestream-secrets + namespace: mediamtx + annotations: + argocd.argoproj.io/sync-wave: "-1" +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: ClusterSecretStore + target: + name: zoitestream-secrets + data: + - secretKey: PUBLISH_PASSWORD + remoteRef: + key: dumpnet + property: mediamtx.publish_password + - secretKey: HOOK_TOKEN + remoteRef: + key: dumpnet + property: mediamtx.hook_token + - secretKey: CREDS_TOKEN + remoteRef: + key: dumpnet + property: mediamtx.creds_token diff --git a/charts/mediamtx/templates/mediamtx.yaml b/charts/mediamtx/templates/mediamtx.yaml new file mode 100644 index 0000000..63eb27d --- /dev/null +++ b/charts/mediamtx/templates/mediamtx.yaml @@ -0,0 +1,125 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mediamtx + namespace: mediamtx +spec: + replicas: 1 + selector: + matchLabels: + app: mediamtx + template: + metadata: + labels: + app: mediamtx + spec: + hostNetwork: true + imagePullSecrets: + - name: forgejo-registry + containers: + - name: mediamtx + image: {{ .Values.mediamtx.image }} + command: ["/bin/sh", "-c"] + args: + - | + sed "s|__PUBLISH_PASSWORD__|$PUBLISH_PASSWORD|g" /etc/mediamtx/mediamtx.yml > /tmp/mediamtx.yml + exec /mediamtx /tmp/mediamtx.yml + env: + - name: HOOK_TOKEN + valueFrom: + secretKeyRef: + name: zoitestream-secrets + key: HOOK_TOKEN + - name: PUBLISH_PASSWORD + valueFrom: + secretKeyRef: + name: zoitestream-secrets + key: PUBLISH_PASSWORD + ports: + - name: rtmp + containerPort: {{ .Values.mediamtx.rtmpPort }} + - name: webrtc-http + containerPort: {{ .Values.mediamtx.webrtcHttpPort }} + - name: webrtc-udp + containerPort: {{ .Values.mediamtx.webrtcUdpPort }} + protocol: UDP + volumeMounts: + - name: config + mountPath: /etc/mediamtx/mediamtx.yml + subPath: mediamtx.yml + - name: zoitestream + image: "{{ .Values.registry.host }}/{{ .Values.registry.user }}/zoitestream:latest" + env: + - name: DOMAIN + value: "stream.{{ .Values.domain }}" + - name: RTMP_PORT + value: "{{ .Values.mediamtx.rtmpPort }}" + - name: PUBLISH_USER + value: {{ .Values.publishUser }} + - name: STATE_DB_PATH + value: /data/zoitestream.db + envFrom: + - secretRef: + name: zoitestream-secrets + ports: + - name: http + containerPort: 5000 + readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 3 + livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: config + configMap: + name: mediamtx-config + - name: data + hostPath: + path: /var/local/appdata/zoitestream + type: DirectoryOrCreate +--- +# HTTP-only Service (landing page + API), routed through ingress-nginx +apiVersion: v1 +kind: Service +metadata: + name: zoitestream + namespace: mediamtx +spec: + selector: + app: mediamtx + ports: + - port: 80 + targetPort: 5000 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: zoitestream + namespace: mediamtx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - stream.{{ .Values.domain }} + secretName: zoitestream-tls + rules: + - host: stream.{{ .Values.domain }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: zoitestream + port: + number: 80 diff --git a/charts/mediamtx/templates/namespace.yaml b/charts/mediamtx/templates/namespace.yaml new file mode 100644 index 0000000..265a8a2 --- /dev/null +++ b/charts/mediamtx/templates/namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mediamtx + labels: + pod-security.kubernetes.io/enforce: privileged + pod-security.kubernetes.io/audit: privileged + pod-security.kubernetes.io/warn: privileged diff --git a/charts/mediamtx/templates/registry-secret.yaml b/charts/mediamtx/templates/registry-secret.yaml new file mode 100644 index 0000000..8077e56 --- /dev/null +++ b/charts/mediamtx/templates/registry-secret.yaml @@ -0,0 +1,27 @@ +{{- $host := .Values.registry.host }} +{{- $user := .Values.registry.user }} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: forgejo-registry + namespace: mediamtx + annotations: + argocd.argoproj.io/sync-wave: "-1" +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: ClusterSecretStore + target: + name: forgejo-registry + template: + engineVersion: v2 + mergePolicy: Replace + type: kubernetes.io/dockerconfigjson + data: + .dockerconfigjson: '{"auths":{"{{ $host }}":{"username":"{{ $user }}","password":"{{ "{{" }} .registry_token {{ "}}" }}","auth":"{{ "{{" }} printf "{{ $user }}:%s" .registry_token | b64enc {{ "}}" }}"}}}' + data: + - secretKey: registry_token + remoteRef: + key: dumpnet + property: forgejo.registry_token diff --git a/charts/mediamtx/values.yaml b/charts/mediamtx/values.yaml new file mode 100644 index 0000000..13f37f3 --- /dev/null +++ b/charts/mediamtx/values.yaml @@ -0,0 +1,10 @@ +domain: dumpnet.chat +registry: + host: forge.keane.sh + user: ian +mediamtx: + image: bluenviron/mediamtx:latest-ffmpeg + rtmpPort: 1935 + webrtcHttpPort: 8889 + webrtcUdpPort: 8189 +publishUser: streamer diff --git a/charts/repertory-api/values.yaml b/charts/repertory-api/values.yaml index 0a95ca1..de956cd 100644 --- a/charts/repertory-api/values.yaml +++ b/charts/repertory-api/values.yaml @@ -2,4 +2,4 @@ domain: dumpnet.chat registry: host: forge.keane.sh user: ian -corsOrigins: "*" +corsOrigins: "https://repertory.dumpnet.chat" diff --git a/manifests/services/mediamtx.yaml b/manifests/services/mediamtx.yaml new file mode 100644 index 0000000..a14bceb --- /dev/null +++ b/manifests/services/mediamtx.yaml @@ -0,0 +1,33 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mediamtx + namespace: argocd +spec: + project: default + sources: + - repoURL: https://forge.keane.sh/ian/dumpnet-argo.git + targetRevision: HEAD + path: charts/mediamtx + helm: + valueFiles: + - $values/values.yaml + - repoURL: https://forge.keane.sh/ian/dumpnet-argo.git + targetRevision: HEAD + ref: values + destination: + server: https://kubernetes.default.svc + namespace: mediamtx + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + ignoreDifferences: + - group: external-secrets.io + kind: ExternalSecret + jsonPointers: + - /spec/target/template/mergePolicy + - /spec/target/template/engineVersion + - /spec/target/type diff --git a/terraform/repertory-frontend.tf b/terraform/repertory-frontend.tf new file mode 100644 index 0000000..8a149c6 --- /dev/null +++ b/terraform/repertory-frontend.tf @@ -0,0 +1,147 @@ +# Static site hosting for the repertory frontend (tunes.dumpnet.chat) +# S3 static website hosting behind CloudFront (for HTTPS) + ACM cert. +# NOTE: bucket name currently matches the live hostname (repertory.dumpnet.chat) +# because it's also used as an S3-website CNAME target internally. When this +# moves to tunes.dumpnet.chat, rename the bucket + update the Route53 record +# + alt name below to match. + +resource "aws_s3_bucket" "repertory_frontend" { + bucket = "repertory.dumpnet.chat" + force_destroy = true +} + +resource "aws_s3_bucket_website_configuration" "repertory_frontend" { + bucket = aws_s3_bucket.repertory_frontend.id + + index_document { + suffix = "index.html" + } +} + +resource "aws_s3_bucket_public_access_block" "repertory_frontend" { + bucket = aws_s3_bucket.repertory_frontend.id + + block_public_acls = false + block_public_policy = false + ignore_public_acls = false + restrict_public_buckets = false +} + +resource "aws_s3_bucket_policy" "repertory_frontend" { + bucket = aws_s3_bucket.repertory_frontend.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "PublicReadGetObject" + Effect = "Allow" + Principal = "*" + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.repertory_frontend.arn}/*" + } + ] + }) + depends_on = [aws_s3_bucket_public_access_block.repertory_frontend] +} + +# --- ACM cert for CloudFront (must be in us-east-1, which this provider already is) --- + +resource "aws_acm_certificate" "repertory_frontend" { + domain_name = "repertory.${var.domain}" + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "repertory_frontend_cert_validation" { + for_each = { + for dvo in aws_acm_certificate.repertory_frontend.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + zone_id = data.aws_route53_zone.dumpnet.zone_id + name = each.value.name + type = each.value.type + records = [each.value.record] + ttl = 300 +} + +resource "aws_acm_certificate_validation" "repertory_frontend" { + certificate_arn = aws_acm_certificate.repertory_frontend.arn + validation_record_fqdns = [for r in aws_route53_record.repertory_frontend_cert_validation : r.fqdn] +} + +# --- CloudFront distribution in front of the S3 website endpoint --- + +resource "aws_cloudfront_distribution" "repertory_frontend" { + enabled = true + default_root_object = "index.html" + aliases = ["repertory.${var.domain}"] + + origin { + domain_name = aws_s3_bucket_website_configuration.repertory_frontend.website_endpoint + origin_id = "repertory-frontend-s3-website" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "http-only" # S3 website endpoints are HTTP-only + origin_ssl_protocols = ["TLSv1.2"] + } + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "repertory-frontend-s3-website" + viewer_protocol_policy = "redirect-to-https" + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = aws_acm_certificate_validation.repertory_frontend.certificate_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } +} + +resource "aws_route53_record" "repertory_frontend" { + zone_id = data.aws_route53_zone.dumpnet.zone_id + name = "repertory.${var.domain}" + type = "A" + + alias { + name = aws_cloudfront_distribution.repertory_frontend.domain_name + zone_id = aws_cloudfront_distribution.repertory_frontend.hosted_zone_id + evaluate_target_health = false + } +} + +output "repertory_frontend_bucket" { + value = aws_s3_bucket.repertory_frontend.id +} + +output "repertory_frontend_website_endpoint" { + value = aws_s3_bucket_website_configuration.repertory_frontend.website_endpoint +} + +output "repertory_frontend_cloudfront_domain" { + value = aws_cloudfront_distribution.repertory_frontend.domain_name +} diff --git a/terraform/security_group.tf b/terraform/security_group.tf index fd8e682..312b18f 100644 --- a/terraform/security_group.tf +++ b/terraform/security_group.tf @@ -43,6 +43,22 @@ resource "aws_security_group" "talos" { self = true } + ingress { + description = "MediaMTX RTMP ingest" + from_port = 1935 + to_port = 1935 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "MediaMTX WebRTC ICE" + from_port = 8189 + to_port = 8189 + protocol = "udp" + cidr_blocks = ["0.0.0.0/0"] + } + egress { from_port = 0 to_port = 0 diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars index 439106f..3d58bbd 100644 --- a/terraform/terraform.tfvars +++ b/terraform/terraform.tfvars @@ -1,2 +1,2 @@ hosted_zone_id = "Z068835512G0ZQJ9SJGOI" -dns_records = ["argocd", "todo", "git-mcp", "git-mcp-oauth", "repertory-api"] +dns_records = ["argocd", "todo", "git-mcp", "git-mcp-oauth", "repertory-api", "stream"] diff --git a/values.yaml b/values.yaml index f1f5f9b..f67866e 100644 --- a/values.yaml +++ b/values.yaml @@ -5,6 +5,7 @@ clusterName: dumpnet domain: dumpnet.chat +publicIp: "32.195.88.32" repoURL: https://forge.keane.sh/ian/dumpnet-argo.git certEmail: dumpnetcerts@keane.sh awsRegion: us-east-1