Phase 1 MVP — echo bot factory
All checks were successful
Docker Build / build-and-push-image (push) Successful in 1m8s

This commit is contained in:
2026-05-09 12:23:59 +02:00
commit ee832de089
28 changed files with 1376 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
---
name: Docker Build
on:
workflow_dispatch: {}
push:
branches:
- main
paths-ignore:
- 'README.md'
- 'chart/**'
- '.gitignore'
- 'Makefile'
- 'bots.example.yaml'
concurrency:
group: ${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true
jobs:
build-and-push-image:
runs-on: ubuntu-latest
steps:
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: gitea.arcodange.lab
username: ${{ github.actor }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: git checkout
uses: actions/checkout@v4
- name: Build and push image to Gitea Container Registry
run: |-
TAGS="latest ${{ github.ref_name }}"
docker build -t app .
for TAG in $TAGS; do
docker tag app gitea.arcodange.lab/${{ github.repository }}:$TAG
docker push gitea.arcodange.lab/${{ github.repository }}:$TAG
done

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
bin/
*.test
*.out
.DS_Store
.env
.env.local
bots.yaml

117
DEPLOY.md Normal file
View File

@@ -0,0 +1,117 @@
# Deploy `homelab-gateway` — Phase 1 (echo bot)
Procédure end-to-end pour mettre le gateway en ligne avec un bot
`arcodange_factory_bot` (slug interne `factory`).
> Phase 1 simplifiée : **pas de Vault**. Le `Secret` k8s
> `homelab-gateway-bots` est créé manuellement avec `kubectl create secret`.
> La migration vers Vault Secrets Operator se fait plus tard (Phase 2+) via
> `vault.enabled: true` dans `chart/values.yaml`.
---
## 1. Pré-requis
- Repo Gitea déjà créé : `arcodange/homelab-gateway`
- Bot Telegram déjà créé via @BotFather : `@arcodange_factory_bot`
- Token : `8737289837:…` (en variable d'env, jamais committé)
- chat_id : récupéré via [@userinfobot](https://t.me/userinfobot)
- DNS : Cloudflare route déjà `*.arcodange.fr` vers le home lab → rien
à faire côté DNS, le sous-domaine `tg.arcodange.fr` arrive au cluster
dès qu'on déclare l'Ingress Traefik.
## 2. Push du repo (déclenche le build Docker)
```bash
cd /Users/gabrielradureau/Work/Vibe/homelab_gateway
git init
git add .
git commit -m "Phase 1 MVP — echo bot factory"
git branch -M main
git remote add origin ssh://git@192.168.1.202:2222/arcodange/homelab-gateway.git
git push -u origin main
```
Gitea Actions build l'image et la pousse :
`gitea.arcodange.lab/arcodange/homelab-gateway:latest`.
## 3. Créer le `Secret` k8s avec le token + secret_token
```bash
# Génère un secret_token frais (256 bits hex)
SECRET=$(openssl rand -hex 32)
# Le namespace est créé par ArgoCD si absent — on le crée explicitement avant
# pour pouvoir y poser le Secret tout de suite.
kubectl create namespace homelab-gateway --dry-run=client -o yaml | kubectl apply -f -
kubectl -n homelab-gateway create secret generic homelab-gateway-bots \
--from-literal=BOT_FACTORY_TOKEN='8737289837:AAEVIygazfxgqJTxaxOh3X-mEoKaV7Rw1Gw' \
--from-literal=BOT_FACTORY_SECRET="$SECRET"
# Garde $SECRET sous le coude pour l'étape 5 (setWebhook).
echo "secret_token = $SECRET"
```
> Le bot est mappé sur le slug `factory` dans `chart/values.yaml` :
> `BOT_FACTORY_TOKEN` / `BOT_FACTORY_SECRET` correspondent.
> Pour ajouter d'autres bots ultérieurement, ajouter `BOT_<UPPER_SLUG>_TOKEN/SECRET`
> au même Secret + une clé sous `bots:` dans `chart/values.yaml`.
## 4. Activer l'Application ArgoCD
L'entrée `homelab-gateway` est ajoutée dans
`/Users/gabrielradureau/Work/Arcodange/factory/argocd/values.yaml` (PR
ouverte). Une fois la PR mergée :
```bash
kubectl -n argocd get app homelab-gateway -w
# attends Healthy + Synced
kubectl -n homelab-gateway logs deploy/homelab-gateway -f
# attends "homelab-gateway listening on :8080 (1 bot(s) loaded)"
# Smoke
curl -I https://tg.arcodange.fr/healthz # → 200
```
## 5. Enregistrer le webhook côté Telegram
```bash
export BOT_FACTORY_TOKEN='8737289837:AAEVIygazfxgqJTxaxOh3X-mEoKaV7Rw1Gw'
export BOT_FACTORY_SECRET="$SECRET" # même valeur qu'à l'étape 3
cd /Users/gabrielradureau/Work/Vibe/homelab_gateway
make setwebhook SLUG=factory BASE_URL=https://tg.arcodange.fr
# → "webhook set: url=https://tg.arcodange.fr/bot/factory pending=0 last_err=\"\""
```
Vérification côté Telegram :
```bash
curl -s "https://api.telegram.org/bot$BOT_FACTORY_TOKEN/getWebhookInfo" | jq
```
## 6. Test réel
Envoyer n'importe quel message à `@arcodange_factory_bot` dans
l'app Telegram → réponse identique en < 2 s.
Pour le test `/echo coucou` répond `coucou`.
## Troubleshooting
| Symptôme | Action |
|---|---|
| Pod `CreateContainerConfigError` | Le Secret `homelab-gateway-bots` manque. Le créer (étape 3). |
| Pod `CrashLoopBackOff` "no bots in /etc/…/bots.yaml" | ConfigMap pas généré ou mal monté. `kubectl get cm -n homelab-gateway -o yaml`. |
| `curl https://tg.arcodange.fr/healthz` → 502/504 | Ingress pas encore propagé OU le pod n'est pas Ready. `kubectl describe ingress` + `kubectl describe pod`. |
| `setWebhook``Wrong response from the webhook: 401` | `BOT_FACTORY_SECRET` côté Secret ≠ celui passé à setWebhook. Régénérer + recréer le Secret avec `kubectl delete && create`. |
| Webhook accepté mais pas de réponse Telegram | `kubectl logs` côté gateway → erreur sendMessage. Token bot invalide (révoqué via @BotFather ?) ou rate-limit Telegram. |
## Pour aller plus loin
- Phase 2 : handler `http` (forward vers svc interne) + queue Postgres durable.
- Phase 3 : handlers `shell` / `script` / `ollama` async, retry quand le
Macbook Ollama est endormi.
- Phase 4 : passage à Vault (toggle `vault.enabled: true` + provisionner
`kvv2/homelab-gateway/config`), Wake-on-LAN, multi-provider.
Plan complet : `~/.claude/plans/pour-les-notifications-on-inherited-seal.md`.

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o gateway .
FROM alpine:latest
RUN apk --no-cache add ca-certificates && \
addgroup -g 65532 -S app && \
adduser -u 65532 -S app -G app
WORKDIR /home/app
COPY --from=builder /app/gateway /usr/local/bin/gateway
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/gateway"]
CMD ["serve"]

38
Makefile Normal file
View File

@@ -0,0 +1,38 @@
SHELL := /bin/bash
APP := homelab-gateway
IMAGE := gitea.arcodange.lab/arcodange-org/$(APP)
TAG ?= dev
.PHONY: build test vet tidy run docker push setwebhook deletewebhook
build:
go build -o bin/gateway .
test:
go test ./...
vet:
go vet ./...
tidy:
go mod tidy
run: build
CONFIG_PATH=./bots.example.yaml ./bin/gateway serve
docker:
docker build -t $(IMAGE):$(TAG) .
push: docker
docker push $(IMAGE):$(TAG)
# Usage: make setwebhook SLUG=echo BASE_URL=https://tg.arcodange.fr
# BOT_<SLUG>_TOKEN and BOT_<SLUG>_SECRET must be exported in your shell.
setwebhook:
@test -n "$(SLUG)" || (echo "SLUG=<bot-slug> required" && exit 1)
@test -n "$(BASE_URL)" || (echo "BASE_URL=https://… required" && exit 1)
go run . setwebhook --slug $(SLUG) --base-url $(BASE_URL)
deletewebhook:
@test -n "$(SLUG)" || (echo "SLUG=<bot-slug> required" && exit 1)
go run . deletewebhook --slug $(SLUG)

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# homelab-gateway
Telegram **webhook gateway** for the Arcodange home lab. Replaces polling-based
bots (e.g. those scheduled in Cowork) with direct webhook delivery from
Telegram, routed to per-bot handlers running on the k3s cluster.
> Phase 1 (MVP): single sync `echo` handler, end-to-end flow validated.
> Phase 2 (planned): `http` forward handler + Postgres-backed durable queue.
> Phase 3 (planned): async `shell` / `script` / `ollama` handlers.
See the design doc at `~/.claude/plans/pour-les-notifications-on-inherited-seal.md`.
## Architecture (current)
```
Telegram → Cloudflare Tunnel (tg.arcodange.fr) → Service homelab-gateway:8080
→ /bot/<slug> → secret_token check → handler dispatch → Bot API sendMessage
```
## Routes
| Method | Path | Description |
|--------|------------------|--------------------------------------------|
| GET | `/healthz` | Liveness probe |
| GET | `/readyz` | Readiness probe |
| POST | `/bot/{slug}` | Telegram webhook entry (validates secret) |
## Local dev
```bash
# 1. Provide a config + env
export BOT_FACTORY_TOKEN='8737289837:…' # from @BotFather
export BOT_FACTORY_SECRET=$(openssl rand -hex 32)
# 2. Run
make run # uses bots.example.yaml
# 3. Smoke a webhook
curl -X POST -H "X-Telegram-Bot-Api-Secret-Token: $BOT_FACTORY_SECRET" \
-H 'Content-Type: application/json' \
-d '{"update_id":1,"message":{"chat":{"id":<your-chat-id>},"text":"hi"}}' \
http://localhost:8080/bot/factory
```
## Set / delete webhook
```bash
# Once the gateway is reachable at https://tg.arcodange.fr:
export BOT_FACTORY_TOKEN=
export BOT_FACTORY_SECRET=
make setwebhook SLUG=factory BASE_URL=https://tg.arcodange.fr
make deletewebhook SLUG=factory
```
## Configuration
- **Routing** (non-secret): YAML at `$CONFIG_PATH` (default
`/etc/homelab-gateway/bots.yaml`, mounted from a ConfigMap in cluster).
- **Secrets**: per-bot env vars `BOT_<UPPER_SLUG>_TOKEN`,
`BOT_<UPPER_SLUG>_SECRET`. Sourced from Vault path
`kvv2/homelab-gateway/config` via Vault Secrets Operator.
## Cluster deploy
- Image: `gitea.arcodange.lab/arcodange/homelab-gateway:<tag>`
- Helm chart: `chart/`
- ArgoCD app: `homelab-gateway` (in `factory/argocd/values.yaml`)
- Public URL: `https://tg.arcodange.fr` (Cloudflare déjà configuré pour
router `*.arcodange.fr` vers le home lab → Traefik route par Host)
- Secrets Phase 1 : `kubectl create secret generic homelab-gateway-bots …`
(sans Vault). Migration vers Vault Secrets Operator en Phase 2+ via
`vault.enabled: true` dans `chart/values.yaml`.
Voir `DEPLOY.md` pour la procédure end-to-end.
## Layout
```
.
├── main.go # bootstrap, subcommand dispatch
├── server.go # HTTP routes
├── middleware.go # secret validation, recover, access log
├── handlers.go # Handler interface + Registry
├── handler_echo.go # echo handler
├── telegram.go # Telegram Bot API client
├── telegram_types.go # Update / Message structs
├── config.go # YAML routing config + per-bot env merge
├── setwebhook.go # CLI subcommands (setwebhook / deletewebhook)
├── chart/ # Helm chart
└── .gitea/workflows/ # CI: docker build → gitea registry
```

15
bots.example.yaml Normal file
View File

@@ -0,0 +1,15 @@
# Bot routing config (non-secret). One entry per bot.
# Tokens / secret_token live in Vault and are injected as env vars
# BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET
#
# `<UPPER_SLUG>` = uppercase(slug) with `-` replaced by `_`.
bots:
factory:
handler: echo
# Phase 2 example (not implemented yet)
# webappbot:
# handler: http
# url: http://webapp.webapp.svc.cluster.local:8080/telegram/update
# timeout: 5s

6
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: homelab-gateway
description: Telegram webhook gateway for the Arcodange home lab
type: application
version: 0.1.0
appVersion: "0.1.0"

View File

@@ -0,0 +1,60 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "homelab-gateway.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "homelab-gateway.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Chart name + version label value.
*/}}
{{- define "homelab-gateway.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "homelab-gateway.labels" -}}
helm.sh/chart: {{ include "homelab-gateway.chart" . }}
{{ include "homelab-gateway.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "homelab-gateway.selectorLabels" -}}
app.kubernetes.io/name: {{ include "homelab-gateway.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Service account name.
*/}}
{{- define "homelab-gateway.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "homelab-gateway.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "homelab-gateway.fullname" . }}-bots
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
data:
bots.yaml: |
bots:
{{- range $slug, $cfg := .Values.bots }}
{{ $slug }}:
{{ toYaml $cfg | indent 8 }}
{{- end }}

View File

@@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "homelab-gateway.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
spec:
revisionHistoryLimit: 3
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "homelab-gateway.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/bots-config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "homelab-gateway.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "homelab-gateway.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["serve", "--config", "/etc/homelab-gateway/bots.yaml", "--addr", ":{{ .Values.service.port }}"]
env:
- name: LISTEN_ADDR
value: ":{{ .Values.service.port }}"
- name: CONFIG_PATH
value: /etc/homelab-gateway/bots.yaml
envFrom:
- secretRef:
name: {{ .Values.secret.name }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: bots-config
mountPath: /etc/homelab-gateway
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: bots-config
configMap:
name: {{ include "homelab-gateway.fullname" . }}-bots
- name: tmp
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,36 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "homelab-gateway.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "homelab-gateway.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "homelab-gateway.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "homelab-gateway.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,14 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "homelab-gateway.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

View File

@@ -0,0 +1,17 @@
{{- if .Values.vault.enabled -}}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: auth
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: {{ .Values.vault.role }}
serviceAccount: {{ include "homelab-gateway.serviceAccountName" . }}
audiences:
- vault
{{- end }}

View File

@@ -0,0 +1,18 @@
{{- if .Values.vault.enabled -}}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: bots-secrets
namespace: {{ .Release.Namespace }}
labels:
{{- include "homelab-gateway.labels" . | nindent 4 }}
spec:
type: kv-v2
mount: {{ .Values.vault.mount }}
path: {{ .Values.vault.path }}
destination:
name: {{ .Values.secret.name }}
create: true
refreshAfter: {{ .Values.vault.refreshAfter }}
vaultAuthRef: auth
{{- end }}

106
chart/values.yaml Normal file
View File

@@ -0,0 +1,106 @@
replicaCount: 1
image:
repository: gitea.arcodange.lab/arcodange/homelab-gateway
pullPolicy: Always
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
automount: true
annotations: {}
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
service:
type: ClusterIP
port: 8080
# Public exposure via Traefik. Cloudflare routes *.arcodange.fr to the home lab
# already, so we just declare the hostname here. CF terminates TLS, Traefik
# receives plain HTTP on entrypoint `web`.
ingress:
enabled: true
className: ""
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
traefik.ingress.kubernetes.io/router.middlewares: kube-system-crowdsec@kubernetescrd
hosts:
- host: tg.arcodange.fr
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 50m
memory: 64Mi
livenessProbe:
httpGet:
path: /healthz
port: http
readinessProbe:
httpGet:
path: /readyz
port: http
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
# Bot routing config — non-secret, becomes the bots.yaml ConfigMap entry.
# Tokens & secret_token values live in a k8s Secret named `secret.name`.
# In Phase 1 the Secret is created out-of-band (kubectl create secret); in a
# later phase Vault will produce it via VSO (toggle `vault.enabled`).
bots:
factory:
handler: echo
# k8s Secret consumed by `envFrom`. Phase 1: create it manually with kubectl.
# kubectl -n homelab-gateway create secret generic homelab-gateway-bots \
# --from-literal=BOT_FACTORY_TOKEN=… --from-literal=BOT_FACTORY_SECRET=…
secret:
name: homelab-gateway-bots
# Vault Secrets Operator integration (Phase 2+). When enabled, VSO writes the
# secret named `secret.name` automatically from `kvv2/homelab-gateway/config`.
vault:
enabled: false
role: homelab-gateway
mount: kvv2
path: homelab-gateway/config
refreshAfter: 30s
nodeSelector:
kubernetes.io/hostname: pi1
tolerations: []
affinity: {}

43
config.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Bots map[string]BotConfig `yaml:"bots"`
}
type BotConfig struct {
Handler string `yaml:"handler"`
Token string `yaml:"-"`
Secret string `yaml:"-"`
}
// LoadConfig reads the YAML routing config and merges per-bot secrets pulled
// from the process environment. Per-bot env keys are derived from the bot
// slug uppercased: BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET.
func LoadConfig(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("parse yaml: %w", err)
}
if len(cfg.Bots) == 0 {
return nil, fmt.Errorf("no bots in %s", path)
}
for slug, b := range cfg.Bots {
envSlug := strings.ToUpper(strings.ReplaceAll(slug, "-", "_"))
b.Token = os.Getenv("BOT_" + envSlug + "_TOKEN")
b.Secret = os.Getenv("BOT_" + envSlug + "_SECRET")
cfg.Bots[slug] = b
}
return &cfg, nil
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/arcodange-org/homelab-gateway
go 1.23
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

33
handler_echo.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"context"
"fmt"
"strings"
)
type EchoHandler struct {
tg *TelegramClient
}
func (e *EchoHandler) Handle(ctx context.Context, update Update, bot Bot) error {
chatID, ok := update.ChatID()
if !ok {
return nil
}
text := strings.TrimSpace(update.Text())
if text == "" {
return nil
}
reply := text
if strings.HasPrefix(text, "/echo") {
reply = strings.TrimSpace(strings.TrimPrefix(text, "/echo"))
if reply == "" {
reply = "echo bot online — send me anything"
}
}
if err := e.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: reply}); err != nil {
return fmt.Errorf("sendMessage: %w", err)
}
return nil
}

75
handlers.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"context"
"fmt"
)
type Handler interface {
Handle(ctx context.Context, update Update, bot Bot) error
}
type Bot struct {
Slug string
Token string
Secret string
Handler Handler
}
type Registry struct {
bots map[string]Bot
}
func NewRegistry(cfg *Config) (*Registry, error) {
if len(cfg.Bots) == 0 {
return nil, fmt.Errorf("no bots configured")
}
tg := NewTelegramClient()
bots := make(map[string]Bot, len(cfg.Bots))
for slug, b := range cfg.Bots {
token := b.Token
secret := b.Secret
if token == "" {
return nil, fmt.Errorf("bot %s: token missing", slug)
}
if secret == "" {
return nil, fmt.Errorf("bot %s: secret missing", slug)
}
var h Handler
switch b.Handler {
case "echo":
h = &EchoHandler{tg: tg}
case "":
return nil, fmt.Errorf("bot %s: handler missing", slug)
default:
return nil, fmt.Errorf("bot %s: unknown handler %q", slug, b.Handler)
}
bots[slug] = Bot{
Slug: slug,
Token: token,
Secret: secret,
Handler: h,
}
}
return &Registry{bots: bots}, nil
}
func (r *Registry) Get(slug string) (Bot, bool) {
b, ok := r.bots[slug]
return b, ok
}
func (r *Registry) Count() int { return len(r.bots) }
func (r *Registry) Slugs() []string {
out := make([]string, 0, len(r.bots))
for s := range r.bots {
out = append(out, s)
}
return out
}

86
main.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
const defaultListenAddr = ":8080"
const defaultConfigPath = "/etc/homelab-gateway/bots.yaml"
func main() {
subcmd := ""
if len(os.Args) > 1 && os.Args[1] != "" && os.Args[1][0] != '-' {
subcmd = os.Args[1]
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
}
switch subcmd {
case "setwebhook":
runSetWebhook()
case "deletewebhook":
runDeleteWebhook()
case "", "serve":
runServer()
default:
log.Fatalf("unknown subcommand: %s (expected: serve | setwebhook | deletewebhook)", subcmd)
}
}
func runServer() {
addr := flag.String("addr", envOr("LISTEN_ADDR", defaultListenAddr), "listen address")
configPath := flag.String("config", envOr("CONFIG_PATH", defaultConfigPath), "bot routing config (YAML)")
flag.Parse()
cfg, err := LoadConfig(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
registry, err := NewRegistry(cfg)
if err != nil {
log.Fatalf("build registry: %v", err)
}
srv := &http.Server{
Addr: *addr,
Handler: NewServer(registry).Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("homelab-gateway listening on %s (%d bot(s) loaded)", *addr, registry.Count())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
<-ctx.Done()
log.Print("shutdown signal received, draining...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown error: %v", err)
}
log.Print("bye")
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

54
middleware.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"crypto/subtle"
"log"
"net/http"
"runtime/debug"
"time"
)
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func recoverMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
func accessLogMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sr, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sr.status, time.Since(start))
})
}
func verifyTelegramSecret(provided, expected string) bool {
if expected == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}

