Phase 1 MVP — echo bot factory
All checks were successful
Docker Build / build-and-push-image (push) Successful in 1m8s
All checks were successful
Docker Build / build-and-push-image (push) Successful in 1m8s
This commit is contained in:
41
.gitea/workflows/dockerimage.yaml
Normal file
41
.gitea/workflows/dockerimage.yaml
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
bin/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
bots.yaml
|
||||||
117
DEPLOY.md
Normal file
117
DEPLOY.md
Normal 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
22
Dockerfile
Normal 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
38
Makefile
Normal 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
91
README.md
Normal 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
15
bots.example.yaml
Normal 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
6
chart/Chart.yaml
Normal 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"
|
||||||
60
chart/templates/_helpers.tpl
Normal file
60
chart/templates/_helpers.tpl
Normal 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 }}
|
||||||
14
chart/templates/configmap.yaml
Normal file
14
chart/templates/configmap.yaml
Normal 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 }}
|
||||||
84
chart/templates/deployment.yaml
Normal file
84
chart/templates/deployment.yaml
Normal 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 }}
|
||||||
36
chart/templates/ingress.yaml
Normal file
36
chart/templates/ingress.yaml
Normal 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 }}
|
||||||
16
chart/templates/service.yaml
Normal file
16
chart/templates/service.yaml
Normal 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 }}
|
||||||
14
chart/templates/serviceaccount.yaml
Normal file
14
chart/templates/serviceaccount.yaml
Normal 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 }}
|
||||||
17
chart/templates/vaultauth.yaml
Normal file
17
chart/templates/vaultauth.yaml
Normal 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 }}
|
||||||
18
chart/templates/vaultsecret.yaml
Normal file
18
chart/templates/vaultsecret.yaml
Normal 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
106
chart/values.yaml
Normal 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
43
config.go
Normal 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
5
go.mod
Normal 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
4
go.sum
Normal 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
33
handler_echo.go
Normal 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
75
handlers.go
Normal 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
86
main.go
Normal 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
54
middleware.go
Normal 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
75
server.go
Normal 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
74
setwebhook.go
Normal 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
166
telegram.go
Normal 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
59
telegram_types.go
Normal 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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user