75
server.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
)
type Server struct {
registry *Registry
}
func NewServer(r *Registry) *Server {
return &Server{registry: r}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/readyz", s.ready)
mux.HandleFunc("/bot/", s.botWebhook)
return chain(mux, recoverMW, accessLogMW)
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "OK")
}
func (s *Server) ready(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "OK")
}
func (s *Server) botWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
slug := strings.TrimPrefix(r.URL.Path, "/bot/")
slug = strings.Trim(slug, "/")
if slug == "" || strings.Contains(slug, "/") {
http.Error(w, "bot slug missing or malformed", http.StatusBadRequest)
return
}
bot, ok := s.registry.Get(slug)
if !ok {
http.Error(w, "unknown bot", http.StatusNotFound)
return
}
if !verifyTelegramSecret(r.Header.Get("X-Telegram-Bot-Api-Secret-Token"), bot.Secret) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var update Update
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&update); err != nil {
http.Error(w, "bad update payload", http.StatusBadRequest)
return
}
if err := bot.Handler.Handle(r.Context(), update, bot); err != nil {
log.Printf("bot=%s update=%d handler error: %v", slug, update.UpdateID, err)
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "{}")
}

74
setwebhook.go Normal file
View File

@@ -0,0 +1,74 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
)
func runSetWebhook() {
slug := flag.String("slug", "", "bot slug (must match a key in bots.yaml)")
baseURL := flag.String("base-url", envOr("BASE_URL", ""), "public base URL of the gateway, e.g. https://tg.arcodange.fr")
dropPending := flag.Bool("drop-pending", true, "drop pending updates")
flag.Parse()
if *slug == "" {
log.Fatal("--slug required")
}
if *baseURL == "" {
log.Fatal("--base-url required (or set BASE_URL)")
}
envSlug := strings.ToUpper(strings.ReplaceAll(*slug, "-", "_"))
token := os.Getenv("BOT_" + envSlug + "_TOKEN")
secret := os.Getenv("BOT_" + envSlug + "_SECRET")
if token == "" || secret == "" {
log.Fatalf("BOT_%s_TOKEN and BOT_%s_SECRET must be set in env", envSlug, envSlug)
}
hookURL := strings.TrimSuffix(*baseURL, "/") + "/bot/" + *slug
tg := NewTelegramClient()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := tg.SetWebhook(ctx, token, SetWebhookParams{
URL: hookURL,
SecretToken: secret,
DropPendingUpdates: *dropPending,
AllowedUpdates: []string{"message", "edited_message", "callback_query"},
MaxConnections: 20,
}); err != nil {
log.Fatalf("setWebhook: %v", err)
}
info, err := tg.GetWebhookInfo(ctx, token)
if err != nil {
log.Fatalf("getWebhookInfo: %v", err)
}
fmt.Printf("webhook set: url=%s pending=%d last_err=%q\n", info.URL, info.PendingUpdateCount, info.LastErrorMessage)
}
func runDeleteWebhook() {
slug := flag.String("slug", "", "bot slug")
flag.Parse()
if *slug == "" {
log.Fatal("--slug required")
}
envSlug := strings.ToUpper(strings.ReplaceAll(*slug, "-", "_"))
token := os.Getenv("BOT_" + envSlug + "_TOKEN")
if token == "" {
log.Fatalf("BOT_%s_TOKEN must be set in env", envSlug)
}
tg := NewTelegramClient()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := tg.DeleteWebhook(ctx, token); err != nil {
log.Fatalf("deleteWebhook: %v", err)
}
fmt.Println("webhook deleted")
}

166
telegram.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const telegramAPIBase = "https://api.telegram.org"
type TelegramClient struct {
httpClient *http.Client
apiBase string
}
func NewTelegramClient() *TelegramClient {
return &TelegramClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
apiBase: telegramAPIBase,
}
}
type SendMessageParams struct {
ChatID int64 `json:"chat_id"`
Text string `json:"text"`
}
type apiResponse struct {
OK bool `json:"ok"`
Description string `json:"description,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
}
func (c *TelegramClient) SendMessage(ctx context.Context, token string, params SendMessageParams) error {
body, err := json.Marshal(params)
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/bot%s/sendMessage", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram api error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
type SetWebhookParams struct {
URL string `json:"url"`
SecretToken string `json:"secret_token,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
AllowedUpdates []string `json:"allowed_updates,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
func (c *TelegramClient) SetWebhook(ctx context.Context, token string, params SetWebhookParams) error {
body, err := json.Marshal(params)
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/bot%s/setWebhook", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram setWebhook error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
func (c *TelegramClient) DeleteWebhook(ctx context.Context, token string) error {
endpoint := fmt.Sprintf("%s/bot%s/deleteWebhook", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram deleteWebhook error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
type WebhookInfo struct {
URL string `json:"url"`
HasCustomCert bool `json:"has_custom_certificate"`
PendingUpdateCount int `json:"pending_update_count"`
LastErrorDate int64 `json:"last_error_date,omitempty"`
LastErrorMessage string `json:"last_error_message,omitempty"`
}
func (c *TelegramClient) GetWebhookInfo(ctx context.Context, token string) (*WebhookInfo, error) {
endpoint := fmt.Sprintf("%s/bot%s/getWebhookInfo", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return nil, fmt.Errorf("decode telegram response: %w", jerr)
}
if !ar.OK {
return nil, fmt.Errorf("telegram getWebhookInfo error: %s", ar.Description)
}
var info WebhookInfo
if jerr := json.Unmarshal(ar.Result, &info); jerr != nil {
return nil, fmt.Errorf("decode webhook info: %w", jerr)
}
return &info, nil
}
// botBuildURL is exposed for tests; not used directly.
var _ = url.Parse

59
telegram_types.go Normal file
View File

@@ -0,0 +1,59 @@
package main
type Update struct {
UpdateID int64 `json:"update_id"`
Message *Message `json:"message,omitempty"`
EditedMessage *Message `json:"edited_message,omitempty"`
CallbackQuery *CallbackQuery `json:"callback_query,omitempty"`
}
type Message struct {
MessageID int64 `json:"message_id"`
From *User `json:"from,omitempty"`
Chat Chat `json:"chat"`
Date int64 `json:"date,omitempty"`
Text string `json:"text,omitempty"`
}
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type,omitempty"`
}
type User struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot,omitempty"`
Username string `json:"username,omitempty"`
FirstName string `json:"first_name,omitempty"`
}
type CallbackQuery struct {
ID string `json:"id"`
From *User `json:"from,omitempty"`
Message *Message `json:"message,omitempty"`
Data string `json:"data,omitempty"`
}
func (u Update) ChatID() (int64, bool) {
switch {
case u.Message != nil:
return u.Message.Chat.ID, true
case u.EditedMessage != nil:
return u.EditedMessage.Chat.ID, true
case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
return u.CallbackQuery.Message.Chat.ID, true
}
return 0, false
}
func (u Update) Text() string {
switch {
case u.Message != nil:
return u.Message.Text
case u.EditedMessage != nil:
return u.EditedMessage.Text
case u.CallbackQuery != nil:
return u.CallbackQuery.Data
}
return ""
}