Compare commits

..

207 Commits

Author SHA1 Message Date
forust 0c5cf29f83 feat: add userbot k8s deployment method
Deploy to Server / deploy (push) Has been cancelled
- Kustomize: base + overlays/dev + overlays/prod
2026-06-07 19:41:28 +02:00
forust 43c38767a1 fix: removed git checkout (was destructive)
- Remove git init/fetch/checkout from utils/misc.py
- Hardcode userbot_version to 2.5.0
2026-06-07 19:40:52 +02:00
forust a68c01a68f chore: add gitea container registry compose support for localy builded apps
Deploy to Server / deploy (push) Has been cancelled
2026-06-07 14:37:57 +02:00
forust bdcdb1cb3d feat: router for gitea container registry (unproxied) 2026-06-07 14:37:53 +02:00
forust fe2b80b51f fix: errorpages intercepted gitea container registry endpoint
gitea registry introduced
2026-06-07 13:39:54 +02:00
forust b8d5bc747d hack: commented out local certs until i figure out how to route certs for local routers 2026-06-07 13:38:14 +02:00
forust 6f77b7d845 switch to embedded dockmon login page
Deploy to Server / deploy (push) Has been cancelled
2026-05-27 20:11:48 +02:00
forust ea286e117d feat: add diary (/diary) command with calendar parsing via Playwright
Deploy to Server / deploy (push) Has been cancelled
- Parse school diary calendar HTML table for daily/weekly/monthly views
- Cache diary data with 5-minute TTL
- Inline keyboard for today/tomorrow/week/month selection
- Ukrainian month/weekday names and formatting
2026-05-27 19:39:45 +02:00
forust 6a050669e8 chore: ignore all generated searxng core-config files 2026-05-27 19:39:41 +02:00
forust b9c11b8eab Merge branch 'main' into optimize/userbot
Deploy to Server / deploy (push) Has been cancelled
2026-05-25 01:48:31 +02:00
forust 6dac841b2c updated traefik to v3.7,
Deploy to Server / deploy (push) Has been cancelled
resolved maxResponseBodySize not set
2026-05-25 01:47:59 +02:00
forust 526a2b617a refactor: update .dockerignore
pull_policy never to use local images
2026-05-25 01:43:46 +02:00
forust 1e08391e0e refactor: improved error handling, timeouts and use temp files 2026-05-21 22:14:29 +02:00
forust 0a31601b77 refactor: imporved layer caching for dockerfile
using existing built image for account 2
2026-05-21 22:12:33 +02:00
forust 25eb89c902 feat: add searxng service (metasearch engine) 2026-05-21 21:34:40 +02:00
forust d988b0f7db refactor: change Cloudlfare DDNS config to env-based
Deploy to Server / deploy (push) Failing after 14m7s
2026-05-08 17:38:41 +02:00
forust e873f37159 refactor: nextcloud now requires mounting /dev/dri inside the container.
Deploy to Server / deploy (push) Failing after 0s
Changed network name for nextcloud
2026-05-08 16:39:09 +02:00
forust 8362f45bdc upd: updated image tags for services:
- gitea 1.25.1 --> 1.26
- portainer-ce latest --> 2.41.0
- traefik latest --> 3.6.15
2026-05-08 16:37:26 +02:00
forust fd5ea6cadd upd: kener v4.0.23
Deploy to Server / deploy (push) Failing after 0s
2026-04-10 02:29:02 +02:00
forust aa3598ee3d Merge pull request 'upd: updated kener to v4' (#14) from upd/kener-v4 into main
Deploy to Server / deploy (push) Failing after 1s
Reviewed-on: #14
2026-02-27 13:06:36 +01:00
forust c0205fa49b upd: updated kener to v4 2026-02-27 13:05:56 +01:00
forust a22707770f feat: update certificates logic:
Deploy to Server / deploy (push) Failing after 1s
- Switched xdfnx's homepage certificates to traefik-managed mode
- Renamed local fallback certs
2026-02-25 21:12:44 +01:00
forust 646f988a86 chore: Pinned stable kener version
Deploy to Server / deploy (push) Failing after 5s
2026-02-24 12:09:06 +01:00
forust 414d17d839 deleted test gitea workflow
Deploy to Server / deploy (push) Successful in 1s
2026-02-22 03:53:51 +01:00
forust 812e4eb87a ci: add copy of the gitea workflow for the github
Deploy to Server / deploy (push) Successful in 1s
2026-02-22 03:37:45 +01:00
forust 70b3b203fb fix: update email address
Deploy to Server / deploy (push) Has been cancelled
2026-02-22 00:28:19 +01:00
forust d857f3838d revert testing message
Deploy to Server / deploy (push) Successful in 1s
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-02-20 20:41:01 +01:00
forust f8620349a9 fix deploy pipeline to prevent logspam
Deploy to Server / deploy (push) Successful in 16s
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-02-20 20:38:39 +01:00
forust b1acff5c85 ci: add run-name to deployment workflow
Deploy to Server / deploy (push) Successful in 19s
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-02-20 20:25:17 +01:00
forust beb6dca6a2 CI test change 2026-02-20 20:23:11 +01:00
forust aed6ff31f7 fix: add .runner (act runner settings) to .gitignore 2026-02-20 20:22:10 +01:00
forust 73684af21b ci: deploy workflow with local act_runner
Deploy to Server / deploy (push) Successful in 1s
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-02-20 20:09:26 +01:00
forust cd5c90adcc Merge branch 'main' into ci/gitea-actions 2026-02-20 20:08:48 +01:00
forust 578a1ca544 revert: naio needs insecure transport
2593b54402 chore: add backquotes for downtify traefik labels removed insecureTransport
2026-02-20 01:25:23 +01:00
forust 12ed20a306 fix: support for external dynamic traefik configs 2026-02-20 01:16:54 +01:00
forust 400e7b6595 Starting learning cicd from scratch
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 8s
Demo gitea action
2026-02-19 00:36:02 +01:00
forust f58e96a25d add support for external dynamic traefik configs 2026-02-07 01:56:10 +01:00
forust a3e5f5a78b fix: let traefik handle https to portainer 2026-02-05 02:42:39 +01:00
forust 1a09a62a4c chore: deploy and lint workflows 2026-02-05 02:17:47 +01:00
forust 2593b54402 chore: add backquotes for downtify traefik labels
removed insecureTransport
2026-02-05 01:33:04 +01:00
forust 42826a037c refactor: store n8n data in named volumes 2026-02-04 22:26:43 +01:00
forust d7a68237e5 chore: remove redundant or unnececary traefik labels
- traefik.docker.network= (defined by traefik cli)
- traefik.http.routers.<routername>.middlewares=security-headers@file" (applied globally by traefik cli)
2026-02-04 22:26:27 +01:00
forust bbb8bdf0a7 feat: traefik routers for netronome, exported netronome env to .env 2026-02-04 11:00:44 +01:00
forust f4d3bd9c6d feat: basic netronome service 2026-02-03 14:25:53 +01:00
forust 6b919df7c6 chore: restart downtify service unless stopped 2026-02-01 17:24:50 +01:00
forust 3ee0b96ed5 feat: add acl policies to the headscale 2026-02-01 01:14:58 +01:00
forust d25d213d9b feat: add headscale admin web admin (along with headplane accessible via port) 2026-01-31 13:38:33 +01:00
forust a3b7c4c889 commit team avatars (and love) 2026-01-29 21:19:34 +01:00
forust 3dbb50c924 json logging 2026-01-29 17:30:09 +01:00
forust 54da82432b fix: remove headplane healtcheck (always false negative) 2026-01-29 17:22:47 +01:00
forust e5eb234c41 chore: gitignore for adguard 2026-01-29 17:22:40 +01:00
forust 721348e67f refactor: switch adguard data to named volume 2026-01-29 17:22:33 +01:00
forust d58ae2a5e7 chore: new statuspage for errorpages 2026-01-25 23:41:42 +01:00
forust aa516c3445 Merge pull request 'fix: errorpage styles fixes #11' (#12) from fix/errorpage-styles into main
Reviewed-on: #12
2026-01-25 23:39:10 +01:00
forust b5cc7d9a0d fix: errorpage styles fixes #11 2026-01-25 23:38:34 +01:00
forust 5dfa9c879b chore: comment-out errorpage ports 2026-01-24 01:24:18 +01:00
forust 3c5702a0fb Merge pull request 'feat/kener' (#9) from feat/kener into main
Reviewed-on: #9
2026-01-24 01:19:52 +01:00
forust dd0ca27f4f fix: add TZ env for checkmk 2026-01-24 01:16:24 +01:00
forust 7f7d0de090 fix: comment out kener ports 2026-01-24 00:58:09 +01:00
forust bee3f16cb2 fix: set restart policy of kener to Unelss stopped 2026-01-24 00:55:43 +01:00
forust 303d23968d fix: set restart policy of errorpages to Unelss stopped 2026-01-24 00:53:41 +01:00
forust b7ecf88052 fix: correct metube local router rule and fix TLS configuration comments 2026-01-23 17:54:00 +01:00
forust 5068591b8b fix: xdfnx's certificate 2026-01-23 17:48:00 +01:00
forust 8e57f21e44 feat: add traefik routers for kener (status subdomain) 2026-01-23 11:26:55 +01:00
forust 073d9ff569 feat: add initial kener instance (uptime monitoring) 2026-01-23 11:12:36 +01:00
forust c6d00e525b fix: comment-out CF Origin CA. (google trust service) 2026-01-22 00:51:15 +01:00
forust 539994a145 Merge branch 'feat/error-page' 2026-01-21 20:14:26 +01:00
forust 95f0afbbee feat: add traefik integration for error-handler
TODO: fix css
2026-01-21 20:14:02 +01:00
forust 9f86bd1142 feat: add errorpage-handler service (403,404, 500, 502-504) 2026-01-21 17:39:14 +01:00
forust af9668e8e6 md: archive overlayfs incident 2026-01-21 17:33:18 +01:00
forust 53b71a33ad fix: correct adguard traefik devrule 2026-01-21 11:41:37 +01:00
forust bf72007b14 feat: headplane (ui for headscale) 2026-01-21 10:09:31 +01:00
forust 0bad0a817e fix: bad request
Revert "chore: compose cleanup:"

This reverts nextcloud service's changes from commit 45ce789f58.
2026-01-20 15:11:06 +01:00
forust 525fed3b92 Merge pull request 'chore/compose-cleanup' (#8) from chore/compose-cleanup into main
Reviewed-on: #8
2026-01-20 00:17:42 +01:00
forust fe5e5c5e35 fix: aborted connection to portainer 2026-01-20 00:08:18 +01:00
forust 72aa022048 fix: "http plaintext sent to https" 2026-01-20 00:05:10 +01:00
forust f18d4d9be4 chore: use volumes to store data of
- portainer
- headscale
- termix
- uptime-kuma
- dockmon
2026-01-19 23:58:26 +01:00
forust 45ce789f58 chore: compose cleanup:
- Remove TZ envs
- +- unified compose structure
- Minify where possible
- Remove <service>.internal routers
- Remove service specifications where possible
Affected services:
- adguardhome
- authentik
- cfddns
- checkmk
- dockmon
- downtify
- gitea
- glance
- headscale
- homepages
- metube
- nextcloud
- penpot
- portainer
- termix
- traefik
- uptime-kuma

TODO: Move data from directory to volumes
2026-01-19 23:33:50 +01:00
forust d7c05fd058 fix: use webinar links to detect duplicates, supress logspam from telegram bot 2026-01-19 17:01:23 +01:00
forust c13056fba1 Merge pull request 'feat/checkmk' (#7) from feat/checkmk into main
Reviewed-on: #7
2026-01-19 15:59:21 +01:00
forust 5fe8af82d5 Revert "fix: clean up traefik configuration and add redirect middleware"
This reverts commit dc7fe64fbc.
2026-01-19 11:18:36 +01:00
forust 6d97246997 fix: change checkmk container restart policy to 'unless-stopped' 2026-01-19 00:08:16 +01:00
forust 6970279311 fix: remove checkmk port for docker cmk agent, using external agent 2026-01-18 23:09:41 +01:00
forust 02f4e0ab42 chore: switch to named volumes for site storing 2026-01-18 22:39:48 +01:00
forust 4a25622552 Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab into feat/checkmk 2026-01-18 19:28:12 +01:00
forust 0138fbd276 fix: update middleware for dev router in glance compose file 2026-01-18 19:24:41 +01:00
forust 94b5f39207 make glance dashboard public again 2026-01-18 00:19:39 +01:00
forust 403e88d548 fix: update NEXTCLOUD_DATADIR path to match new hardware 2026-01-17 17:44:11 +01:00
forust d03a4844fb chore: remove unused tailscale setup 2026-01-17 15:38:27 +01:00
forust 48ff08529e Merge pull request 'feat: add group support for webinar notifier' (#5) from feat/edu-group into main
Reviewed-on: #5
2026-01-14 16:46:37 +01:00
forust 81592b6142 feat: add group support for webinar notifier 2026-01-13 00:24:04 +01:00
forust 9480576966 fix: update bots' code to match .env keys 2026-01-12 15:33:42 +01:00
forust 9b43a9bef4 feat: add traefik configuration for external fileservers 2026-01-09 14:48:42 +01:00
forust 2b03335af5 chore: shorten aio subdomain 2026-01-06 19:04:23 +01:00
forust ea738ec14c Merge pull request 'add pgp pubkey to landing page, minor info changes' (#4) from chore/pgp into main
Reviewed-on: #4
2026-01-03 02:26:04 +01:00
forust e4e9d96a0b add pgp pubkey to landing page, minor info changes 2026-01-03 02:25:12 +01:00
forust 89576032b9 fix: update DNS settings for headscale 2026-01-03 01:39:05 +01:00
forust a9edfc0a25 fix: traefik middlewares for local and dev envs 2026-01-02 13:06:49 +01:00
forust acb8009307 Merge pull request 'feat: headscale implementation' (#3) from feat/tailscale into main
Reviewed-on: #3
2026-01-02 03:06:50 +01:00
forust 21f4e46028 chore: container name change for headscale web panel 2026-01-02 03:06:24 +01:00
forust 0234524635 feat: headscale implementation
- Headscale server
- Using goodieshq/headscale-admin as ui
- UI protected by security-chain
- Metrics available at /metrics
2026-01-02 01:29:51 +01:00
forust 26f5fb2fb9 Revert "feat: add gitea-basic-runner"
This reverts commit f0682319a7.
2026-01-02 00:38:04 +01:00
forust 122e6f6986 Revert "chore: gitea runners gitignored"
This reverts commit e0f7ab6561.
2026-01-02 00:37:47 +01:00
forust ce0bc4613a fix: headscale command 2026-01-01 03:19:45 +01:00
forust 8f4f460ff3 feat: add headscale server and UI configuration with Traefik routing 2026-01-01 02:39:28 +01:00
forust c048efd569 Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab into feat/tailscale 2026-01-01 01:45:57 +01:00
forust 1f1e13ff39 WIP: fix: update checkmk configuration for correct routing 2026-01-01 00:25:17 +01:00
forust c80a6c5351 Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab into feat/checkmk 2025-12-31 21:19:42 +01:00
forust 4fe3d660f1 Merge pull request 'Added DoT support on dns.forust.xyz' (#2) from fix/dns into main
Reviewed-on: #2
2025-12-31 03:44:08 +01:00
forust 9675eac2bf feat: add DoT support on dns.forust.xyz
- Made adguard available on adguard. and dns. subdomain
- DoT and DoH implementation complete
2025-12-31 03:41:38 +01:00
forust dc7fe64fbc fix: clean up traefik configuration and add redirect middleware 2025-12-30 22:44:52 +01:00
forust 502810a12e fix: update traefik router rules for DNS and adjust letsencrypt volume path 2025-12-30 17:20:13 +01:00
forust c047cc291d LE for DoH 2025-12-30 16:54:23 +01:00
forust f2b951673c fix: add traefik route for dns o https 2025-12-30 15:47:39 +01:00
forust 6b7c0df586 Merge pull request 'refactor: update network refs form "traefik-proxy" to "proxy"' (#1) from refactor/traefik-network into main
Reviewed-on: #1
2025-12-30 14:26:09 +01:00
forust fb2f420520 refactor: update network refs form "traefik-proxy" to "proxy"
- To allow testing dev vers of other services
2025-12-30 14:14:24 +01:00
forust 60c7d4ff17 feat: add mailer support and credentials for gitea 2025-12-28 23:35:31 +01:00
forust d20ec696a3 tcp router for gite ssh 2025-12-26 21:02:47 +01:00
forust c030b4cffa Merge branch 'main' into fix/gitea-ssh 2025-12-26 20:58:22 +01:00
forust e0f7ab6561 chore: gitea runners gitignored 2025-12-25 20:00:22 +01:00
forust f0682319a7 feat: add gitea-basic-runner 2025-12-25 19:58:38 +01:00
forust dbd3f36f76 kek 2025-12-25 19:54:32 +01:00
forust 4471da827c kek 2025-12-25 19:41:25 +01:00
forust 163ea867ce feat: downtify service, traefik integration 2025-12-20 02:00:10 +01:00
forust dc75dbaf7c chore: disable logging on pre-dormant period 2025-12-17 03:03:32 +01:00
forust db0f0f7bb6 feat: tailscale server 2025-12-14 02:43:00 +01:00
forust 5e4c60bb30 chore (security): port hardening, commented out non-critical ports 2025-12-14 02:42:46 +01:00
forust 17cae71952 refactor: userbots' compose-files cleanup 2025-12-09 21:45:24 +01:00
forust f4c695f95e feat: security-chain (authentik) on no-login services, SSO traefik dashboard 2025-12-09 15:14:42 +01:00
forust 6bdb16ad21 chore: whitespaces 2025-12-09 13:23:59 +01:00
forust 6eb62f41cc fix: authentik-traefik dns lookup 2025-12-09 13:23:48 +01:00
forust 586f0e3f7d feat: authenitk-only access for metube 2025-12-09 03:58:36 +01:00
forust 0e46193f53 chore: replace sec middlewares with sec chain 2025-12-09 03:57:13 +01:00
forust aed28ed15c refactor: sepparate traefik and authentik 2025-12-09 03:52:20 +01:00
forust f3b73bae11 feat: authentik service+envfile for traefik and authentik 2025-12-08 01:29:00 +01:00
forust be049cfa0d chore: gitignore checkmk's files 2025-12-08 00:34:08 +01:00
forust bfb21adff7 using local directory for storing chkmk data 2025-12-07 22:06:30 +01:00
forust 1c75382a6e fix: replace container_name to avoid misunderstandings 2025-12-07 04:14:56 +01:00
forust 9c5e037567 refactor: switch to official checkmk dockercompose 2025-12-07 04:10:32 +01:00
forust 4f9e7ea990 feat: nextcloud beta 2025-12-07 03:51:43 +01:00
forust 9f784d2c31 chore: gitignore checkmk's files 2025-12-07 02:59:18 +01:00
forust a07e27bff6 feat: checkmk service 2025-12-07 02:44:12 +01:00
forust c31369f2b7 add certfiles for xdfnx landing 2025-12-07 02:28:55 +01:00
forust 3bfcd6edf4 feat: add xdfnx's landing site 2025-12-07 02:17:19 +01:00
forust 504cbc81a0 fix: nextcloud traefik router parse error 2025-12-07 02:16:59 +01:00
forust aa7239ee83 refactor: traefik dynamic configuration 2025-12-07 01:39:58 +01:00
forust 6d9427cf2a chore: add traefik dev rule for main nextcloud 2025-12-06 02:34:19 +01:00
forust 70d60ed40a chore: add traefik dev rule for main nextcloud 2025-12-06 02:33:10 +01:00
forust aca6824309 chore: comments cleanup 2025-12-06 01:31:50 +01:00
forust d48a01775d fix: routers for nextcloud frontend 2025-12-06 00:38:16 +01:00
forust 99d50b43fe fix: traefik service 2025-12-05 21:00:55 +01:00
forust 23e955bde7 chore: new auth credentials, revert to info logging 2025-12-05 20:33:14 +01:00
forust 7d7a0e5c8a fix: penpot non- latin symbol 2025-12-05 18:53:19 +01:00
forust a767107277 fix: refering to file-defined middlewares 2025-12-05 14:36:57 +01:00
forust e68e37c285 refactor: use internal tld according to ICAAN 2025-12-05 04:30:51 +01:00
forust fc6a11397b refactor: move traefik configuration to docker-compose files via labels 2025-12-05 04:03:24 +01:00
forust d776124f26 chore: unignored examples, example env for penpot 2025-12-04 20:25:32 +01:00
forust 8a0ed85e7c feat: Penpoint service, traefik integration 2025-12-04 20:04:49 +01:00
forust 15f0f35ce4 chore: exclude prometheus config 2025-12-03 04:23:34 +01:00
forust 1ec145d4cf chore: add xdfnx domain to team links 2025-12-03 00:30:06 +01:00
forust 8b6815f314 chore: gitignored temp folder for references 2025-12-02 13:10:13 +01:00
forust 2d05a1911c feat: cloudflare IP sync (ddns) 2025-11-30 03:28:41 +01:00
forust 6f2f70ad09 chore: ingore streaming services 2025-11-28 12:59:15 +01:00
forust 6cb49be951 chore: update gitignore, add translations 2025-11-27 18:58:17 +01:00
forust 3bcabcce4b feat: notify on new webinar, storing N last webinars in redis 2025-11-27 16:33:15 +01:00
forust a54b7b000f fix: add timeout for playwright to load page and debug response saving 2025-11-27 15:50:12 +01:00
forust 5b1fa11b5e chore: switching to latest release of portainer 2025-11-27 02:13:54 +01:00
forust 380288d103 fix: port conflicts 2025-11-26 22:55:16 +01:00
forust 8fae8bf75d feat: add admin commands to manage whitelist, extended webinar reporting 2025-11-26 22:25:47 +01:00
forust d4e0e7f37a feat: telegram webianr checker bot 2025-11-26 22:12:03 +01:00
forust cb92bff0c5 feat: basic webinar checker bot 2025-11-26 21:08:43 +01:00
forust bfbe841154 feat: redis integration 2025-11-26 20:53:53 +01:00
forust 7cbc5bf4f3 feat: edu phpsessid updater bot 2025-11-26 17:00:44 +01:00
forust 258ed04f98 feat: miku 2025-11-26 14:23:56 +01:00
forust 7ee0a1e7a2 feat: repos added to forust homepage 2025-11-26 11:51:09 +01:00
forust 739915c451 feat: team avatars 2025-11-26 01:20:12 +01:00
forust 68b5467a37 chore: exclude team images from project 2025-11-26 01:19:50 +01:00
forust 61e6b2b5c7 chore: reformat discord username to unified style 2025-11-26 01:01:37 +01:00
forust 272666d2fd fix: container url forust-homepage 2025-11-26 00:44:58 +01:00
forust aca485836b fix: service names 2025-11-26 00:42:10 +01:00
forust 02671b9117 feat: traefik dynamic files for forust-homepage 2025-11-26 00:10:35 +01:00
forust 1c1170ca96 refactor: change names for forust-homepage 2025-11-26 00:09:12 +01:00
forust 3eab3b254a Merge branch 'main' into landing 2025-11-26 00:02:37 +01:00
forust 5d9fc18ba5 feat: docker deployment 2025-11-26 00:01:43 +01:00
forust 03d39f8a32 chore: separate docker files from nginx 2025-11-25 23:59:15 +01:00
forust 7b60630d76 feat: new landing page matching xrock style 2025-11-25 23:48:12 +01:00
forust cc20d848f8 chore: vv pass 2025-11-25 19:29:12 +01:00
forust 1040e4fdf7 chore: termix-data to gitignore 2025-11-20 03:44:50 +01:00
forust 7af5af0630 fix: Return glance service 2025-11-18 15:42:51 +01:00
forust 8f9be8a478 fix: Misplaced comment 2025-11-18 15:20:53 +01:00
forust d1adaa54b1 feat: Add routers for local access 2025-11-18 15:19:27 +01:00
forust b3b0d5b553 refactor: sepparate traefik production routers 2025-11-18 15:13:45 +01:00
forust d22ef0cb44 feat: Add routers for local testing 2025-11-18 15:04:06 +01:00
forust 7c29d74c72 refactor: Removed routers from dynamic. Use .bak for references 2025-11-18 14:47:59 +01:00
forust b2ae170ea0 refactor: Sepparate traefik middlewares 2025-11-18 14:45:30 +01:00
forust 2c2474165b refactor: Sepparate traefik tls configuration 2025-11-18 14:43:49 +01:00
forust 155bb8d02b refactor: Sepparate traefik services 2025-11-18 14:41:41 +01:00
forust ed1b41ca1d feat: Add termix ssh manager, traefik routers 2025-11-18 03:34:55 +01:00
forust 9bb5f070e3 chore: gitignore for termix 2025-11-18 03:34:34 +01:00
forust b238e9ccf2 chore: Env example 2025-11-18 03:23:57 +01:00
forust 09a05a5ad5 chore: remove commented docker labels 2025-11-18 02:34:55 +01:00
forust 5b05207985 refactor: Move uptime-kuma traefik labels to dynamic.yaml 2025-11-18 02:30:42 +01:00
forust 319cf0ea1f feat: Add demo landing page 2025-11-18 02:12:48 +01:00
forust 6843befac3 fix: temp labels for routing SSH for gitea 2025-11-16 00:01:35 +01:00
forust 0dfb09590d fix: added ssh domain 2025-11-15 23:55:02 +01:00
forust 625eb9561b testing: removed https:// from root url 2025-11-15 23:48:35 +01:00
forust b367b64879 fix: removed ssh port-forwarding for gitea 2025-11-15 23:42:03 +01:00
108 changed files with 6410 additions and 1706 deletions
+70
View File
@@ -0,0 +1,70 @@
#===============================
# Basic auth credentials
#===============================
#
#
#
#===============================
#===============================================
#BEGIN TRAEFIK ENVIRONMENT VARIABLES ===========
#===============================================
#===============================================
# General Traefik Environment Variables
#===============================================
HOST=hostname
EMAIL=your@email.here
CF_DNS_API_TOKEN=API_TOKEN_HERE
CF_EMAIL=your_cloudflare@email.here
TZ=Europe/Berlin
#===============================================
# Dockmon Traefik Configuration File
#===============================================
DOCKMON_APPNAME=dockmon
DOCKMON_SUBDOMEN=dockmon
#===============================================
# Dashboard Traefik Environment Variables
#===============================================
DASHBOARD_APPNAME=traefik
DASHBOARD_SUBDOMEN=traefik
#===============================================
# Watercrawl Traefik Environment Variables
#===============================================
WATERCRAWL_APPNAME=watercrawl
WATERCRAWL_SUBDOMEN=watercrawl
#===============================================
# n8n Traefik Environment Variables
#===============================================
N8N_APPNAME=n8n
N8N_SUBDOMEN=n8n
#===============================================
# Glance Traefik Environment Variables
#===============================================
GLANCE_APPNAME=glance
GLANCE_SUBDOMEN=glance
#===============================================
# AdGuard Traefik Environment Variables
#===============================================
ADGUARD_APPNAME=adguard
ADGUARD_SUBDOMEN=adguard
#===============================================
# Portainer Traefik Environment Variables
#===============================================
PORTAINER_APPNAME=portainer
PORTAINER_SUBDOMEN=portainer
#===============================================
# Nextcloud Traefik Environment Variables
#===============================================
NEXTCLOUD_APPNAME=nextcloud
NEXTCLOUD_SUBDOMEN=nextcloud
#===============================================
# Aio Traefik Environment Variables
#===============================================
NEXTCLOUD_AIO_APPNAME=nextcloud-aio
NEXTCLOUD_AIO_SUBDOMEN=nextcloud-aio
# END OF TRAEFIK ENVIRONMENT VARIABLES
#===============================================
+39
View File
@@ -0,0 +1,39 @@
name: Deploy to Server
run-name: Deploying to ${{ runner.os}} server on ${{ gitea.ref }}
on:
push:
branches:
- main
- ci/gitea-actions
jobs:
deploy:
runs-on: prod
steps:
- name: Fetch and Diff Analysis
id: diff
run: |
cd ${{ secrets.PROD_DIR }}
git fetch origin main
CHANGES=$(git diff --name-only HEAD origin/main | cut -d/ -f1 | sort -u | tr '\n' ' ')
echo "dirs=$CHANGES" >> $GITHUB_OUTPUT
echo "Changed dirs: $CHANGES"
- name: Sync Server Files
run: |
cd ${{ secrets.PROD_DIR }}
git reset --hard origin/main
echo "Server files synced with origin/main"
- name: Deploy Services
run: |
cd ${{ secrets.PROD_DIR }}
for dir in ${{ steps.diff.outputs.dirs }}; do
if [ -d "$dir" ] && ([ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yaml" ]); then
echo ">>> Deploying $dir"
cd "$dir"
DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=plain docker compose up -d --build --no-color
cd ..
else
echo ">>> Skipping $dir: no compose file found"
fi
done
+41
View File
@@ -0,0 +1,41 @@
## BINARY MODE, USE WITH THE GITHUB RUNNER BINARY INSTALLED ON THE SERVER
name: Deploy to Server
run-name: Deploying onto server on ${{ github.ref }}
on:
push:
branches:
- main
- ci/actions
jobs:
deploy:
runs-on: [prod, self-hosted]
steps:
- name: Fetch and Diff Analysis
id: diff
run: |
cd ${{ secrets.PROD_DIR }}
git fetch origin main
CHANGES=$(git diff --name-only HEAD origin/main | cut -d/ -f1 | sort -u | tr '\n' ' ')
echo "dirs=$CHANGES" >> $GITHUB_OUTPUT
echo "Changed dirs: $CHANGES"
- name: Sync Server Files
run: |
cd ${{ secrets.PROD_DIR }}
git reset --hard origin/main
echo "Server files synced with origin/main"
- name: Deploy Services
run: |
cd ${{ secrets.PROD_DIR }}
for dir in ${{ steps.diff.outputs.dirs }}; do
if [ -d "$dir" ] && ([ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yaml" ]); then
echo ">>> Deploying $dir"
cd "$dir"
DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=plain docker compose up -d --build --no-color
cd ..
else
echo ">>> Skipping $dir: no compose file found"
fi
done
+92 -24
View File
@@ -2,31 +2,99 @@
sync.ffs_lock
.sync.ffs_db
# Copyparty
*.hist/
# Volumes, configs and data directories
gitea/gitea-db/
gitea/gitea-data/*
n8n/n8n-data/*
n8n/n8n-node-data/*
adguardhome/conf/*
dockmon/data/*
portainer/portainer_data/*
metube/MeTube_downloads
uptime-kuma/data/
termix/termix-data/*
cfddns/config.json
checkmk/checkmk/*
downtify/Downtify_downloads
headscale/config/*
headscale/data/*
searxng/core-config/*
# Steaming services files
streaming/jellyfin/*
streaming/jellyseerr/*
streaming/sonarr/*
streaming/radarr/*
streaming/data/*
streaming/qbittorrent/*
streaming/prowlarr/*
# Homepage
homepages/forust_files/.well-known/*
# Traefik files
traefik/letsencrypt/acme.json
traefik/dynamic/fileservers.yml
traefik/dynamic/*.local.y*ml.*
traefik/dynamic/*.external.y*ml
traefik/logs/*
# SSL Certificates
adguardhome/certs/*
traefik/certs/*
# Monitoring
monitoring/prometheus.yml
# Python
.python-version
venv/
pyc
unknown_errors.txt
moonlogs.txt
thumb.jpg
antipm_pic.jpg
musicbot/
.trunk/
previous_profiles/
.python-version
/modules/__pycache__/
__pycache__/
*.session
*.session-old
*.db
*.sqlite3
*-journal
/venv/
.venv/
# DataSecurity
replacements.txt
# Vscode
.vscode
# Git
.gitattributes
# Gitea/github Runners
.runner
# Misc
.DS_Store
.idea
# Temp files
edu_master/temp/
temp/*
# Environment
.env
.env.anna
.env.forust
# Volumes and data directories
gitea/gitea-db/
gitea/gitea-data/*
n8n/n8n-data/*
n8n/n8n-node-data/*
adguardhome/data/*
dockmon/data/*
portainer/portainer_data/*
metube/MeTube_downloads
uptime-kuma/data/
# Traefik files
traefik/letsencrypt/acme.json
traefik/logs/*
traefik/certs/*
# Python
.python-version
.venv/
venv/
#DataSecurity
replacements.txt
.env.*
!*example
+31 -11
View File
@@ -3,26 +3,46 @@ services:
image: adguard/adguardhome:latest
container_name: adguardhome
restart: unless-stopped
environment:
- TZ=${TZ}
ports:
- "53:53/tcp"
- "53:53/udp"
- "853:853/tcp" # DNS over TLS
# - "67:67/udp" # DHCP
# - "68:68/tcp" # DHCP
- "3000:3000/tcp"
# - "3000:3000/tcp"
volumes:
- ./data/work:/opt/adguardhome/work
- ./data/conf:/opt/adguardhome/conf
networks:
- traefik-proxy
- data:/opt/adguardhome/work
- ./conf:/opt/adguardhome/conf
- ./certs:/certs:ro
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- "traefik.http.services.adguard.loadbalancer.server.port=3000"
# Prod Router
- "traefik.http.routers.adguard.rule=Host(`dns.forust.xyz`) || Host(`adguard.forust.xyz`)"
- "traefik.http.routers.adguard.entrypoints=websecure"
- "traefik.http.routers.adguard.tls=true"
# Local Router
- "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`dns.workstation.internal`)"
- "traefik.http.routers.adguard-local.entrypoints=websecure"
- "traefik.http.routers.adguard-local.tls=true"
# Dev Router
- "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`) || Host(`dns.gigaforust.internal`)"
- "traefik.http.routers.adguard-dev.entrypoints=websecure"
- "traefik.http.routers.adguard-dev.tls=true"
# DoH Router
- "traefik.http.routers.dns.rule=(Host(`dns.forust.xyz`) && PathPrefix(`/dns-query`))"
- "traefik.http.routers.dns.entrypoints=websecure"
- "traefik.http.routers.dns.tls.certresolver=letsencrypt"
# Glance Metadata
- glance.name=adguard
# - glance.icon=si:adguard
- glance.url=https://adguard.forust.xyz/
- glance.description=AdGuard Home is a network-wide software for blocking ads.
networks:
- proxy
volumes:
data:
networks:
traefik-proxy:
external: true
proxy:
external: true
+20
View File
@@ -0,0 +1,20 @@
# ===================================
# Authentification app (authentik)
# PostgresQL conf
PG_PASS=change_this_cuz_its_ur_db_pass
PG_USER=authentik # it's okay
# Image Settings
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
AUTHENTIK_TAG=2025.10.2
# Networking
PORT_HTTP=9000
PORT_HTTPS=9443 # btw likely already used by portainer
AUTHENTIK_SECRET_KEY=super_secret_super_scary_authenik_key
AUTHENTIK_BOOTSTRAP_PASSWORD=pls_change_this
AUTHENTIK_ERROR_REPORTING__ENABLED=true # Or false to turn off
+95
View File
@@ -0,0 +1,95 @@
services:
postgresql:
image: docker.io/library/postgres:15-alpine
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_DB: ${PG_DB:-authentik}
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
healthcheck:
interval: 30s
retries: 5
start_period: 20s
test:
- CMD-SHELL
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
timeout: 5s
volumes:
- database:/var/lib/postgresql/data
networks:
- authentik
server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
command: server
container_name: authentik-server
restart: unless-stopped
# ports:
# - ${PORT_HTTP:-9000}:9000
# - ${PORT_HTTPS:-9443}:9443
env_file:
- .env
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
volumes:
- ./media:/media
- ./custom-templates:/templates
labels:
- "traefik.enable=true"
- "traefik.http.services.authentik-server.loadbalancer.server.port=9000"
# Prod Router
- "traefik.http.routers.authentik-server.rule=Host(`auth.forust.xyz`)"
- "traefik.http.routers.authentik-server.entrypoints=websecure"
- "traefik.http.routers.authentik-server.tls=true"
# Local Router
- "traefik.http.routers.authentik-server-local.rule=Host(`auth.workstation.internal`)"
- "traefik.http.routers.authentik-server-local.entrypoints=websecure"
- "traefik.http.routers.authentik-server-local.tls=true"
# Dev Router
- "traefik.http.routers.authentik-server-dev.rule=Host(`auth.gigaforust.internal`)"
- "traefik.http.routers.authentik-server-dev.entrypoints=websecure"
- "traefik.http.routers.authentik-server-dev.middlewares=security-headers@file"
- "traefik.http.routers.authentik-server-dev.tls=true"
networks:
- proxy
- authentik
depends_on:
postgresql:
condition: service_healthy
worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
restart: unless-stopped
user: root
command: worker
env_file:
- .env
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./media:/media
- ./certs:/certs
- ./custom-templates:/templates
networks:
- authentik
depends_on:
postgresql:
condition: service_healthy
volumes:
database:
driver: local
networks:
authentik:
proxy:
external: true
+15
View File
@@ -0,0 +1,15 @@
CLOUDFLARE_API_TOKEN=YOUR_CLOUDFLARE_API_TOKEN
DOMAINS=example.com,dns.example.com,mc.example.com,auth.example.com,ssh.example.com
IP4_DOMAINS=
IP6_DOMAINS=
IP4_PROVIDER=cloudflare.trace
IP6_PROVIDER=none # change if you want to update AAAA
UPDATE_CRON=@every 5m
UPDATE_ON_START=true
DELETE_ON_STOP=false
DELETE_ON_FAILURE=true
TTL=1
PROXIED=!is(dns.example.com) && !is(mc.example.com) && !is(ssh.example.com)
EMOJI=true
UPTIMEKUMA=https://uptime-kuma.example.com/api/push/AsaSDFGFkfklaFALSKffkfFKfkfkfkFK?status=up&msg=OK&ping=
REJECT_CLOUDFLARE_IPS=true
+30
View File
@@ -0,0 +1,30 @@
services:
cloudflare-ddns:
image: timothyjmiller/cloudflare-ddns:latest
container_name: cloudflare-ddns
restart: unless-stopped
security_opt:
- no-new-privileges:true
network_mode: 'host'
# https://github.com/timothymiller/cloudflare-ddns#-quick-start
environment:
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?Cloudflare API token is required}
- DOMAINS=${DOMAINS:-}
- IP4_DOMAINS=${IP4_DOMAINS:-}
- IP6_DOMAINS=${IP6_DOMAINS:-}
- IP4_PROVIDER=${IP4_PROVIDER:-cloudflare.trace}
- IP6_PROVIDER=${IP6_PROVIDER:-none}
- UPDATE_CRON=${UPDATE_CRON:-@every 5m}
- UPDATE_ON_START=${UPDATE_ON_START:-true}
- DELETE_ON_STOP=${DELETE_ON_STOP:-false}
- DELETE_ON_FAILURE=${DELETE_ON_FAILURE:-true}
- TTL=${TTL:-1} # 1=auto
# to proxy only "dns.example.com" and "wfs.example.com" use "!is(dns.domain.com) && !is (wfs.domain.com)"
- PROXIED=${PROXIED:-true}
- EMOJI=${EMOJI:-true}
- UPTIMEKUMA=${UPTIMEKUMA:-}
- HEALTHCHECKS=${HEALTHCHECKS:-}
- REJECT_CLOUDFLARE_IPS=${REJECT_CLOUDFLARE_IPS:-true}
# volumes:
# Prefer using environment variables for configuration, config.json legacy support
# - ./config.json:/config.json
+22
View File
@@ -0,0 +1,22 @@
{
"cloudflare": [
{
"authentication": {
"api_token": "API_TOKEN"
},
"api_key": {
"api_key": "api_key_here",
"account_email": "your_email_here"
}
"zone_id": "your_zone-id",
"subdomains": [
{ "name": "", "proxied": true },
{ "name": "www", "proxied": true }
]
}
],
"a": true,
"aaaa": false,
"purgeUnknownRecords": false,
"ttl": 300
}
+2
View File
@@ -0,0 +1,2 @@
CMK_PASSWORD=password
TZ=Europe/Berlin
+39
View File
@@ -0,0 +1,39 @@
services:
checkmk:
image: "checkmk/check-mk-raw:2.4.0-latest"
container_name: "checkmk"
restart: unless-stopped
# ports:
# - 5000:5000
# - 6776:8000
volumes:
- sites:/omd/sites
tmpfs:
- /opt/omd/sites/cmk/tmp:uid=1000,gid=1000
environment:
- CMK_PASSWORD=${CMK_PASSWORD:-password}
- CMK_SITE_ID=cmk
- TZ=${TZ:-Etc/UTC}
labels:
- "traefik.enable=true"
- "traefik.http.services.checkmk.loadbalancer.server.port=5000"
# Prod Router
- "traefik.http.routers.checkmk.rule=Host(`cmk.forust.xyz`)"
- "traefik.http.routers.checkmk.entrypoints=websecure"
- "traefik.http.routers.checkmk.tls=true"
# Local Router
- "traefik.http.routers.checkmk-local.rule=Host(`cmk.workstation.internal`)"
- "traefik.http.routers.checkmk-local.entrypoints=websecure"
- "traefik.http.routers.checkmk-local.tls=true"
# Dev Router
- "traefik.http.routers.checkmk-dev.rule=Host(`cmk.gigaforust.internal`)"
- "traefik.http.routers.checkmk-dev.entrypoints=websecure"
- "traefik.http.routers.checkmk-dev.tls=true"
networks:
- proxy
networks:
proxy:
external: true
volumes:
sites:
+32 -16
View File
@@ -3,28 +3,44 @@ services:
image: darthnorse/dockmon:latest
container_name: dockmon
restart: unless-stopped
ports:
- 8000:443
environment:
- TZ=Europe/Bratislava
# ports:
# - 8000:443
volumes:
- ./data:/app/data
- data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
healthcheck:
test: ["CMD", "curl", "-k", "-f", "https://localhost:443/health"]
test: [ "CMD", "curl", "-k", "-f", "https://localhost:443/health" ]
interval: 30s
timeout: 10s
retries: 3
networks:
- traefik-proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- glance.name=dockmon
# - glance.icon=sh:dockmon
- glance.url=https://dockmon.forust.xyz/
- glance.description=Dockmon is a lightweight Docker container monitoring and management tool with a user-friendly web interface.
- "traefik.enable=true"
- "traefik.http.services.dockmon.loadbalancer.server.port=443"
- "traefik.http.services.dockmon.loadbalancer.server.scheme=https"
- "traefik.http.services.dockmon.loadbalancer.serverstransport=insecureTransport@file"
# Prod Router
- "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)"
- "traefik.http.routers.dockmon.entrypoints=websecure"
- "traefik.http.routers.dockmon.middlewares=security-headers@file"
- "traefik.http.routers.dockmon.tls=true"
# Local Router
- "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.internal`)"
- "traefik.http.routers.dockmon-local.entrypoints=websecure"
- "traefik.http.routers.dockmon-local.tls=true"
# Dev Router
- "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.internal`)"
- "traefik.http.routers.dockmon-dev.entrypoints=websecure"
- "traefik.http.routers.dockmon-dev.tls=true"
# Glance Metadata
- glance.name=dockmon
- glance.url=https://dockmon.forust.xyz/
- glance.description=Dockmon is a lightweight Docker container monitoring and management tool with a user-friendly web interface.
networks:
- proxy
volumes:
data:
networks:
traefik-proxy:
external: true
proxy:
external: true
+31
View File
@@ -0,0 +1,31 @@
services:
downtify:
container_name: downtify
image: ghcr.io/henriquesebastiao/downtify:latest
restart: unless-stopped
# ports:
# - '7077:8000'
volumes:
- ./Downtify_downloads:/downloads
labels:
- "traefik.enable=true"
- "traefik.http.services.downtify.loadbalancer.server.port=8000"
# Prod Router
- "traefik.http.routers.downtify.rule=Host(`downtify.forust.xyz`)"
- "traefik.http.routers.downtify.entrypoints=websecure"
- "traefik.http.routers.downtify.middlewares=security-chain@file"
- "traefik.http.routers.downtify.tls=true"
# Local Router
- "traefik.http.routers.downtify-local.rule=Host(`downtify.workstation.internal`)"
- "traefik.http.routers.downtify-local.entrypoints=websecure"
- "traefik.http.routers.downtify-local.tls=true"
# Dev Router
- "traefik.http.routers.downtify-dev.rule=Host(`downtify.gigaforust.internal`)"
- "traefik.http.routers.downtify-dev.entrypoints=websecure"
- "traefik.http.routers.downtify-dev.tls=true"
networks:
- proxy
networks:
proxy:
external: true
+3 -2
View File
@@ -3,12 +3,13 @@ services:
build:
context: .
dockerfile: Dockerfile
image: gcr.forust.xyz/forust/dtek-notif:latest
pull_policy: build
restart: unless-stopped
environment:
- TZ=Europe/Kyiv
dns:
- 1.1.1.1
- 8.8.8.8
networks:
- default
- default
+13
View File
@@ -0,0 +1,13 @@
EDU_LOGIN=your_edu_login_here
EDU_PASSWORD=your_edu_password_here
EDU_URL_LOGIN=https://edu.edu.vn.ua/user/login
EDU_URL_VERIFY=https://edu.edu.vn.ua/course/userlist
PHPSESSID_INTERVAL=10
USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
WEBINAR_URL=https://edu.edu.vn.ua/webinar/useractive
WEBINAR_CHECK_INTERVAL=60
REDIS_HOST=redis
REDIS_PORT=6379
PLAYWRIGHT_WS=ws://playwright-service:3000/ws
WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here
WEBINAR_ADMIN_ID=123456789
-15
View File
@@ -1,15 +0,0 @@
ARG VERSION
# Use the official WaterCrawl image as the base image
FROM watercrawl/watercrawl:${VERSION:-v0.10.2}
# Set working directory
WORKDIR /var/www
# Copy the extra requirements file
COPY extra_requirements.txt /var/www/extra_requirements.txt
# Install any additional packages
RUN poetry run pip install -r /var/www/extra_requirements.txt
# The rest of the configuration is inherited from the base image
# The entrypoint and command should be defined in docker-compose.yml
@@ -1 +0,0 @@
# Add your additional Python packages here, one per line
+36 -249
View File
@@ -1,262 +1,49 @@
x-app: &app
build:
context: ./backend/
dockerfile: Dockerfile
args:
- VERSION=${VERSION:-v0.10.2}
depends_on:
db:
condition: service_healthy
dns:
- 8.8.8.8
- 1.1.1.1
environment:
- SECRET_KEY=${SECRET_KEY:-django-insecure-el4wo4a4--=f0+ag#omp@^w4eq^8v4(scda&1a(td_y2@=sh6&}
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-8zSd6JIuC7ovfZ4AoxG_XmhubW6CPnQWW7Qe_4TD1TQ=}
- DEBUG=${DEBUG:-True}
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-*}
- LANGUAGE_CODE=${LANGUAGE_CODE:-en-us}
- TIME_ZONE=${TIME_ZONE:-UTC}
- USE_I18N=${USE_I18N:-True}
- USE_TZ=${USE_TZ:-True}
- STATIC_ROOT=${STATIC_ROOT:-storage/static/}
- MEDIA_ROOT=${MEDIA_ROOT:-storage/media/}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/1}
- DATABASE_URL=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
- REDIS_LOCKER_URL=${REDIS_LOCKER_URL:-redis://redis:6379/3}
- MINIO_ENDPOINT=minio:9000
- MINIO_EXTERNAL_ENDPOINT=nginx
- MINIO_REGION=us-east-1
- MINIO_ACCESS_KEY=minio
- MINIO_SECRET_KEY=minio123
- MINIO_USE_HTTPS=False
- MINIO_EXTERNAL_ENDPOINT_USE_HTTPS=False
- MINIO_URL_EXPIRY_HOURS=7
- MINIO_PRIVATE_BUCKET=private
- MINIO_PUBLIC_BUCKET=public
- CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:-}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
- CORS_ALLOWED_ORIGIN_REGEXES=${CORS_ALLOWED_ORIGIN_REGEXES:-}
- CORS_ALLOW_ALL_ORIGINS=${CORS_ALLOW_ALL_ORIGINS:-False}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost}
- IS_LOGIN_ACTIVE=${IS_LOGIN_ACTIVE:-True}
- IS_SIGNUP_ACTIVE=${IS_SIGNUP_ACTIVE:-True}
- IS_GITHUB_LOGIN_ACTIVE=${IS_GITHUB_LOGIN_ACTIVE:-True}
- IS_GOOGLE_LOGIN_ACTIVE=${IS_GOOGLE_LOGIN_ACTIVE:-True}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- ACCESS_TOKEN_LIFETIME_MINUTES=${ACCESS_TOKEN_LIFETIME_MINUTES:-5}
- REFRESH_TOKEN_LIFETIME_DAYS=${REFRESH_TOKEN_LIFETIME_DAYS:-30}
- EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}
- EMAIL_HOST=${EMAIL_HOST:-}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-}
- SCRAPY_USER_AGENT=${SCRAPY_USER_AGENT:-WaterCrawl/0.1 (+https://github.com/watercrawl/watercrawl)}
- SCRAPY_ROBOTSTXT_OBEY=${SCRAPY_ROBOTSTXT_OBEY:-True}
- SCRAPY_CONCURRENT_REQUESTS=${SCRAPY_CONCURRENT_REQUESTS:-16}
- SCRAPY_DOWNLOAD_DELAY=${SCRAPY_DOWNLOAD_DELAY:-0}
- SCRAPY_CONCURRENT_REQUESTS_PER_DOMAIN=${SCRAPY_CONCURRENT_REQUESTS_PER_DOMAIN:-4}
- SCRAPY_CONCURRENT_REQUESTS_PER_IP=${SCRAPY_CONCURRENT_REQUESTS_PER_IP:-4}
- SCRAPY_COOKIES_ENABLED=${SCRAPY_COOKIES_ENABLED:-False}
- SCRAPY_HTTPCACHE_ENABLED=${SCRAPY_HTTPCACHE_ENABLED:-True}
- SCRAPY_HTTPCACHE_EXPIRATION_SECS=${SCRAPY_HTTPCACHE_EXPIRATION_SECS:-3600}
- SCRAPY_HTTPCACHE_DIR=${SCRAPY_HTTPCACHE_DIR:-httpcache}
- SCRAPY_LOG_LEVEL=${SCRAPY_LOG_LEVEL:-ERROR}
- SCRAPY_GOOGLE_API_KEY=${SCRAPY_GOOGLE_API_KEY:-}
- SCRAPY_GOOGLE_CSE_ID=${SCRAPY_GOOGLE_CSE_ID:-}
- SCRAPY_MAX_NUMBER_OF_SITEMAP_URLS=${SCRAPY_MAX_NUMBER_OF_SITEMAP_URLS:-20000}
- SCRAPY_SITEMAP_CRAWL_PAGE_LIMIT=${SCRAPY_SITEMAP_CRAWL_PAGE_LIMIT:-100}
- PLAYWRIGHT_SERVER=${PLAYWRIGHT_SERVER:-http://playwright:8000}
- PLAYWRIGHT_API_KEY=${PLAYWRIGHT_API_KEY:-your-secret-api-key}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID:-}
- IS_ENTERPRISE_MODE_ACTIVE=${IS_ENTERPRISE_MODE_ACTIVE:-False}
- MAX_CRAWL_DEPTH=${MAX_CRAWL_DEPTH:--1}
- CAPTURE_USAGE_HISTORY=${CAPTURE_USAGE_HISTORY:-True}
- MCP_SERVER=${MCP_SERVER:-http://localhost/sse}
networks:
- traefik-proxy
- default
- n8n
x-frontend: &frontend
image: watercrawl/frontend:${VERSION:-v0.10.2}
environment:
- VITE_API_BASE_URL=${API_BASE_URL:-http://localhost/api}
depends_on:
- app
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf.template
- ./nginx/entrypoint.sh:/entrypoint.sh
environment:
- MINIO_PRIVATE_BUCKET=${MINIO_PRIVATE_BUCKET:-private}
- MINIO_PUBLIC_BUCKET=${MINIO_PUBLIC_BUCKET:-public}
command: ["/bin/sh", "/entrypoint.sh"]
depends_on:
- app
- frontend
- minio
restart: unless-stopped
networks:
- traefik-proxy
- n8n
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
app:
<<: *app
command: [ "gunicorn", "-b", "0.0.0.0:9000", "-w", "2", "watercrawl.wsgi:application", "--access-logfile", "-", "--error-logfile", "-", "--timeout", "60" ]
celery:
<<: *app
command: [ "celery", "-A", "watercrawl", "worker", "-l", "info", "-S", "django" ]
dns:
- 1.1.1.1
- 8.8.8.8
celery-beat:
<<: *app
command: [ "celery", "-A", "watercrawl", "beat", "-l", "info", "-S", "django" ]
frontend:
<<: *frontend
command: [ "npm", "run", "serve" ]
minio:
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
redis:
image: redis:alpine
restart: unless-stopped
volumes:
- ./volumes/minio-data:/data
command: server /data --console-address ":9001"
environment:
- MINIO_BROWSER_REDIRECT_URL=${MINIO_BROWSER_REDIRECT_URL:-http://localhost/minio-console/}
- MINIO_SERVER_URL=${MINIO_SERVER_URL:-http://localhost/}
- MINIO_ROOT_USER=${MINIO_ACCESS_KEY:-minio}
- MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY:-minio123}
playwright:
image: watercrawl/playwright:1.1
restart: unless-stopped
user: root
environment:
- AUTH_API_KEY=${PLAYWRIGHT_API_KEY:-your-secret-api-key}
- PORT=${PLAYWRIGHT_PORT:-8000}
- HOST=${PLAYWRIGHT_HOST:-0.0.0.0}
dns:
- 8.8.8.8
- 1.1.1.1
networks:
- traefik-proxy
- n8n
db:
image: postgres:17.2-alpine3.21
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-postgres}
volumes:
- ./volumes/postgres-db:/var/lib/postgresql/data
- redis-data:/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready" ]
interval: 10s
timeout: 5s
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
mcp:
image: watercrawl/mcp:v1.2.0
playwright-service:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
restart: unless-stopped
command: [ "sse", "--base-url", "http://app:9000", '--port', '3000', '--endpoint', '/sse' ]
networks:
- n8n
command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws
redis:
image: redis:latest
session-keeper:
build: ./phpsessid-bot
image: gcr.forust.xyz/forust/session-keeper:latest
pull_policy: build
env_file: .env
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
healthcheck:
test: [ "CMD-SHELL", "redis-cli -h redis EXISTS EDU_PHPSESSID | grep -q 1" ]
interval: 30s
timeout: 5s
retries: 10
start_period: 60s
llm:
image: ollama/ollama:latest
webinar-checker:
build: ./webinar-checker
image: gcr.forust.xyz/forust/webinar-checker:latest
pull_policy: build
env_file: .env
restart: unless-stopped
volumes:
- ./volumes/ollama-models:/root/.ollama
environment:
- OLLAMA_DISABLE_TELEMETRY=true
- OLLAMA_KEEP_ALIVE=5m
- OLLAMA_HOST=0.0.0.0:11434
- OLLAMA_NUM_PARALLEL=1
- OLLAMA_MAX_LOADED_MODELS=1
dns:
- 1.1.1.1
- 8.8.8.8
networks:
- n8n
# docker exec -it edu_master-llm-1 ollama pull neural-chat:7b-q4
# docker exec -it edu_master-llm-1 ollama pull mistral:7b-q4
# lessons-bot:
# build:
# context: edu_master/lessons_bot/
# dockerfile: Dockerfile
# restart: unless-stopped
# environment:
# - LESSONS_BOT_TOKEN=${LESSONS_BOT_TOKEN}
# - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://n8n:5678/webhook-test/get-lessons}
# - N8N_SECRET=${N8N_SECRET:-your-secret-token-here}
# - WATERCRAWL_API_URL=${WATERCRAWL_API_URL:-http://app:9000/api}
# - PHPSESSID_BOT_URL=${PHPSESSID_BOT_URL:-http://phpsessid-bot:5000}
# - EDU_HOST=${EDU_HOST:-edu.edu.vn.ua}
# depends_on:
# - n8n
# - app
# - phpsessid-bot
# dns:
# - 1.1.1.1
# - 8.8.8.8
# networks:
# - default
phpsessid-bot:
build:
context: ./phpsessid_bot/
dockerfile: Dockerfile
restart: unless-stopped
environment:
- EDU_HOST=${EDU_HOST:-edu.edu.vn.ua}
- EDU_LOGIN=${EDU_LOGIN}
- EDU_PASSWORD=${EDU_PASSWORD}
- BOT_PORT=${PHPSESSID_BOT_PORT:-5000}
- BOT_HOST=${PHPSESSID_BOT_HOST:-0.0.0.0}
dns:
- 1.1.1.1
- 8.8.8.8
networks:
- default
depends_on:
redis:
condition: service_healthy
session-keeper:
condition: service_healthy
playwright-service:
condition: service_started
volumes:
n8n_data:
postgres-db:
minio-data:
ollama-models:
lmstudio_data:
networks:
traefik-proxy:
external: true
redis-data:
-16
View File
@@ -1,16 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# Установка зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода
COPY config.py .
COPY utils.py .
COPY handlers.py .
COPY main.py .
# Запуск бота
CMD ["python", "-u", "main.py"]
-26
View File
@@ -1,26 +0,0 @@
import os
from dotenv import load_dotenv
load_dotenv()
# Telegram
BOT_TOKEN = os.getenv('LESSONS_BOT_TOKEN')
# n8n
N8N_WEBHOOK_URL = os.getenv('N8N_WEBHOOK_URL', 'http://n8n:5678/webhook/homework-check')
N8N_SECRET = os.getenv('N8N_SECRET', 'your-secret-token-here')
# WaterCrawl API
WATERCRAWL_API_URL = os.getenv('WATERCRAWL_API_URL', 'http://app:9000/api')
# PHPSESSID Bot
PHPSESSID_BOT_URL = os.getenv('PHPSESSID_BOT_URL', 'http://phpsessid-bot:5000')
# EDU site
EDU_HOST = os.getenv('EDU_HOST', 'edu.edu.vn.ua')
EDU_WEBINAR_URL = f'https://{EDU_HOST}/webinar/useractive'
# Playwright
PLAYWRIGHT_SERVER = os.getenv('PLAYWRIGHT_SERVER', 'http://playwright:8000')
PLAYWRIGHT_API_KEY = os.getenv('PLAYWRIGHT_API_KEY', 'your-secret-api-key')
WEBINAR_WAIT_TIME = int(os.getenv('WEBINAR_WAIT_TIME', '3')) # Секунды ожидания загрузки
-131
View File
@@ -1,131 +0,0 @@
import logging
import requests
from telegram import Update
from telegram.ext import ContextTypes
import config
from utils import fetch_webinars, format_webinar_message
logger = logging.getLogger(__name__)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Команда /start"""
welcome_message = """
Привет! Я бот для проверки домашних заданий и вебинаров.
<b>Команды:</b>
/check - Проверить несделанные уроки
/webinar - Проверить активные онлайн уроки
/help - Помощь
"""
await update.message.reply_text(welcome_message, parse_mode='HTML')
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Команда /help"""
help_text = """
<b>Как пользоваться ботом:</b>
<b>/check</b> - Проверка домашних заданий
- Поиск несделанных уроков
⏱ Проверка занимает 10-30 секунд
<b>/webinar</b> - Активные онлайн уроки
- Проверка активных вебинаровв
⏱ Проверка занимает 3-5 секунд
"""
await update.message.reply_text(help_text, parse_mode='HTML')
async def check_homework(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Команда /check - запускает проверку уроков"""
chat_id = update.effective_chat.id
user_id = update.effective_user.id
username = update.effective_user.username or "unknown"
# Отправляем уведомление что начали работу
status_message = await update.message.reply_text("Запускаю проверку уроков...")
# Формируем данные для n8n
payload = {
"chat_id": chat_id,
"user_id": user_id,
"username": username,
"timestamp": update.message.date.isoformat()
}
headers = {
"Authorization": f"Bearer {config.N8N_SECRET}",
"Content-Type": "application/json"
}
try:
logger.info(f"Sending request to n8n for user {user_id}")
# Отправляем запрос в n8n
response = requests.post(
config.N8N_WEBHOOK_URL,
json=payload,
headers=headers,
timeout=5 # Короткий таймаут т.к. это асинхронный запрос
)
if response.status_code == 200:
await status_message.edit_text(
"✅ Запрос принят!\n"
"🔄 Парсинг сайта и анализ данных...\n"
"⏱ Это займет 10-30 секунд"
)
logger.info(f"Request accepted for user {user_id}")
else:
await status_message.edit_text(
f"Ошибка при отправке запроса. Функция в разработке\n"
f"Код: {response.status_code}"
)
logger.error(f"n8n returned status {response.status_code}")
except requests.Timeout:
await status_message.edit_text("⏱ Запрос обрабатывается (таймаут соединения)")
logger.warning(f"Timeout for user {user_id}")
except Exception as e:
await status_message.edit_text(f"❌ Ошибка: {str(e)}")
logger.error(f"Error for user {user_id}: {e}", exc_info=True)
async def check_webinar(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Команда /webinar - проверяет активные онлайн уроки"""
user_id = update.effective_user.id
# Отправляем уведомление что начали работу
status_message = await update.message.reply_text("Проверяю активные вебинары...")
try:
logger.info(f"Checking webinars for user {user_id}")
# Получаем список вебинаров
webinars = fetch_webinars()
if webinars is None:
await status_message.edit_text(
"❌ Не удалось получить информацию о вебинарах\n"
"Попробуйте позже или обратитесь к администратору\n"
"|@MrForust|mr.forust| Либо же прямо сюда."
)
logger.error(f"Failed to fetch webinars for user {user_id}")
return
# Форматируем и отправляем результат
message = format_webinar_message(webinars)
await status_message.edit_text(message, parse_mode='HTML', disable_web_page_preview=True)
logger.info(f"Webinar check completed for user {user_id}: found {len(webinars)} webinars")
except Exception as e:
await status_message.edit_text(f"❌ Ошибка: {str(e)}")
logger.error(f"Error checking webinars for user {user_id}: {e}", exc_info=True)
async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик ошибок"""
logger.error(f"Update {update} caused error {context.error}", exc_info=context.error)
-42
View File
@@ -1,42 +0,0 @@
import logging
from telegram import Update
from telegram.ext import Application, CommandHandler
import config
from handlers import start, help_command, check_homework, check_webinar, error_handler
# Настройка логирования
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
logger = logging.getLogger(__name__)
def main():
"""Запуск бота"""
if not config.BOT_TOKEN:
logger.error("LESSONS_BOT_TOKEN not set!")
return
# Создаем приложение
application = Application.builder().token(config.BOT_TOKEN).build()
# Регистрируем обработчики команд
application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("help", help_command))
application.add_handler(CommandHandler("check", check_homework))
application.add_handler(CommandHandler("webinar", check_webinar))
# Регистрируем обработчик ошибок
application.add_error_handler(error_handler)
# Запускаем бота
logger.info("Lessons Bot started!")
logger.info(f"PHPSESSID Bot URL: {config.PHPSESSID_BOT_URL}")
logger.info(f"n8n Webhook URL: {config.N8N_WEBHOOK_URL}")
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == '__main__':
main()
-5
View File
@@ -1,5 +0,0 @@
python-telegram-bot==20.7
requests==2.31.0
beautifulsoup4==4.12.2
python-dotenv==1.0.0
lxml==4.9.3
-258
View File
@@ -1,258 +0,0 @@
import logging
import requests
from bs4 import BeautifulSoup
from typing import Optional, Dict, List
import config
logger = logging.getLogger(__name__)
def get_phpsessid() -> Optional[str]:
"""
Получает валидный PHPSESSID через phpsessid-bot
Returns:
str: PHPSESSID или None в случае ошибки
"""
try:
url = f"{config.PHPSESSID_BOT_URL}/get-session"
logger.info(f"Requesting PHPSESSID from {url}")
response = requests.post(url, timeout=10)
if response.status_code == 200:
data = response.json()
if data.get('success'):
phpsessid = data.get('phpsessid')
logger.info(f"Got PHPSESSID: {phpsessid[:10]}...")
return phpsessid
else:
logger.error(f"Failed to get PHPSESSID: {data.get('error')}")
return None
else:
logger.error(f"PHPSESSID bot returned status {response.status_code}")
return None
except Exception as e:
logger.error(f"Error getting PHPSESSID: {e}", exc_info=True)
return None
def parse_webinar_table(html_content: str) -> List[Dict[str, str]]:
"""
Парсит таблицу с вебинарами
Args:
html_content: HTML контент страницы
Returns:
List[Dict]: Список вебинаров или пустой список
"""
try:
soup = BeautifulSoup(html_content, 'html.parser')
# Находим таблицу с вебинарами
meetings_div = soup.find('div', {'id': 'meetings'})
if not meetings_div:
logger.warning("meetings div not found")
return []
table = meetings_div.find('table', {'class': 'table table-zebra'})
if not table:
logger.warning("table not found")
return []
tbody = table.find('tbody')
if not tbody:
logger.warning("tbody not found")
return []
rows = tbody.find_all('tr')
if not rows:
return []
# Проверяем на сообщение "Жодного онлайн уроку зараз"
first_row = rows[0]
td = first_row.find('td')
if td and 'Жодного онлайн уроку зараз' in td.get_text(strip=True):
logger.info("No webinars available")
return []
# Парсим активные вебинары
webinars = []
for row in rows:
tds = row.find_all('td')
if len(tds) >= 4:
webinar = {
'topic': tds[0].get_text(strip=True),
'course': tds[1].get_text(strip=True),
'teacher': tds[2].get_text(strip=True),
'join_link': tds[3].find('a')['href'] if tds[3].find('a') else ''
}
webinars.append(webinar)
logger.info(f"Parsed {len(webinars)} webinars")
return webinars
except Exception as e:
logger.error(f"Error parsing webinar table: {e}", exc_info=True)
return []
def fetch_webinars_with_playwright() -> Optional[List[Dict[str, str]]]:
"""
Получает список активных вебинаров используя Playwright для динамического контента
Returns:
List[Dict]: Список вебинаров или None в случае ошибки
"""
# Получаем PHPSESSID
phpsessid = get_phpsessid()
if not phpsessid:
logger.error("Failed to get PHPSESSID")
return None
try:
# Подготавливаем cookies для Playwright
cookies = [
{
'name': 'PHPSESSID',
'value': phpsessid,
'domain': config.EDU_HOST,
'path': '/'
}
]
# Запрос к Playwright серверу
playwright_request = {
'url': config.EDU_WEBINAR_URL,
'cookies': cookies,
'wait_until': 'networkidle', # Ждем пока сеть успокоится
'wait_time': config.WEBINAR_WAIT_TIME * 1000, # Дополнительное ожидание в миллисекундах
'user_agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
}
headers = {
'Authorization': f'Bearer {config.PLAYWRIGHT_API_KEY}',
'Content-Type': 'application/json'
}
logger.info(f"Fetching webinars via Playwright from {config.EDU_WEBINAR_URL}")
logger.info(f"Will wait {config.WEBINAR_WAIT_TIME} seconds for dynamic content")
response = requests.post(
f"{config.PLAYWRIGHT_SERVER}/render",
json=playwright_request,
headers=headers,
timeout=30
)
if response.status_code != 200:
logger.error(f"Playwright server returned status {response.status_code}")
logger.error(f"Response: {response.text}")
return None
result = response.json()
html_content = result.get('html', '')
if not html_content:
logger.error("No HTML content in Playwright response")
return None
# Парсим таблицу
webinars = parse_webinar_table(html_content)
return webinars
except Exception as e:
logger.error(f"Error fetching webinars via Playwright: {e}", exc_info=True)
return None
def fetch_webinars() -> Optional[List[Dict[str, str]]]:
"""
Получает список активных вебинаров
Сначала пробует через Playwright (для динамического контента),
при неудаче - через обычный requests
Returns:
List[Dict]: Список вебинаров или None в случае ошибки
"""
# Пробуем через Playwright
logger.info("Attempting to fetch via Playwright for dynamic content")
webinars = fetch_webinars_with_playwright()
if webinars is not None:
return webinars
# Fallback на обычный requests
logger.warning("Playwright fetch failed, falling back to simple requests")
# Получаем PHPSESSID
phpsessid = get_phpsessid()
if not phpsessid:
logger.error("Failed to get PHPSESSID")
return None
# Запрашиваем страницу с вебинарами
try:
headers = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9,uk;q=0.8',
'Referer': f'https://{config.EDU_HOST}/'
}
cookies = {
'PHPSESSID': phpsessid
}
logger.info(f"Fetching webinars from {config.EDU_WEBINAR_URL}")
response = requests.get(
config.EDU_WEBINAR_URL,
headers=headers,
cookies=cookies,
timeout=15
)
if response.status_code != 200:
logger.error(f"Failed to fetch webinars page: {response.status_code}")
return None
# Парсим таблицу
webinars = parse_webinar_table(response.text)
return webinars
except Exception as e:
logger.error(f"Error fetching webinars: {e}", exc_info=True)
return None
def format_webinar_message(webinars: List[Dict[str, str]]) -> str:
"""
Форматирует список вебинаров для отправки в Telegram
Args:
webinars: Список вебинаров
Returns:
str: Отформатированное сообщение
"""
if not webinars:
return "📭 Жодного онлайн уроку зараз"
message = "🎓 <b>Активні онлайн уроки:</b>\n\n"
for i, webinar in enumerate(webinars, 1):
message += f"<b>{i}. {webinar['topic']}</b>\n"
message += f"📚 Курс: {webinar['course']}\n"
message += f"👨‍🏫 Вчитель: {webinar['teacher']}\n"
if webinar['join_link']:
full_link = webinar['join_link']
if not full_link.startswith('http'):
full_link = f"https://{config.EDU_HOST}{webinar['join_link']}"
message += f"🔗 <a href='{full_link}'>Увійти до уроку</a>\n"
message += "\n"
return message
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
set -e
# Replace environment variables in the Nginx configuration template
envsubst '${MINIO_PRIVATE_BUCKET} ${MINIO_PUBLIC_BUCKET}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
# Start Nginx
exec nginx -g 'daemon off;'
-87
View File
@@ -1,87 +0,0 @@
server {
listen 80;
server_name localhost;
client_max_body_size 100M;
# Frontend
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API
location /api/ {
proxy_pass http://app:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# MCP
location ~ ^/(sse|messages) {
proxy_pass http://mcp:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Important SSE settings
proxy_http_version 1.1;
proxy_set_header Connection "";
# Disable buffering so events are sent immediately
proxy_buffering off;
proxy_cache off;
# Increase timeouts so connection stays open
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# MinIO private bucket
location /${MINIO_PRIVATE_BUCKET}/ {
proxy_pass http://minio:9000/${MINIO_PRIVATE_BUCKET}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
# MinIO public bucket
location /${MINIO_PUBLIC_BUCKET}/ {
proxy_pass http://minio:9000/${MINIO_PUBLIC_BUCKET}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
# MinIO API - for direct S3 operations
# location /minio/api/ {
# proxy_pass http://minio:9000/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_buffering off;
# }
# MinIO Console
location /minio-console/ {
proxy_pass http://minio:9001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Rewrite location headers
proxy_redirect / /minio-console/;
}
}
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y redis-tools && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN pip install requests redis
# Copy application code
COPY . .
# Run the bot
CMD ["python", "bot.py"]
+129
View File
@@ -0,0 +1,129 @@
import os
import time
import requests
import logging
import redis
from datetime import datetime
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Load configuration (adapted to .env keys)
def _env(key, default=None):
v = os.getenv(key, default)
if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")):
return v[1:-1]
return v
LOGIN = _env('KEEPER_LOGIN')
PASSWORD = _env('KEEPER_PASSWORD')
EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua')
EDU_LOGIN_PATH = _env('EDU_URL_LOGIN', '/user/login')
EDU_COURSES_PATH = _env('EDU_URL_COURSES', '/course/userlist')
URL_LOGIN = f"{EDU_BASE.rstrip('/')}/{EDU_LOGIN_PATH.lstrip('/')}"
URL_VERIFY = f"{EDU_BASE.rstrip('/')}/{EDU_COURSES_PATH.lstrip('/')}"
INTERVAL = int(_env('KEEPER_INTERVAL', 10))
USER_AGENT = _env('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
REDIS_HOST = _env('REDIS_HOST', 'redis')
REDIS_PORT = int(_env('REDIS_PORT', 6379))
SUCCESS_FILE = '/tmp/last_success'
def touch_success_file():
"""Updates the timestamp of the success file for healthchecks."""
try:
with open(SUCCESS_FILE, 'w') as f:
f.write(str(datetime.now().timestamp()))
except Exception as e:
logger.error(f"Failed to touch success file: {e}")
def main():
logger.info("Starting Session Keeper Bot")
# Connect to Redis
try:
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
redis_client.ping()
logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
return
session = requests.Session()
# Set headers
headers = {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'max-age=0',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document',
'Sec-Ch-Ua': '"Not_A Brand";v="99", "Chromium";v="142"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Linux"',
'Accept-Encoding': 'gzip, deflate, br',
'Priority': 'u=0, i'
}
session.headers.update(headers)
while True:
try:
logger.info("Attempting login...")
# Login payload
payload = {
'login': LOGIN,
'password': PASSWORD
}
# Perform Login
# Note: The user request shows a POST to /user/login with form data
# We need to make sure we handle the PHPSESSID correctly.
# If we already have a PHPSESSID, requests will send it.
login_response = session.post(URL_LOGIN, data=payload, allow_redirects=True)
logger.info(f"Login Response Status: {login_response.status_code}")
logger.info(f"Cookies after login: {session.cookies.get_dict()}")
# Verify Session
logger.info("Verifying session...")
verify_response = session.get(URL_VERIFY, allow_redirects=False)
logger.info(f"Verify Response Status: {verify_response.status_code}")
if verify_response.status_code == 200:
logger.info("Session verification SUCCESS (200 OK).")
touch_success_file()
# Save PHPSESSID to Redis
phpsessid = session.cookies.get('PHPSESSID')
if phpsessid:
try:
redis_client.set('EDU_PHPSESSID', phpsessid)
logger.info(f"Saved PHPSESSID to Redis: {phpsessid}")
except Exception as e:
logger.error(f"Failed to save PHPSESSID to Redis: {e}")
elif verify_response.status_code == 302:
logger.warning("Session verification FAILED (302 Redirect). Session might be invalid.")
else:
logger.warning(f"Session verification returned unexpected status: {verify_response.status_code}")
except Exception as e:
logger.error(f"An error occurred: {e}")
logger.info(f"Sleeping for {INTERVAL} minutes...")
time.sleep(INTERVAL * 60)
if __name__ == "__main__":
main()
-15
View File
@@ -1,15 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# Устанавливаем зависимости
RUN pip install --no-cache-dir flask requests
# Копируем код бота
COPY main.py .
# Открываем порт
EXPOSE 5000
# Запускаем бот
CMD ["python", "-u", "main.py"]
-216
View File
@@ -1,216 +0,0 @@
import os
import logging
from flask import Flask, request, jsonify
import requests
from datetime import datetime
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# Конфигурация из переменных окружения
EDU_HOST = os.getenv('EDU_HOST', 'edu.edu.vn.ua')
EDU_LOGIN = os.getenv('EDU_LOGIN', '')
EDU_PASSWORD = os.getenv('EDU_PASSWORD', '')
BOT_PORT = int(os.getenv('BOT_PORT', '5000'))
BOT_HOST = os.getenv('BOT_HOST', '0.0.0.0')
# Кэш для хранения актуальной сессии
session_cache = {
'phpsessid': None,
'expires_at': None
}
def login_and_get_session():
"""
Выполняет логин и возвращает новый PHPSESSID
"""
url = f"https://{EDU_HOST}/user/login"
headers = {
'Cache-Control': 'max-age=0',
'Sec-Ch-Ua': '"Chromium";v="141", "Not?A_Brand";v="8"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Linux"',
'Accept-Language': 'ru-RU,ru;q=0.9',
'Origin': f'https://{EDU_HOST}',
'Content-Type': 'application/x-www-form-urlencoded',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document',
'Referer': f'https://{EDU_HOST}/',
'Accept-Encoding': 'gzip, deflate, br',
'Priority': 'u=0, i'
}
data = {
'login': EDU_LOGIN,
'password': EDU_PASSWORD
}
try:
logger.info(f"Attempting login to {url}")
response = requests.post(
url,
data=data,
headers=headers,
allow_redirects=False,
timeout=10
)
# Получаем PHPSESSID из cookies
phpsessid = response.cookies.get('PHPSESSID')
if phpsessid:
logger.info(f"Login successful, got PHPSESSID: {phpsessid[:10]}...")
return {
'success': True,
'phpsessid': phpsessid,
'status_code': response.status_code
}
else:
logger.warning(f"Login failed: no PHPSESSID in response. Status: {response.status_code}")
return {
'success': False,
'error': 'No PHPSESSID in response',
'status_code': response.status_code
}
except requests.exceptions.RequestException as e:
logger.error(f"Login request failed: {str(e)}")
return {
'success': False,
'error': str(e)
}
def validate_phpsessid(phpsessid):
"""
Проверяет валидность существующего PHPSESSID
"""
url = f"https://{EDU_HOST}/"
headers = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
cookies = {
'PHPSESSID': phpsessid
}
try:
response = requests.get(url, headers=headers, cookies=cookies, timeout=10)
# Проверяем, не редиректит ли на страницу логина
is_valid = response.status_code == 200 and '/user/login' not in response.url
return {
'valid': is_valid,
'status_code': response.status_code,
'url': response.url
}
except requests.exceptions.RequestException as e:
logger.error(f"Validation request failed: {str(e)}")
return {
'valid': False,
'error': str(e)
}
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({'status': 'ok', 'timestamp': datetime.now().isoformat()})
@app.route('/get-session', methods=['POST', 'GET'])
def get_session():
"""
Основной endpoint для получения валидного PHPSESSID
Возвращает кэшированную сессию или создает новую
"""
result = login_and_get_session()
if result['success']:
session_cache['phpsessid'] = result['phpsessid']
session_cache['last_updated'] = datetime.now().isoformat()
return jsonify({
'success': True,
'phpsessid': result['phpsessid'],
'timestamp': datetime.now().isoformat()
})
else:
return jsonify({
'success': False,
'error': result.get('error', 'Login failed'),
'timestamp': datetime.now().isoformat()
}), 400
@app.route('/validate-session', methods=['POST'])
def validate_session():
"""
Проверяет валидность переданного PHPSESSID
"""
data = request.get_json() or {}
phpsessid = data.get('phpsessid') or request.args.get('phpsessid')
if not phpsessid:
return jsonify({
'success': False,
'error': 'PHPSESSID not provided'
}), 400
validation_result = validate_phpsessid(phpsessid)
return jsonify({
'success': True,
'valid': validation_result.get('valid', False),
'details': validation_result,
'timestamp': datetime.now().isoformat()
})
@app.route('/refresh-session', methods=['POST', 'GET'])
def refresh_session():
"""
Принудительно обновляет сессию
"""
result = login_and_get_session()
if result['success']:
return jsonify({
'success': True,
'phpsessid': result['phpsessid'],
'message': 'Session refreshed successfully',
'timestamp': datetime.now().isoformat()
})
else:
return jsonify({
'success': False,
'error': result.get('error', 'Failed to refresh session'),
'timestamp': datetime.now().isoformat()
}), 400
if __name__ == '__main__':
if not EDU_LOGIN or not EDU_PASSWORD:
logger.error("EDU_LOGIN and EDU_PASSWORD must be set!")
exit(1)
logger.info(f"Starting PHPSESSID validator bot on {BOT_HOST}:{BOT_PORT}")
logger.info(f"Target host: {EDU_HOST}")
app.run(host=BOT_HOST, port=BOT_PORT, debug=False)
@@ -1,3 +0,0 @@
flask==3.0.0
requests==2.31.0
Werkzeug==3.0.1
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
RUN pip install --upgrade pip && pip install playwright==1.56.0 redis requests "python-telegram-bot[job-queue]"
COPY checker.py .
CMD ["python", "checker.py"]
+938
View File
@@ -0,0 +1,938 @@
import os
import re
import logging
import redis
import json
import time
from datetime import datetime, timedelta
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember
from telegram.constants import ChatType
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
from playwright.async_api import async_playwright
# Logger
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Suppress HTTP request logs
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('telegram.ext._application').setLevel(logging.WARNING)
# Load environment variables
def _env(key, default=None):
v = os.getenv(key, default)
if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")):
return v[1:-1]
return v
EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua')
EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive')
WEBINAR_URL = f"{EDU_BASE.rstrip('/')}/{EDU_WEBINAR_PATH.lstrip('/')}"
DIARY_URL = f"{EDU_BASE.rstrip('/')}/user/diary"
WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60))
REDIS_HOST = _env('REDIS_HOST', 'redis')
REDIS_PORT = int(_env('REDIS_PORT', 6379))
PLAYWRIGHT_WS = _env('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws')
USER_AGENT = _env('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN')
ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0'))
# Redis Keys
KEY_WHITELIST = "bot:whitelist"
KEY_WHITELIST_ENABLED = "bot:whitelist_enabled"
KEY_SUBSCRIBERS = "bot:subscribers"
KEY_PHPSESSID = "EDU_PHPSESSID"
KEY_WEBINAR_HISTORY = "bot:webinar_history" # Stores last 3 webinars
# Initialize Redis
try:
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
redis_client.ping()
logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
exit(1)
# --- Translations ---
TRANSLATIONS = {
'ru': {
'welcome': "👋 Привет, {name}!\n\nЯ бот-уведомитель о вебинарах. Я буду сообщать вам, когда появится новый вебинар.\nВы подписаны на уведомления.",
'welcome_admin': "\n\n👑 <b>Режим администратора активен</b>",
'access_denied': "⛔ Доступ запрещен. Вас нет в белом списке.",
'help_title': "🤖 <b>Помощь по боту</b>\n\n",
'help_commands': "/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык",
'help_admin': "\n<b>Команды администратора:</b>\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.",
'admin_only': "⛔ Только для администратора!",
'user_added': "✅ Пользователь {user_id} добавлен в белый список",
'user_removed': "✅ Пользователь {user_id} удален из белого списка",
'user_not_in_whitelist': "⚠️ Пользователь {user_id} не был в белом списке",
'cannot_remove_admin': "❌ Невозможно удалить администратора из белого списка",
'invalid_user_id': "❌ Неверный ID пользователя. Должно быть число.",
'usage_adduser': "Использование: /adduser [user_id]",
'usage_removeuser': "Использование: /removeuser [user_id]",
'whitelist_enabled': "✅ Белый список включен",
'whitelist_disabled': "✅ Белый список отключен",
'whitelist_title': "📋 <b>Белый список:</b>\n",
'subscribers_title': "👥 <b>Подписчики:</b>\n",
'empty': "Пусто",
'force_check_running': "🔄 Запускаю проверку...",
'check_failed': "❌ Проверка не удалась. Смотрите логи.",
'check_completed_none': "✅ Проверка завершена. Вебинаров не найдено.",
'check_completed': "✅ Проверка завершена. Найдено {count} вебинар(ов)!",
'toggle_whitelist_disable': "🔒 Отключить белый список",
'toggle_whitelist_enable': "🔓 Включить белый список",
'view_whitelist': "📋 Посмотреть белый список",
'view_subscribers': "👥 Посмотреть подписчиков",
'force_check': "🔄 Принудительная проверка",
'webinar_found': "🎓 <b>Новый вебинар!</b>\n\n",
'webinar_item': "📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}",
'select_language': "🌐 <b>Выберите язык / Оберіть мову / Select language:</b>",
'language_changed': "✅ Язык изменен на русский",
'flag_ru': "🇷🇺 Русский",
'flag_uk': "🇺🇦 Українська",
'flag_en': "🇬🇧 English",
'history_cleared': "✅ История вебинаров очищена",
'history_clear_failed': "❌ Ошибка при очистке истории",
},
'uk': {
'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.",
'welcome_admin': "\n\n👑 <b>Режим адміністратора активний</b>",
'access_denied': "⛔ Доступ заборонено. Вас немає в білому списку.",
'help_title': "🤖 <b>Довідка по боту</b>\n\n",
'help_commands': "/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову",
'help_admin': "\n<b>Команди адміністратора:</b>\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.",
'admin_only': "⛔ Тільки для адміністратора!",
'user_added': "✅ Користувач {user_id} доданий до білого списку",
'user_removed': "✅ Користувач {user_id} видалений з білого списку",
'user_not_in_whitelist': "⚠️ Користувач {user_id} не був у білому списку",
'cannot_remove_admin': "❌ Неможливо видалити адміністратора з білого списку",
'invalid_user_id': "❌ Невірний ID користувача. Має бути число.",
'usage_adduser': "Використання: /adduser [user_id]",
'usage_removeuser': "Використання: /removeuser [user_id]",
'whitelist_enabled': "✅ Білий список увімкнено",
'whitelist_disabled': "✅ Білий список вимкнено",
'whitelist_title': "📋 <b>Білий список:</b>\n",
'subscribers_title': "👥 <b>Підписники:</b>\n",
'empty': "Порожньо",
'force_check_running': "🔄 Запускаю перевірку...",
'check_failed': "❌ Перевірка не вдалася. Дивіться логи.",
'check_completed_none': "✅ Перевірка завершена. Вебінарів не знайдено.",
'check_completed': "✅ Перевірка завершена. Знайдено {count} вебінар(ів)!",
'toggle_whitelist_disable': "🔒 Вимкнути білий список",
'toggle_whitelist_enable': "🔓 Увімкнути білий список",
'view_whitelist': "📋 Переглянути білий список",
'view_subscribers': "👥 Переглянути підписників",
'force_check': "🔄 Примусова перевірка",
'webinar_found': "🎓 <b>Новий вебінар!</b>\n\n",
'webinar_item': "📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}",
'select_language': "🌐 <b>Виберіть мову / Выберите язык / Select language:</b>",
'language_changed': "✅ Мову змінено на українську",
'flag_ru': "🇷🇺 Русский",
'flag_uk': "🇺🇦 Українська",
'flag_en': "🇬🇧 English",
'history_cleared': "✅ Історія вебінарів очищена",
'history_clear_failed': "❌ Помилка при очищенні історії",
},
'en': {
'welcome': "👋 Hello, {name}!\n\nI am the Webinar Checker Bot. I will notify you when a new webinar appears.\nYou have been subscribed to notifications.",
'welcome_admin': "\n\n👑 <b>Admin Mode Active</b>",
'access_denied': "⛔ Access denied. You are not on the whitelist.",
'help_title': "🤖 <b>Bot Help</b>\n\n",
'help_commands': "/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language",
'help_admin': "\n<b>Admin Commands:</b>\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.",
'admin_only': "⛔ Admin only!",
'user_added': "✅ User {user_id} added to whitelist",
'user_removed': "✅ User {user_id} removed from whitelist",
'user_not_in_whitelist': "⚠️ User {user_id} was not in whitelist",
'cannot_remove_admin': "❌ Cannot remove admin from whitelist",
'invalid_user_id': "❌ Invalid user ID. Must be a number.",
'usage_adduser': "Usage: /adduser [user_id]",
'usage_removeuser': "Usage: /removeuser [user_id]",
'whitelist_enabled': "✅ Whitelist Enabled",
'whitelist_disabled': "✅ Whitelist Disabled",
'whitelist_title': "📋 <b>Whitelist:</b>\n",
'subscribers_title': "👥 <b>Subscribers:</b>\n",
'empty': "Empty",
'force_check_running': "🔄 Running immediate check...",
'check_failed': "❌ Check failed. See logs for details.",
'check_completed_none': "✅ Check completed. No webinars found.",
'check_completed': "✅ Check completed. Found {count} webinar(s)!",
'toggle_whitelist_disable': "🔒 Disable Whitelist",
'toggle_whitelist_enable': "🔓 Enable Whitelist",
'view_whitelist': "📋 View Whitelist",
'view_subscribers': "👥 View Subscribers",
'force_check': "🔄 Force Check",
'webinar_found': "🎓 <b>New webinar found!</b>\n\n",
'webinar_item': "📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}",
'select_language': "🌐 <b>Select language / Виберіть мову / Выберите язык:</b>",
'language_changed': "✅ Language changed to English",
'flag_ru': "🇷🇺 Русский",
'flag_uk': "🇺🇦 Українська",
'flag_en': "🇬🇧 English",
'history_cleared': "✅ Webinar history cleared",
'history_clear_failed': "❌ Error clearing history",
}
}
# --- Language Helper Functions ---
def get_user_language(user_id: int) -> str:
"""Get user's preferred language from Redis. Default: Ukrainian."""
lang = redis_client.get(f"user:{user_id}:language")
return lang if lang in ['ru', 'uk', 'en'] else 'uk'
def set_user_language(user_id: int, lang: str):
"""Save user's language preference to Redis."""
if lang in ['ru', 'uk', 'en']:
redis_client.set(f"user:{user_id}:language", lang)
logger.info(f"User {user_id} language set to {lang}")
def t(user_id: int, key: str, **kwargs) -> str:
"""Translate message for user with optional formatting."""
lang = get_user_language(user_id)
message = TRANSLATIONS.get(lang, TRANSLATIONS['uk']).get(key, key)
if kwargs:
return message.format(**kwargs)
return message
def get_language_keyboard():
"""Generate language selection keyboard."""
keyboard = [
[
InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_ru"),
InlineKeyboardButton("🇺🇦 Українська", callback_data="lang_uk"),
],
[
InlineKeyboardButton("🇬🇧 English", callback_data="lang_en"),
]
]
return InlineKeyboardMarkup(keyboard)
# --- Helper Functions ---
def is_whitelisted(user_id: int) -> bool:
"""Check if user is allowed to use the bot."""
if user_id == ADMIN_ID:
return True
enabled = redis_client.get(KEY_WHITELIST_ENABLED)
if enabled == "0": # Whitelist disabled
return True
return redis_client.sismember(KEY_WHITELIST, str(user_id))
async def is_group_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
"""Check if the user is an administrator in the group."""
user = update.effective_user
chat = update.effective_chat
if chat.type in [ChatType.PRIVATE, "private"]:
return True
try:
member = await context.bot.get_chat_member(chat.id, user.id)
return member.status in [ChatMember.OWNER, ChatMember.ADMINISTRATOR]
except Exception as e:
logger.error(f"Failed to check admin status: {e}")
return False
def get_admin_keyboard(user_id: int):
"""Generate admin panel keyboard."""
whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != "0"
toggle_text = t(user_id, 'toggle_whitelist_disable') if whitelist_enabled else t(user_id, 'toggle_whitelist_enable')
keyboard = [
[InlineKeyboardButton(toggle_text, callback_data="toggle_whitelist")],
[InlineKeyboardButton(t(user_id, 'view_whitelist'), callback_data="view_whitelist")],
[InlineKeyboardButton(t(user_id, 'view_subscribers'), callback_data="view_subscribers")],
[InlineKeyboardButton(t(user_id, 'force_check'), callback_data="force_check")]
]
return InlineKeyboardMarkup(keyboard)
# --- Diary Functions ---
DIARY_MONTH_NAMES = ['', 'Січня', 'Лютого', 'Березня', 'Квітня', 'Травня', 'Червня',
'Липня', 'Серпня', 'Вересня', 'Жовтня', 'Листопада', 'Грудня']
DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд']
def get_diary_keyboard():
today = datetime.now()
keyboard = [
[
InlineKeyboardButton(f"📌 Сьогодні ({today.day}.{today.month:02d})", callback_data="diary_today"),
InlineKeyboardButton("📌 Завтра", callback_data="diary_tomorrow"),
],
[
InlineKeyboardButton("📅 Цей тиждень", callback_data="diary_week"),
InlineKeyboardButton("📅 Весь місяць", callback_data="diary_month"),
],
]
return InlineKeyboardMarkup(keyboard)
def _parse_calendar_html(table_html: str) -> tuple:
"""Parse calendar HTML table into (month_text, {day_num: {weekday, events}})."""
days = {}
weekdays = []
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL)
month_text = ''
for r_idx, row in enumerate(rows):
cells = re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.DOTALL)
if r_idx == 0:
# Month navigation row: extract "Травень 2026" from nav text
raw = re.sub(r'<[^>]+>', ' ', row).strip()
raw = re.sub(r'\s+', ' ', raw)
m = re.search(r'([А-Яа-яіїєґ\']+\s*:?\s*\d{4})', raw)
if m:
month_text = m.group(1).replace(' : ', ' ').strip()
else:
month_text = raw
elif r_idx == 1:
# Day names row
for cell in cells:
name = re.sub(r'<[^>]+>', '', cell).strip()
if name:
weekdays.append(name)
else:
# Data rows: each cell = a day
for col_idx, cell in enumerate(cells):
# Extract day number — first number in the cell text
text = re.sub(r'<[^>]+>', ' ', cell).strip()
text = re.sub(r'\s+', ' ', text)
dm = re.match(r'(\d+)', text)
if not dm:
continue
day_num = dm.group(1)
# Extract events: title attribute (full name) of ALL <a> tags inside the cell
events = []
for a_match in re.finditer(r'<a[^>]*>(.*?)</a>', cell, re.DOTALL):
a_tag = a_match.group(0)
# Prefer the title attribute (contains full name, not truncated)
title_m = re.search(r'title\s*=\s*"([^"]*)"', a_tag)
if title_m:
et = title_m.group(1).strip()
else:
et = re.sub(r'<[^>]+>', '', a_match.group(1)).strip()
if et:
events.append(et)
weekday = weekdays[col_idx] if col_idx < len(weekdays) else ''
days[day_num] = {'weekday': weekday, 'events': events}
return month_text, days
async def fetch_diary_data(phpsessid: str) -> dict | None:
logger.info("Fetching diary data via Playwright...")
try:
async with async_playwright() as p:
browser = await p.chromium.connect(PLAYWRIGHT_WS)
try:
context_browser = await browser.new_context(user_agent=USER_AGENT)
await context_browser.add_cookies([{
'name': 'PHPSESSID',
'value': phpsessid,
'domain': 'edu.edu.vn.ua',
'path': '/'
}])
page = await context_browser.new_page()
try:
await page.goto(DIARY_URL, wait_until='domcontentloaded')
await page.wait_for_selector('table.calendar', timeout=10000)
await page.wait_for_timeout(1500)
table_html = await page.evaluate("""
() => {
const t = document.querySelector('table.calendar');
return t ? t.outerHTML : null;
}
""")
if not table_html:
logger.error("table.calendar not found in DOM")
return None
# Debug: save HTML for troubleshooting
try:
with open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f:
f.write(table_html)
except Exception:
pass
month_text, days = _parse_calendar_html(table_html)
logger.info(f"Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d['events'])}/{len(days)}")
return {'monthFullText': month_text, 'days': days}
except Exception as e:
logger.error(f"Error parsing diary: {e}")
return None
finally:
await page.close()
await context_browser.close()
finally:
await browser.close()
except Exception as e:
logger.error(f"Playwright error in diary fetch: {e}")
return None
def _parse_diary_month(text: str) -> str:
match = re.search(r'([А-Яа-яіїєґ\']+\s*:\s*\d{4})', text)
if match:
return match.group(1).replace(' : ', ' ').strip()
return text.strip()
def format_diary_day(data: dict, day_num: int) -> str:
days = data.get('days', {})
month_str = _parse_diary_month(data.get('monthFullText', ''))
day_data = days.get(str(day_num))
lines = [f"📅 <b>{day_num} {month_str}</b>", "" * 18]
if not day_data or not day_data.get('events'):
lines.append("Немає подій")
else:
for e in day_data['events']:
lines.append(f"📌 {e}")
lines.append(f"\n🔗 {DIARY_URL}")
return "\n".join(lines)
def format_diary_week(data: dict, today: datetime) -> str:
days = data.get('days', {})
month_str = _parse_diary_month(data.get('monthFullText', ''))
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
lines = [f"📅 <b>Тиждень {monday.day}.{monday.month} {sunday.day}.{sunday.month}</b>\n"]
for i in range(7):
d = monday + timedelta(days=i)
day_data = days.get(str(d.day))
lines.append(f"─ <b>{DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month}</b> ─")
if not day_data or not day_data.get('events'):
lines.append("Немає подій\n")
else:
for e in day_data['events']:
lines.append(f"📌 {e}")
lines.append("")
lines.append(f"🔗 {DIARY_URL}")
return "\n".join(lines)
def format_diary_month(data: dict) -> str:
days = data.get('days', {})
month_str = _parse_diary_month(data.get('monthFullText', ''))
lines = [f"📅 <b>{month_str}</b>\n"]
for day_num in sorted(days.keys(), key=int):
day_data = days[day_num]
events = day_data.get('events', [])
weekday = day_data.get('weekday', '')
lines.append(f"─ <b>{weekday} {day_num}</b> ─")
if not events:
lines.append("Немає подій\n")
else:
for e in events:
lines.append(f"📌 {e}")
lines.append("")
lines.append(f"🔗 {DIARY_URL}")
return "\n".join(lines)
async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None:
cached = context.user_data.get('diary_cache')
now_ts = time.time()
if cached and (now_ts - cached.get('timestamp', 0)) < 300:
return cached['data']
phpsessid = redis_client.get(KEY_PHPSESSID)
if not phpsessid:
return None
data = await fetch_diary_data(phpsessid)
if data:
context.user_data['diary_cache'] = {'data': data, 'timestamp': now_ts}
return data
# --- Command Handlers ---
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /start command."""
user = update.effective_user
chat = update.effective_chat
logger.info(f"User {user.id} ({user.username}) started the bot in chat {chat.id} ({chat.type}).")
# Check whitelist - MUST be the user executing the command
if not is_whitelisted(user.id):
await update.message.reply_text(t(user.id, 'access_denied'))
return
# Add to subscribers (Chat ID!)
redis_client.sadd(KEY_SUBSCRIBERS, chat.id)
msg = t(chat.id, 'welcome', name=user.first_name)
if user.id == ADMIN_ID and chat.type == "private":
msg += t(chat.id, 'welcome_admin')
await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user.id))
else:
await update.message.reply_text(msg, parse_mode='HTML')
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /help command."""
user_id = update.effective_user.id
chat_id = update.effective_chat.id
msg = t(chat_id, 'help_title') + t(chat_id, 'help_commands')
if user_id == ADMIN_ID and update.effective_chat.type == "private":
msg += t(chat_id, 'help_admin')
await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user_id))
else:
await update.message.reply_text(msg, parse_mode='HTML')
async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /stop command (unsubscribe)."""
user = update.effective_user
chat = update.effective_chat
# Permission check: Whitelisted user OR Group Admin
if not (is_whitelisted(user.id) or await is_group_admin(update, context)):
await update.message.reply_text(t(chat.id, 'access_denied')) # Or specific "admin only" message
return
redis_client.srem(KEY_SUBSCRIBERS, chat.id)
await update.message.reply_text(t(chat.id, 'whitelist_disabled').replace(" whitelist", " notifications").replace("Білий список", "Сповіщення").replace("Белый список", "Уведомления") if chat.id else "Unsubscribed")
async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /language command."""
user = update.effective_user
chat = update.effective_chat
# Permission check for groups
if not (is_whitelisted(user.id) or await is_group_admin(update, context)):
return
await update.message.reply_text(
t(chat.id, 'select_language'),
parse_mode='HTML',
reply_markup=get_language_keyboard()
)
async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Add user to whitelist (admin only)."""
admin_id = update.effective_user.id
if admin_id != ADMIN_ID:
await update.message.reply_text(t(admin_id, 'admin_only'))
return
if not context.args:
await update.message.reply_text(t(admin_id, 'usage_adduser'))
return
try:
user_id = int(context.args[0])
redis_client.sadd(KEY_WHITELIST, str(user_id))
await update.message.reply_text(t(admin_id, 'user_added', user_id=user_id))
logger.info(f"Admin added user {user_id} to whitelist")
except ValueError:
await update.message.reply_text(t(admin_id, 'invalid_user_id'))
async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Remove user from whitelist (admin only)."""
admin_id = update.effective_user.id
if admin_id != ADMIN_ID:
await update.message.reply_text(t(admin_id, 'admin_only'))
return
if not context.args:
await update.message.reply_text(t(admin_id, 'usage_removeuser'))
return
try:
user_id = int(context.args[0])
if str(user_id) == str(ADMIN_ID):
await update.message.reply_text(t(admin_id, 'cannot_remove_admin'))
return
removed = redis_client.srem(KEY_WHITELIST, str(user_id))
if removed:
await update.message.reply_text(t(admin_id, 'user_removed', user_id=user_id))
logger.info(f"Admin removed user {user_id} from whitelist")
else:
await update.message.reply_text(t(admin_id, 'user_not_in_whitelist', user_id=user_id))
except ValueError:
await update.message.reply_text(t(admin_id, 'invalid_user_id'))
async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Clear webinar history (admin only)."""
admin_id = update.effective_user.id
if admin_id != ADMIN_ID:
await update.message.reply_text(t(admin_id, 'admin_only'))
return
try:
redis_client.delete(KEY_WEBINAR_HISTORY)
await update.message.reply_text(t(admin_id, 'history_cleared'))
logger.info("Admin cleared webinar history")
except Exception as e:
logger.error(f"Failed to clear history: {e}")
await update.message.reply_text(t(admin_id, 'history_clear_failed'))
async def diary_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
chat = update.effective_chat
if not is_whitelisted(user.id):
await update.message.reply_text(t(user.id, 'access_denied'))
return
await update.message.reply_text(
"📅 <b>Щоденник</b> — виберіть період:",
parse_mode='HTML',
reply_markup=get_diary_keyboard()
)
async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()
if user_id != ADMIN_ID:
if not is_whitelisted(user_id):
await query.edit_message_text("⛔ Доступ заборонено.")
return
data = query.data
if data == "diary_refresh":
context.user_data.pop('diary_cache', None)
await query.edit_message_text("🔄 Завантажую щоденник...")
diary_data = await _get_diary_data(context)
if not diary_data:
await query.edit_message_text("❌ Не вдалося завантажити щоденник. Немає сесії або помилка.")
return
await query.edit_message_text(
"📅 <b>Щоденник</b> — виберіть період:",
parse_mode='HTML',
reply_markup=get_diary_keyboard()
)
return
await query.edit_message_text("🔄 Завантажую щоденник...")
diary_data = await _get_diary_data(context)
if not diary_data:
await query.edit_message_text("❌ Не вдалося завантажити щоденник.")
return
today = datetime.now()
if data == "diary_today":
text = format_diary_day(diary_data, today.day)
elif data == "diary_tomorrow":
tomorrow = today + timedelta(days=1)
if tomorrow.day < today.day:
text = "❌ Дані за наступний місяць недоступні. Перейдіть на сайт."
else:
text = format_diary_day(diary_data, tomorrow.day)
elif data == "diary_week":
text = format_diary_week(diary_data, today)
elif data == "diary_month":
text = format_diary_month(diary_data)
else:
return
if len(text) > 4096:
text = text[:4090] + "\n\n✂️ ...(обрізано)"
await query.edit_message_text(
text,
parse_mode='HTML',
reply_markup=get_diary_keyboard()
)
# --- Admin Callbacks ---
async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle admin panel button clicks."""
query = update.callback_query
user_id = query.from_user.id
if user_id != ADMIN_ID:
await query.answer(t(user_id, 'admin_only'), show_alert=True)
return
await query.answer()
data = query.data
if data == "toggle_whitelist":
current = redis_client.get(KEY_WHITELIST_ENABLED)
new_state = "0" if current != "0" else "1"
redis_client.set(KEY_WHITELIST_ENABLED, new_state)
state_text = t(user_id, 'whitelist_disabled') if new_state == "0" else t(user_id, 'whitelist_enabled')
await query.edit_message_reply_markup(reply_markup=get_admin_keyboard(user_id))
await query.message.reply_text(state_text)
elif data == "view_whitelist":
members = redis_client.smembers(KEY_WHITELIST)
msg = t(user_id, 'whitelist_title') + ("\n".join(members) if members else t(user_id, 'empty'))
await query.message.reply_text(msg, parse_mode='HTML')
elif data == "view_subscribers":
subs = redis_client.smembers(KEY_SUBSCRIBERS)
msg = t(user_id, 'subscribers_title') + ("\n".join(subs) if subs else t(user_id, 'empty'))
await query.message.reply_text(msg, parse_mode='HTML')
elif data == "force_check":
await query.message.reply_text(t(user_id, 'force_check_running'))
result = await check_webinars_job(context)
if result is None:
await query.message.reply_text(t(user_id, 'check_failed'))
elif result == 0:
await query.message.reply_text(t(user_id, 'check_completed_none'))
else:
await query.message.reply_text(t(user_id, 'check_completed', count=result))
async def language_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle language selection button clicks."""
query = update.callback_query
user_id = query.from_user.id
data = query.data
if data.startswith("lang_"):
lang = data.split("_")[1]
set_user_language(user_id, lang)
await query.answer()
await query.edit_message_text(
t(user_id, 'language_changed'),
parse_mode='HTML'
)
# --- Webinar Checking Job ---
def get_webinar_key(url: str) -> str:
"""Generate unique key for a webinar based on URL."""
return url
def get_stored_webinars() -> list:
"""Get list of stored webinar keys from Redis."""
data = redis_client.get(KEY_WEBINAR_HISTORY)
if data:
try:
return json.loads(data)
except Exception as e:
logger.error(f"Failed to parse webinar history: {e}")
return []
def store_webinars(webinar_keys: list):
"""Store up to 3 most recent webinar keys in Redis."""
# Keep only last 3
webinar_keys = webinar_keys[-3:]
try:
redis_client.set(KEY_WEBINAR_HISTORY, json.dumps(webinar_keys))
logger.info(f"Stored {len(webinar_keys)} webinar(s) in history")
except Exception as e:
logger.error(f"Failed to store webinar history: {e}")
async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
"""Background job to check for webinars using Async Playwright.
Returns:
int: Number of webinars found, or None if check failed
"""
logger.info("Running webinar check...")
phpsessid = redis_client.get(KEY_PHPSESSID)
if not phpsessid:
logger.warning("PHPSESSID missing. Skipping check.")
# --- DEBUG LOGGING ---
try:
with open('phpsessid_missing.log', 'a') as f:
f.write(f"[{os.getcwd()}] PHPSESSID missing at {context.job.last_run: %Y-%m-%d %H:%M:%S}\n")
except Exception as e:
logger.error(f"Failed to write PHPSESSID debug log: {e}")
# ---------------------
return None
current_webinars = [] # List of dicts with name, url, and formatted text
content = ""
try:
async with async_playwright() as p:
# Connect to remote Playwright service
browser = await p.chromium.connect(PLAYWRIGHT_WS)
try:
# Create browser context with user agent
context_browser = await browser.new_context(user_agent=USER_AGENT)
# Add PHPSESSID cookie
await context_browser.add_cookies([{
'name': 'PHPSESSID',
'value': phpsessid,
'domain': 'edu.edu.vn.ua',
'path': '/'
}])
# Create new page
page = await context_browser.new_page()
try:
# Navigate to webinar page
await page.goto(WEBINAR_URL, wait_until='domcontentloaded')
# Wait for the table to load
await page.wait_for_selector('#meetings table', timeout=10000)
await page.wait_for_timeout(2000)
# Get page content
content = await page.content()
# Check if "no webinar" message is present
if "Жодного онлайн уроку зараз" not in content:
logger.info("!!! WEBINAR FOUND !!!")
# Extract webinar details from table rows
rows = page.locator('#meetings table tbody tr')
count = await rows.count()
for i in range(count):
row = rows.nth(i)
text = await row.inner_text()
if "Жодного онлайн уроку зараз" not in text:
# Extract name (topic) from first column
name_elem = row.locator('td').nth(0)
name = await name_elem.inner_text()
name = name.strip()
# Extract join URL from fourth column
url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first
url = await url_elem.get_attribute('href')
if name and url:
current_webinars.append({
'name': name,
'url': url,
'text': text.strip()
})
logger.info(f"Found webinar: {name} -> {url}")
else:
logger.info("No webinars found (expected message present)")
except Exception as e:
logger.error(f"Error checking page: {e}. Saving content for debug.")
# If page content is available, save it on error
try:
if page and not content:
content = await page.content()
except Exception:
pass # Ignore error during content retrieval on check error
return None
finally:
await page.close()
await context_browser.close()
finally:
await browser.close()
except Exception as e:
logger.error(f"Playwright error: {e}")
return None
# --- DEBUG LOGGING (Saving last response content) ---
if not current_webinars and content: #if no webinars found, save the page content
try:
with open('response.html', 'w', encoding='utf-8') as f:
f.write(content)
logger.info("Saved page content to response.html for debug.")
except Exception as e:
logger.error(f"Failed to write debug HTML: {e}")
# -----------------------------------------------------
# Check for NEW webinars and notify
if current_webinars:
# Get stored webinar history
stored_keys = get_stored_webinars()
logger.info(f"Stored webinar keys: {stored_keys}")
# Find new webinars (not in history)
new_webinars = []
current_keys = []
for webinar in current_webinars:
key = get_webinar_key(webinar['url'])
current_keys.append(key)
if key not in stored_keys:
new_webinars.append(webinar)
logger.info(f"NEW webinar detected: {webinar['name']}")
# Update stored history with current webinars
# Merge old and new, keeping only last 5
updated_keys = stored_keys + [k for k in current_keys if k not in stored_keys]
store_webinars(updated_keys)
# Notify subscribers ONLY about NEW webinars
if new_webinars:
subscribers = redis_client.smembers(KEY_SUBSCRIBERS)
logger.info(f"Sending notification about {len(new_webinars)} new webinar(s) to {len(subscribers)} subscriber(s)")
for sub_id in subscribers:
try:
# Build message in user's language
# sub_id comes from redis set as string, convert to int for translation lookup
webinar_items = "\n\n".join([
t(int(sub_id), 'webinar_item', name=w['name'], url=w['url'])
for w in new_webinars
])
message = t(int(sub_id), 'webinar_found') + webinar_items
await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML')
logger.info(f"Notification sent to {sub_id}")
except Exception as e:
logger.error(f"Failed to send to {sub_id}: {e}")
else:
logger.info(f"Found {len(current_webinars)} webinar(s), but all are already known")
return len(current_webinars)
# --- Main ---
def main():
if not WEBINAR_TELEGRAM_TOKEN:
logger.error("WEBINAR_TELEGRAM_TOKEN is missing!")
return
# Set default whitelist state if not set
if not redis_client.exists(KEY_WHITELIST_ENABLED):
redis_client.set(KEY_WHITELIST_ENABLED, "1") # Enabled by default
# Add admin to whitelist
if ADMIN_ID:
redis_client.sadd(KEY_WHITELIST, str(ADMIN_ID))
app = Application.builder().token(WEBINAR_TELEGRAM_TOKEN).build()
# Handlers
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("stop", stop_command))
app.add_handler(CommandHandler("help", help_command))
app.add_handler(CommandHandler("language", language_command))
app.add_handler(CommandHandler("adduser", add_user))
app.add_handler(CommandHandler("removeuser", remove_user))
app.add_handler(CommandHandler("clearhistory", clear_history))
app.add_handler(CommandHandler("diary", diary_command))
# Callback handlers - diary first, then language selection, then admin panel
app.add_handler(CallbackQueryHandler(diary_callback, pattern="^diary_"))
app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_"))
app.add_handler(CallbackQueryHandler(admin_callback))
# Job Queue
job_queue = app.job_queue
job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10)
logger.info("Bot started polling...")
app.run_polling()
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY html /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+21
View File
@@ -0,0 +1,21 @@
services:
errorpage:
build: .
image: gcr.forust.xyz/forust/error-pages:latest
pull_policy: build
container_name: error-pages
restart: unless-stopped
# ports:
# - 1234:80
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.services.error-pages.loadbalancer.server.port=80"
# Error handler middleware
- "traefik.http.middlewares.error-pages.errors.status=400,402-599"
- "traefik.http.middlewares.error-pages.errors.service=error-pages"
- "traefik.http.middlewares.error-pages.errors.query=/{status}.html"
networks:
proxy:
external: true
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 // Forbidden</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="403">403</h1>
<p class="subtitle">> Forbidden / Access Denied.</p>
</header>
<hr>
<section id="message">
<h2>./error_message</h2>
<p>You do not have permission to access this resource.</p>
<br>
<p>Check the <a href="https://status.forust.xyz">System Status</a> if you believe this is an
error.</p>
</section>
<footer>
<p>root@error:~$ sudo access_resource</p>
<p>User is not in the sudoers file. This incident will be reported.</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 // Not Found</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="404">404</h1>
<p class="subtitle">> Page Not Found / Lost in the Void.</p>
</header>
<hr>
<section id="message">
<h2>./error_message</h2>
<p>The page you are looking for does not exist or has been moved.</p>
<br>
<p>Check the <a href="https://status.forust.xyz">System Status</a> if you believe this is an error.</p>
</section>
<footer>
<p>root@error:~$ ping target</p>
<p>Destination Host Unreachable</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 // Server Error</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="500">500</h1>
<p class="subtitle">> Internal Server Error / System Failure.</p>
</header>
<hr>
<section id="message">
<h2>./error_message</h2>
<p>Something went wrong on our end. We are working to fix it.</p>
<br>
<p>Check the <a href="https://status.forust.xyz">System Status</a> for more information.</p>
</section>
<footer>
<p>root@error:~$ systemctl status service</p>
<p>Active: failed (Result: core-dump)</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>502 // Bad Gateway</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="502">502</h1>
<p class="subtitle">> Bad Gateway / System Failure.</p>
</header>
<hr>
<section id="message">
<h2>./error_message</h2>
<p>The server received an invalid response from the upstream server.</p>
<br>
<p>Check the <a href="https://status.forust.xyz">System Status</a> for more information.</p>
</section>
<footer>
<p>root@error:~$ curl -I upstream_host</p>
<p>HTTP/1.1 502 Bad Gateway</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>503 // Service Unavailable</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="503">503</h1>
<p class="subtitle">> Service Unavailable / System Failure.</p>
</header>
<hr>
<section id="message">
<h2>./error_message</h2>
<p>The server is currently unable to handle the request due to maintenance or overload.</p>
<br>
<p>Check the <a href="https://status.forust.xyz">System Status</a> for more information.</p>
</section>
<footer>
<p>root@error:~$ systemctl start service</p>
<p>Job for service failed because the control process exited with error code.</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>504 // Gateway Timeout</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="504">504</h1>
<p class="subtitle">> Gateway Timeout / System Failure.</p>
</header>
<hr>
<section id="message">
<h2>./error_message</h2>
<p>The server did not receive a timely response from the upstream server.</p>
<br>
<p>Check the <a href="https://status.forust.xyz">System Status</a> for more information.</p>
</section>
<footer>
<p>root@error:~$ timeout 30s curl upstream</p>
<p>curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+4 -1
View File
@@ -1,3 +1,6 @@
GITEA_POSTGRES_USER=
GITEA_POSTGRES_PASSWORD=
GITEA_POSTGRES_DB=gitea
GITEA_POSTGRES_DB=gitea
GITEA_SMTP_PASS=
MAILER_ADDR=
SERVICE_EMAIL=email.used.by.services@domain.tld
+41 -11
View File
@@ -1,7 +1,8 @@
services:
server:
image: docker.gitea.com/gitea:1.25.1
image: docker.gitea.com/gitea:1.26
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
@@ -13,23 +14,53 @@ services:
- GITEA__database__NAME=gitea
#Server
- GITEA__server__ROOT_URL=https://gitea.forust.xyz
- GITEA__server__SSH_DOMAIN=gitssh.forust.xyz
- GITEA__server__SSH_PORT=2221
restart: always
networks:
- gitea-db
- traefik-proxy
# Mailer
- GITEA__mailer__ENABLED=true
- GITEA__mailer__FROM=${SERVICE_EMAIL}
- GITEA__mailer__SMTP_ADDR=${MAILER_ADDR}:465
- GITEA__mailer__USER=${SERVICE_EMAIL}
- GITEA__mailer__PASSWD=${GITEA_SMTP_PASS}
- GITEA__mailer__PROTOCOL=SMTP
- GITEA__service__REGISTER_EMAIL_CONFIRM=true
- GITEA__service__ENABLE_NOTIFY_MAIL=true
volumes:
- ./gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
# Prod Router
- "traefik.http.routers.gitea.rule=Host(`gitea.forust.xyz`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls=true"
# Local Router
- "traefik.http.routers.gitea-local.rule=Host(`gitea.workstation.internal`)"
- "traefik.http.routers.gitea-local.entrypoints=websecure"
- "traefik.http.routers.gitea-local.tls=true"
# Dev Router
- "traefik.http.routers.gitea-dev.rule=Host(`gitea.gigaforust.internal`)"
- "traefik.http.routers.gitea-dev.entrypoints=websecure"
- "traefik.http.routers.gitea-dev.tls=true"
# SSH Router
- "traefik.tcp.services.gitea.loadbalancer.server.port=22"
- "traefik.tcp.routers.gitea.entrypoints=ssh"
- "traefik.tcp.routers.gitea.rule=HostSNI(`*`)"
# Gitea container registry Router
- "traefik.http.routers.gitea-registry.rule=Host(`gcr.forust.xyz`) && PathPrefix(`/v2`)"
- "traefik.http.routers.gitea-registry.entrypoints=websecure"
- "traefik.http.routers.gitea-registry.tls.certresolver=letsencrypt"
- "traefik.http.routers.gitea-registry.tls=true"
ports:
- "2221:22"
networks:
- gitea-db
- proxy
depends_on:
- db
db:
image: docker.io/library/postgres:14
restart: always
@@ -37,13 +68,12 @@ services:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea
- POSTGRES_DB=gitea
networks:
- gitea-db
volumes:
- ./gitea-db/:/var/lib/postgresql/data
networks:
- gitea-db
networks:
gitea-db:
external: false
traefik-proxy:
proxy:
external: true
+18 -7
View File
@@ -10,13 +10,24 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.services.glance.loadbalancer.server.port=8080"
# Prod Router
- "traefik.http.routers.glance.rule=Host(`glance.forust.xyz`)"
- "traefik.http.routers.glance.entrypoints=websecure"
- "traefik.http.routers.glance.tls=true"
# Local Router
- "traefik.http.routers.glance-local.rule=Host(`glance.workstation.internal`)"
- "traefik.http.routers.glance-local.entrypoints=websecure"
- "traefik.http.routers.glance-local.tls=true"
# Dev Router
- "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.internal`)"
- "traefik.http.routers.glance-dev.entrypoints=websecure"
- "traefik.http.routers.glance-dev.tls=true"
networks:
- traefik-proxy
dns:
- 1.1.1.1
- 8.8.8.8
- proxy
networks:
traefik-proxy:
proxy:
external: true
+93
View File
@@ -0,0 +1,93 @@
services:
headscale:
image: headscale/headscale:latest
restart: unless-stopped
container_name: headscale-server
command: serve
networks:
- proxy
volumes:
- ./config/headscale.yaml:/etc/headscale/config.yaml
- data:/var/lib/headscale
- ./config/policy.json:/var/lib/headscale/policy.json
labels:
- "me.tale.headplane.target: headscale"
- "traefik.enable=true"
- "traefik.http.services.headscale.loadbalancer.server.port=8080"
- "traefik.http.services.headscale-metrics.loadbalancer.server.port=9090"
## SERVICE
# Prod Router
- "traefik.http.routers.headscale.rule=Host(`hs.forust.xyz`)"
- "traefik.http.routers.headscale.entrypoints=websecure"
- "traefik.http.routers.headscale.service=headscale"
- "traefik.http.routers.headscale.tls=true"
# Local Router
- "traefik.http.routers.headscale-local.rule=Host(`hs.workstation.internal`)"
- "traefik.http.routers.headscale-local.entrypoints=websecure"
- "traefik.http.routers.headscale-local.service=headscale"
- "traefik.http.routers.headscale-local.tls=true"
# Dev Router
- "traefik.http.routers.headscale-dev.rule=Host(`hs.gigaforust.internal`)"
- "traefik.http.routers.headscale-dev.entrypoints=websecure"
- "traefik.http.routers.headscale-dev.service=headscale"
- "traefik.http.routers.headscale-dev.tls=true"
## METRICS
# Prod Router
- "traefik.http.routers.headscale-metrics.rule=Host(`hs.forust.xyz`) && PathPrefix(`/metrics`)"
- "traefik.http.routers.headscale-metrics.entrypoints=websecure"
- "traefik.http.routers.headscale-metrics.service=headscale-metrics"
- "traefik.http.routers.headscale-metrics.tls=true"
# Local Router
- "traefik.http.routers.headscale-metrics-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/metrics`)"
- "traefik.http.routers.headscale-metrics-local.entrypoints=websecure"
- "traefik.http.routers.headscale-metrics-local.service=headscale-metrics"
- "traefik.http.routers.headscale-metrics-local.tls=true"
# Dev Router
- "traefik.http.routers.headscale-metrics-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/metrics`)"
- "traefik.http.routers.headscale-metrics-dev.entrypoints=websecure"
- "traefik.http.routers.headscale-metrics-dev.service=headscale-metrics"
- "traefik.http.routers.headscale-metrics-dev.tls=true"
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
volumes:
- ./config/headplane.yaml:/etc/headplane/config.yaml
- ./config/headscale.yaml:/etc/headscale/config.yaml
- headplane-data:/var/lib/headplane
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy
web:
image: goodieshq/headscale-admin:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.services.headscale-ui.loadbalancer.server.port=80"
# Prod Router
- "traefik.http.routers.headscale-ui.rule=Host(`hs.forust.xyz`) && PathPrefix(`/admin`)"
- "traefik.http.routers.headscale-ui.entrypoints=websecure"
- "traefik.http.routers.headscale-ui.middlewares=security-chain@file"
- "traefik.http.routers.headscale-ui.tls=true"
# Local Router
- "traefik.http.routers.headscale-ui-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/admin`)"
- "traefik.http.routers.headscale-ui-local.entrypoints=websecure"
- "traefik.http.routers.headscale-ui-local.tls=true"
# Dev Router
- "traefik.http.routers.headscale-ui-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/admin`)"
- "traefik.http.routers.headscale-ui-dev.entrypoints=websecure"
- "traefik.http.routers.headscale-ui-dev.tls=true"
networks:
- proxy
volumes:
data:
headplane-data:
name: headplane_data
networks:
proxy:
external: true
+221
View File
@@ -0,0 +1,221 @@
# Configuration for the Headplane server and web application
server:
# These are the default values, change them as needed
host: "0.0.0.0"
port: 3000
# Should not include the dashboard prefix (/admin) portion.
# # Prod server_url
# base_url: https://hs.forust.xyz
# # Local base_url
# base_url: https://hs.workstation.internal
# # Dev base_url
# base_url: https://hs.gigaforust.internal
# You may provide `cookie_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
cookie_secret: "<change_me_to_something_secure!>"
# Whether cookies should be marked as Secure
# * Should be false if running without HTTPs
# * Should be true if running behind a reverse proxy with HTTPs
cookie_secure: true
# The maximum age of the session cookie in seconds
cookie_max_age: 86400 # 1 day in seconds
# This is not required, but if you want to restrict the cookie
# to a specific domain, set it here. Otherwise leave it commented out.
# This may not work as expected if not using a reverse proxy.
# cookie_domain: ""
# The path to persist Headplane specific data. All data going forward
# is stored in this directory, including the internal database and
# any cache related files.
data_path: "/var/lib/headplane"
# The info secret is optional and allows access to certain debug endpoints
# that may expose sensitive information about your Headplane instance.
#
# As of now, this protects the /api/info endpoint which exposes details about
# the Headplane and Headscale versions in use. In the future, more endpoints
# may be protected by this secret.
#
# If not set, these endpoints will be disabled.
# info_secret: "<change_me_to_something_secure!>"
# Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features
headscale:
# The URL to your Headscale instance
# (All API requests are routed through this URL)
# (THIS IS NOT the gRPC endpoint, but the HTTP endpoint)
#
# IMPORTANT: If you are using TLS this MUST be set to `https://`
url: "http://headscale-server:8080"
# If you use the TLS configuration in Headscale, and you are not using
# Let's Encrypt for your certificate, pass in the path to the certificate.
# (This has no effect if `url` does not start with `https://`)
# tls_cert_path: "/var/lib/headplane/tls.crt"
# Optional, public URL if its different from the `headscale.url`
# This affects certain parts of the web UI which shows Headscale's URL
public_url: "https://headscale.example.com"
# Path to the Headscale configuration file
# This is optional, but HIGHLY recommended for the best experience
# If this is read only, Headplane will show your configuration settings
# in the Web UI, but they cannot be changed.
config_path: "/etc/headscale/config.yaml"
# Whether the Headscale configuration should be strictly validated
# when reading from `config_path`. If true, Headplane will not interact
# with Headscale if there are any issues with the configuration file.
#
# This is recommended to be true for production deployments to, however it
# may not work if you are using a version of Headscale that has configuration
# options unknown to Headplane.
config_strict: true
# If you are using `dns.extra_records_path` in your Headscale
# configuration, you need to set this to the path for Headplane
# to be able to read the DNS records.
#
# Pass it in if using Docker and ensure that the file is both
# readable and writable to the Headplane process.
# When using this, Headplane will no longer need to automatically
# restart Headscale for DNS record changes.
# dns_records_path: "/var/lib/headscale/extra_records.json"
# Integration configurations for Headplane to interact with Headscale
integration:
# The Headplane agent allows retrieving information about nodes
# This allows the UI to display version, OS, and connectivity data
# You will see the Headplane agent in your Tailnet as a node when
# it connects.
agent:
enabled: false
# To connect to your Tailnet, you need to generate a pre-auth key
# This can be done via the web UI or through the `headscale` CLI.
pre_authkey: "<your-preauth-key>"
# Optionally change the name of the agent in the Tailnet.
# host_name: "headplane-agent"
# Configure different caching settings. By default, the agent will store
# caches in the path below for a maximum of 1 minute. If you want data
# to update faster, reduce the TTL, but this will increase the frequency
# of requests to Headscale.
# cache_ttl: 60
# cache_path: /var/lib/headplane/agent_cache.json
# The work_dir represents where the agent will store its data to be able
# to automatically reauthenticate with your Tailnet. It needs to be
# writable by the user running the Headplane process.
#
# If using Docker, it is best to leave this as the default.
# work_dir: "/var/lib/headplane/agent"
# Only one of these should be enabled at a time or you will get errors
# This does not include the agent integration (above), which can be enabled
# at the same time as any of these and is recommended for the best experience.
docker:
enabled: true
# By default we check for the presence of a container label (see the docs)
# to determine the container to signal when changes are made to DNS settings.
container_label: "me.tale.headplane.target=headscale"
# HOWEVER, you can fallback to a container name if you desire, but this is
# not recommended as its brittle and doesn't work with orchestrators that
# automatically assign container names.
#
# If `container_name` is set, it will override any label checks.
# container_name: "headscale-server"
# The path to the Docker socket (do not change this if you are unsure)
# Docker socket paths must start with unix:// or tcp:// and at the moment
# https connections are not supported.
socket: "unix:///var/run/docker.sock"
# Please refer to docs/integration/Kubernetes.md for more information
# on how to configure the Kubernetes integration. There are requirements in
# order to allow Headscale to be controlled by Headplane in a cluster.
kubernetes:
enabled: false
# Validates the manifest for the Pod to ensure all of the criteria
# are set correctly. Turn this off if you are having issues with
# shareProcessNamespace not being validated correctly.
validate_manifest: true
# This should be the name of the Pod running Headscale and Headplane.
# If this isn't static you should be using the Kubernetes Downward API
# to set this value (refer to docs/Integrated-Mode.md for more info).
pod_name: "headscale"
# Proc is the "Native" integration that only works when Headscale and
# Headplane are running outside of a container. There is no configuration,
# but you need to ensure that the Headplane process can terminate the
# Headscale process.
#
# (If they are both running under systemd as sudo, this will work).
proc:
enabled: false
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
# headscale_api_key: "<your-headscale-api-key>"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
+79
View File
@@ -0,0 +1,79 @@
# https://wiki.serversatho.me/en/headscale
# # Prod server_url
# server_url: https://hs.example.com
# # Local server_url
# server_url: https://hs.internal_domain.internal
# # Dev server_url
# server_url: https://hs.dev_internal_domain.internal
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: true
region_id: 999
region_code: "headscale"
region_name: "Headscale Embedded DERP"
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
ipv4: 1.2.3.4
ipv6: 2001:db8::1
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 24h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
debug: false
gorm:
prepare_stmt: true
parameterized_queries: true
skip_err_record_not_found: true
slow_threshold: 1000
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""
log:
format: text
level: info
policy:
mode: database
path: ""
dns:
magic_dns: true
base_domain: example.com
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
split: {}
search_domains: []
extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: false
+26
View File
@@ -0,0 +1,26 @@
{
"groups": {
"group:admin": [
"admin@"
],
"group:users": []
},
"tagOwners": {},
"hosts": {},
"acls": [
{
"#ha-meta": {
"name": "users",
"open": true
},
"action": "accept",
"src": [
"autogroup:member"
],
"dst": [
"autogroup:self:*"
]
}
],
"ssh": []
}
+10
View File
@@ -0,0 +1,10 @@
# everyone use that
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY ./forust_files /usr/share/nginx/html
EXPOSE 80
# Start
CMD ["nginx", "-g", "daemon off;"]
+10
View File
@@ -0,0 +1,10 @@
# everyone use that
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY ./xdfnx_files /usr/share/nginx/html
EXPOSE 80
# Start
CMD ["nginx", "-g", "daemon off;"]
+63
View File
@@ -0,0 +1,63 @@
services:
forust:
build:
context: .
dockerfile: Dockerfile.forust
image: gcr.forust.xyz/forust/forust-homepage:latest
pull_policy: build
# ports:
# - "8085:80"
restart: unless-stopped
volumes:
- ./forust_files:/usr/share/nginx/html
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.services.forust-homepage.loadbalancer.server.port=80"
# Prod Router
- "traefik.http.routers.forust-homepage.rule=Host(`forust.xyz`)"
- "traefik.http.routers.forust-homepage.entrypoints=websecure"
- "traefik.http.routers.forust-homepage.tls=true"
# Local Router
- "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.internal`)"
- "traefik.http.routers.forust-homepage-local.entrypoints=websecure"
- "traefik.http.routers.forust-homepage-local.tls=true"
# Dev Router
- "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.internal`)"
- "traefik.http.routers.forust-homepage-dev.entrypoints=websecure"
- "traefik.http.routers.forust-homepage-dev.tls=true"
xdfnx:
build:
context: .
dockerfile: Dockerfile.xdfnx
image: gcr.forust.xyz/forust/xdfnx-homepage:latest
pull_policy: build
restart: unless-stopped
# ports:
# - "8086:80"
volumes:
- ./xdfnx_files:/usr/share/nginx/html
labels:
- "traefik.enable=true"
- "traefik.http.services.xdfnx-homepage.loadbalancer.server.port=80"
# Prod Router
- "traefik.http.routers.xdfnx.rule=Host(`xdfnx.cfd`)"
- "traefik.http.routers.xdfnx.entrypoints=websecure"
- "traefik.http.routers.xdfnx.tls.certresolver=letsencrypt"
- "traefik.http.routers.xdfnx.tls=true"
# Local Router
- "traefik.http.routers.xdfnx-local.rule=Host(`xdfnx.workstation.internal`)"
- "traefik.http.routers.xdfnx-local.entrypoints=websecure"
- "traefik.http.routers.xdfnx-local.tls=true"
# Dev Router
- "traefik.http.routers.xdfnx-dev.rule=Host(`xdfnx.gigaforust.internal`)"
- "traefik.http.routers.xdfnx-dev.entrypoints=websecure"
- "traefik.http.routers.xdfnx-dev.tls=true"
networks:
- proxy
networks:
proxy:
external: true
+167
View File
@@ -0,0 +1,167 @@
/* hidden in a plain sight? */
:root {
--bg-color: #050505;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #666666;
--font-mono: 'Courier New', Courier, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
line-height: 1.6;
font-size: 16px;
padding: 2rem;
}
a {
color: var(--text-color);
text-decoration: none;
border-bottom: 1px solid var(--dim);
transition: all 0.2s;
}
a:hover {
background-color: var(--text-color);
color: var(--bg-color);
border-color: var(--text-color);
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* TEXT */
h1 {
font-size: 2.5rem;
text-transform: uppercase;
letter-spacing: -2px;
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--dim);
display: inline-block;
padding-right: 20px;
}
.subtitle {
color: var(--dim);
margin-bottom: 2rem;
}
hr {
border: 0;
border-top: 1px dashed var(--dim);
margin: 2rem 0;
}
.comment {
color: var(--dim);
font-size: 0.9rem;
margin-left: 10px;
}
/* SECTIONS */
section {
margin-bottom: 3rem;
}
/* LISTS */
ul {
list-style: none;
}
.link-list li {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 15px;
}
/* STACK GRID */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.skill-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.level {
font-weight: bold;
}
.special .level {
color: var(--text-color);
text-shadow: 1px 0 0 red, -1px 0 0 blue;
}
/* my dudes */
.team-grid {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.member {
text-align: center;
width: 100px;
}
.avatar {
width: 80px;
height: 80px;
background-color: #222;
border: 2px solid var(--text-color);
margin: 0 auto 10px auto;
background-size: cover;
}
/* if no avatar added: */
.placeholder::before {
content: "?";
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
color: var(--dim);
}
/* REPOS */
.repo-list li {
margin-bottom: 1rem;
}
/* FOOTER */
footer {
text-align: center;
color: var(--dim);
font-size: 0.8rem;
/* flag{why-are-you-here?} */
margin-top: 4rem;
}
/* SMTH RESPONSIVE */
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
gap: 0;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MrForust // XRock</title>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<div class="container">
<header>
<h1 class="glitch" data-text="MrForust">Mr-Forust</h1>
<p class="subtitle">> CTF Player / XRock_Team / Just Signal.</p>
</header>
<hr>
<section id="socials">
<h2>./socials</h2>
<ul class="link-list">
<li>
<i class="fab fa-github"></i>
<a href="https://github.com/mr-forust" target="_blank">github/mr-forust</a>
</li>
<li>
<i class="fas fa-flag"></i>
<a href="https://tryhackme.com/p/MrForust" target="_blank">tryhackme/MrForust</a>
</li>
<li>
<i class="fab fa-telegram-plane"></i>
<a href="https://t.me/MrForust" target="_blank">telegram/MrForust</a>
</li>
<li>
<i class="fab fa-discord"></i>
<span>discord/mr.forust</span>
</li>
<li>
<i class="fas fa-envelope"></i>
<a href="mailto:contact@forust.xyz">mail/contact@forust.xyz</a>
</li>
<li>
<i class="fa-solid fa-key"></i>
<a href=".well-known/pgp-key.asc">security/PGP Key</a>
</li>
<li>
<p class="comment"># PGP Key Fingerprint: A777 7CB7 D9C4 0A97 443D CCF0 7A3D A455 F820 5B82</p>
</li>
</ul>
</section>
<section id="stack">
<h2>./skills_and_tools</h2>
<div class="grid-2">
<div>
<div class="skill-item">
<span>ArchLinux # btw</span>
<span class="level">[#######...]</span>
</div>
<div class="skill-item">
<span>Docker Compose</span> <span class="level">[#####.....]</span>
</div>
<div class="skill-item">
<span>Web Pentest</span> <span class="level">[#####.....]</span>
</div>
<div class="skill-item">
<span>Burpsuite</span> <span class="level">[#####.....]</span>
</div>
<div class="skill-item">
<span>Docker</span> <span class="level">[####......]</span>
</div>
<div class="skill-item">
<span>Steganography</span> <span class="level">[####......]</span>
</div>
</div>
<div>
<div class="skill-item">
<span>Cryptography</span> <span class="level">[####......]</span>
</div>
<div class="skill-item">
<span>OSINT</span> <span class="level">[####......]</span>
</div>
<div class="skill-item">
<span>Python</span> <span class="level">[###.......]</span>
</div>
<div class="skill-item">
<span>HTML</span> <span class="level">[###.......]</span>
</div>
<div class="skill-item">
<span>Bash</span> <span class="level">[##........]</span>
</div>
<div class="skill-item">
<span>Golang</span> <span class="level">[#.........]</span>
</div>
</div>
</div>
</section>
<section id="team">
<h2>./xrock_team</h2>
<p class="comment"># It's a select caste. Cybershamans. Cryptoanarchists. Shadows on the net..</p>
<div class="team-grid">
<div class="member">
<div class="avatar"
style="background-image: url('assets/images/team/mrforust.jpg'); background-size: cover; background-position: center;">
</div>
<a href="https://github.com/mr-forust" target="_blank">MrForust</a>
</div>
<div class="member">
<div class="avatar"
style="background-image: url('assets/images/team/anna.jpg'); background-size: cover; background-position: center;">
</div>
<a href="./assets/images/love.png" target="_blank">Anna~</a>
</div>
<div class="member">
<div class="avatar"
style="background-image: url('assets/images/team/chernuha.jpg'); background-size: cover; background-position: center;">
</div>
<a href="https://chernuha.space" target="_blank">Chernuha</a>
</div>
<div class="member">
<div class="avatar"
style="background-image: url('assets/images/team/hudan.jpg'); background-size: cover; background-position: center;">
</div>
<a href="https://hudan.xyz" target="_blank">p1ngvi</a>
</div>
<div class="member">
<div class="avatar"
style="background-image: url('assets/images/team/xdfnx.jpg'); background-size: cover; background-position: center;">
</div>
<a href="https://xdfnx.cfd" target="_blank">xdfnx</a>
</div>
</div>
</section>
<section id="repos">
<h2>./favorite_repos</h2>
<ul class="repo-list">
<li>
<a href="https://github.com/TDesktop-x64/tdesktop" target="_blank">TDesktop-x64/tdesktop</a>
<span class="comment">// unofficial telegram client with some additions</span>
</li>
<li>
<a href="https://github.com/traefik/traefik" target="_blank">traefik/traefik</a>
<span class="comment">// beloved reverse-proxy</span>
</li>
<li>
<a href="https://github.com/unhappychoice/gitlogue" target="_blank">unhappychoice/gitlogue</a>
<span class="comment">// nice git log visualizer</span>
</li>
<li>
<a href="https://github.com/mountain-loop/yaak" target="_blank">mountain-loop/yaak</a>
<span class="comment">// modern, fancy api client</span>
</li>
<li>
<a href="https://github.com/pear-devs/pear-desktop" target="_blank">pear-devs/pear-desktop</a>
<span class="comment">// music client with a lot of features</span>
</li>
<li>
<a href="https://github.com/epi052/feroxbuster" target="_blank">epi052/feroxbuster</a>
<span class="comment">// directory discovery</span>
</li>
</ul>
</section>
<footer>
<p>root@xrock:~$ cat <a href="miku.html">./miku</a></p>
<p>miku?</p>
<p>root@xrock:~$ shutdown -h now</p>
<p>&copy; XRock - Just Signal.</p>
</footer>
</div>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>miku?</title>
</head>
<body bgcolor="black" text="grey">
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠛⠘⠛⠛⠛⠿⠿⠿⠿⠿⣽⠙⣛⡛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⠁⠀⠀⠀⠀⠀⢀⣀⣀⣀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠈⠙⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣰⢋⣴⠻⣿⡟⢹⡟⠋⠀⠀⠀⠀⢀⣠⣴⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣷⣶⣶⣦⣤⣄⣀⠀⠀⠉⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⡀⠏⣰⠟⠁⡰⢋⠀⠀⠀⠀⠀⢀⣠⣾⣿⣿⣿⡿⠟⠛⣛⣽⣯⣿⣽⣯⣿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣖⡄⠀⠀⠉⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⣠⠾⠋⠀⢊⡴⠋⠀⠀⠀⣀⣴⣿⣿⣿⡿⠋⢀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣿⣿⣽⣿⣯⣿⣯⣿⣽⣯⣿⢿⣷⣷⣄⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⢠⠞⠁⠀⠀⣰⠋⠀⠀⢀⣤⣾⣿⣿⣿⠿⣋⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣝⣛⡫⠷⣿⡿⣯⣿⢿⣺⣄⠀⠈⠋⠙⣿⡻⣧⠹⣏⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⢀⡴⠋⠀⠀⢠⡾⠁⠀⢀⣴⣿⣿⣿⣿⣿⣿⣾⠿⠛⣩⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣭⣛⣿⣿⣻⣿⣷⣤⠀⠀⠘⢷⠘⣧⠹⣆⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⢀⡰⠍⠀⠀⢀⣴⡟⠁⢀⣶⣿⣿⣿⣿⣿⣿⠟⣋⠴⢂⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠹⣦⠘⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠀⠀⢀⠀⠀⠀⢰⣾⠏⢀⣴⣾⣿⣿⡿⣻⡿⢛⣡⠞⣁⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣻⣽⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⢠⡀⠁⠘⢧⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠠⠀⠀⠀⣿⠏⣴⣿⣿⣿⡿⣋⣾⠟⣱⠋⢂⣶⣿⣿⣿⣿⣿⣿⡿⣯⣿⣽⢿⡾⣟⣿⡽⣾⢽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠱⠀⠀⠈⢧⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡋⠀⠠⢀⠏⠀⠀⠀⠀⠀⠀⢋⣾⣿⣿⣿⣿⣷⠟⡡⠞⢁⣼⣿⣿⣿⣿⣿⢟⣿⢿⣽⣟⡷⣯⣟⡿⣽⢯⡿⣽⢊⢷⣻⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡂⠀⠀⠀⢫⠄⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⡈⠁⡌⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⠟⢁⢮⡱⣳⢾⣟⠋⣿⣿⡿⠃⣼⢯⡟⣾⡽⣳⠶⣭⠿⣽⣛⡞⣷⡈⢎⢷⡹⣯⣟⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣯⣿⣿⣿⣿⣿⣿⣟⣿⣿⣿⣿⣿⣿⣿⣷⡆⠀⠀⠀⠫⡄⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⡔⠀⢈⠆⠀⠀⠀⣠⣾⣿⣿⣿⣿⢛⢋⡰⣋⢮⡱⢏⡾⠍⣰⣿⡿⠁⡼⣏⠷⡘⢼⣓⠧⡓⡜⣯⢳⣭⣛⢶⡁⢊⠶⣙⢧⡻⣷⣻⣯⣿⢿⣷⣿⢿⣯⣿⢿⣽⣾⢿⣳⣿⣽⡿⣿⣿⡿⣟⣾⣽⣻⢿⣿⣿⣿⣿⣧⠀⠀⠀⠄⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡧⠀⡘⠀⡀⠌⡐⠀⠀⣼⣿⣿⣿⣿⣿⠃⢀⠦⡓⢭⡲⢭⣓⠞⢠⣿⡟⢀⣼⢳⡍⠧⡑⠈⡝⠦⡑⡍⡖⣏⢶⡩⢞⠁⡄⠘⡱⢎⡵⢫⢷⣻⣽⣿⣻⣾⢿⣽⡾⣟⣯⡿⣟⣿⣳⣯⢿⡟⣿⣿⣟⡿⣞⡿⣯⣟⣿⢿⣿⣯⡀⠀⢀⠂⠀⠀⠟⡛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠠⠘⠰⢀⡘⠔⢀⣾⣿⣿⣿⣿⣿⠃⠀⡎⠵⣙⠶⣹⢣⡞⠁⣾⡿⢁⡾⣡⠗⡬⠑⢀⡆⡙⢆⠱⡘⠼⣘⠦⣝⢪⠀⣗⡀⠙⡼⣘⢏⡞⡵⣛⡾⣽⢯⣟⠾⡽⣭⢿⣹⢟⣷⡻⣎⢯⢳⡌⢿⣾⣟⣯⢳⡝⣺⠽⣻⣽⣻⡇⠀⠀⠀⠀⢰⡄⠸⡈⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠂⡍⡖⠀⠌⣠⣿⣿⣿⡿⣿⡿⠃⠀⡜⣌⠳⣬⢛⡴⢫⡜⢸⣿⠃⡼⣱⢃⠞⡠⢁⣾⠁⢹⠠⢃⢌⠣⡝⣚⢬⢣⠀⣿⣦⠀⠰⡩⠞⡼⣱⢫⡝⣧⢟⡾⢫⡕⣣⢏⢞⡹⢲⡝⡜⣊⠖⠲⠈⢿⣾⣻⠸⡜⣥⠫⣝⡷⣟⡧⠀⠀⠀⠀⠰⣃⠐⢠⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢀⠱⢠⠄⠈⣴⣿⣿⣿⠟⣼⡿⢡⠃⡜⡰⢊⢷⡸⣍⢞⡣⠄⣿⣏⠴⣣⠧⣉⠎⢁⣼⣯⠇⠰⣉⠌⡄⠳⣌⠧⢎⢧⠀⣿⡽⣷⠀⠈⠽⡰⢣⠳⣜⢬⢫⡜⣳⠸⣑⢎⠎⡕⢣⠜⡰⢡⠎⣡⢃⠘⣿⣽⡆⢹⠰⣍⢲⡹⣟⣿⠀⠀⠀⢀⡃⠲⠀⢠⠂⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠄⡂⡜⢀⣾⣿⣿⣿⢋⣾⣟⢠⠃⡸⢰⡑⢯⡒⢷⣘⢮⡑⢸⣯⢏⡞⣡⠞⡤⠁⣾⠋⣾⠡⠐⡥⢂⠈⡱⣈⢮⡙⢦⠐⣏⠻⣿⣷⣤⠈⠱⣋⠳⣜⢪⡓⡼⣡⠳⣉⢎⢎⡙⠦⡙⡔⠣⢎⠔⡈⠆⢹⣯⣧⠈⡳⢌⠦⣱⣛⣮⠀⠀⠀⠆⡌⢡⠃⠄⢂⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠆⡑⢠⣾⣿⣿⡻⢃⣾⣿⠋⠆⠰⣁⠦⣙⠦⣏⠳⡜⢦⠁⣾⣏⠞⡴⢱⢊⠀⣼⢏⣾⣿⡅⠈⠴⠈⠐⢠⡑⢦⡙⣧⠈⣟⣦⠹⣿⣿⣄⠀⠉⠷⣌⠳⡜⣥⢃⠯⣜⣊⢦⡙⢦⠱⣌⠳⡌⢎⠱⡈⠄⢻⣿⡄⡹⢌⠲⡡⢞⡵⠀⠀⠀⡇⠄⡃⠥⢈⠄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⡈⠂⣴⣟⠫⡍⠰⢁⣾⣿⠃⡜⢀⠣⣐⠣⣍⠞⣌⢳⢩⢚⠀⡿⣌⠏⡼⢡⠂⠰⣿⡾⢿⣿⣇⠀⠓⡄⡄⠂⡜⢢⡝⡶⠐⣸⣿⣷⡜⢿⣿⣷⡠⠘⢌⡳⢍⠶⣩⠞⡴⣊⢦⡙⢆⡳⢌⠣⡜⣌⠲⡁⠎⠈⣿⣷⡱⢋⡒⠥⣫⠖⠀⠠⢌⡓⠌⡰⢁⠎⡐⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⢀⡾⢋⡌⢣⠌⢁⣿⡿⠁⡜⠀⢢⠑⡄⠳⣈⠞⡌⢎⠦⡉⢸⡗⡭⢊⡵⠁⣰⣶⣤⣤⣌⡙⠉⠷⠈⠄⢣⠐⣈⠳⣜⣳⠀⣿⣿⣿⣿⣦⡙⣿⣷⡀⠈⠼⣩⠞⣥⢛⡴⣍⠶⣉⠦⣑⠮⣑⢮⣐⢣⠱⡈⠅⠸⣷⡏⡵⢘⡐⢧⣻⠀⡡⢂⡝⢠⠐⡩⠐⢠⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠂⡰⢋⠔⠣⠘⢠⠃⣾⡟⠄⡐⢀⡘⠄⠣⢌⠱⠠⢎⡐⢣⡘⠁⣾⢸⡱⢋⠄⣘⣿⣿⣿⣿⣿⣿⣿⡦⠀⠁⢸⡄⢠⢋⢾⣱⠠⣿⣿⣿⣿⣿⣿⣌⢻⣿⣤⠈⢱⡚⢦⡛⣶⡩⢞⢥⠓⣜⢢⡍⠶⣨⢆⢣⡑⠌⡀⢿⣷⢡⠣⡘⠦⣏⠔⡁⠢⣜⠠⠂⢥⠃⠄⠂⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠅⢀⠰⠌⡌⢁⠊⠄⣸⡟⠀⡐⠀⡄⢢⢉⠒⡌⢂⠇⢢⠘⢤⠘⢰⠋⣦⡙⠂⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⡄⠘⢸⣧⠀⡚⣼⢳⠀⣿⣿⣿⣿⣿⣿⣿⡷⢈⢁⣤⠀⠍⡧⡝⢦⠻⣍⢮⡙⣤⠣⡜⢳⡰⢎⠲⣌⠒⡀⠘⣿⣎⠱⣀⠛⡼⢐⡈⠔⣌⠂⡉⠔⡨⢈⠄⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠃⠈⠐⠈⠆⢠⠂⠌⡐⢰⡿⠂⡐⠀⠰⡈⠆⡌⠒⡌⢌⡘⠤⡉⢆⠁⣼⣙⢦⠙⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠈⣿⡇⠘⡴⣻⠀⣹⣿⣿⣿⣿⣿⣿⣶⣾⣷⣿⡖⠀⠱⣩⠏⣝⢮⠳⡜⢤⠓⣍⠲⣱⢪⡑⢆⠣⡔⠁⢹⣎⠧⢠⠙⣞⠀⢎⠐⣌⠂⣁⠚⠄⢢⠀⢽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⢀⣠⣶⠁⠌⠂⡌⢀⠂⢠⡿⠁⠀⠀⠀⣎⠡⡘⢠⠃⢌⠢⠄⡃⠜⡠⢀⡿⣜⠋⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⢿⣿⡆⢱⢻⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⣄⠑⢮⡱⢎⠿⣜⠦⡙⢤⠛⣤⠓⡼⣈⠇⣆⠡⠀⢻⡜⡠⢙⡼⠈⠆⡌⣰⠡⠠⠘⡌⠠⠂⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⢛⣡⣴⣿⣿⡏⠐⢨⠀⠆⠠⠁⣼⠃⠀⠀⠀⡜⢄⠢⠑⡄⢊⠄⡃⠜⡠⢃⠔⠰⣯⠍⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣾⣿⣿⣀⠹⡇⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡦⡀⠹⢎⡝⣮⢧⠙⣢⠙⣤⠛⣔⢣⡚⢤⢃⠅⡈⠞⠤⡁⢞⡥⠘⡄⢲⠀⡅⢣⠐⡡⠁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⡈⠄⡘⢠⠁⢸⠇⠀⠀⠀⣼⠱⡈⠄⡡⠐⠌⢂⡘⠤⠑⠢⠀⣼⠇⠀⠀⠉⠙⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣀⠉⢞⡬⣛⣇⠰⣉⠦⡹⢌⡖⡍⣆⠫⡔⡀⢈⠱⢈⠺⣄⠱⣌⠲⢁⠰⢀⠇⠄⡡⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠠⠐⢡⡘⠤⢀⡏⠀⠀⠀⣸⡱⢂⡁⠂⠄⡑⠈⡄⠰⡈⢡⠁⠐⡟⢀⣶⣤⣄⣀⠀⠀⠈⠙⠿⣿⣿⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⢀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡈⠲⡝⡾⣇⠈⡖⣩⠲⣌⠳⣌⢳⣜⡰⠀⠄⠊⣜⣣⢘⡧⡘⠄⢂⠡⢊⡐⠐⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠠⣉⠐⢆⠂⡼⠀⠀⠀⣼⢣⢇⠃⠠⢈⠐⠠⠁⢄⠃⠤⠁⠌⡀⢡⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠙⠻⢷⣬⣙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⠿⠟⠛⠛⠁⠀⠘⠵⣻⡄⠸⣄⠳⣌⠳⣌⠧⣿⢰⢃⠘⡀⠰⣧⢸⠲⡍⠂⡌⢐⡃⢄⡉⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢂⠤⡙⡄⢰⠁⠀⠀⢬⡷⣩⠎⡄⠁⠄⡈⠄⡁⢂⠌⠄⣉⠰⠀⢠⣿⡟⠉⠉⠉⠉⠀⠁⠈⠁⠀⠀⠀⠀⠈⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⢡⣶⠾⠟⠋⠉⠀⠀⠀⢀⣀⣀⣠⣤⣤⡀⠓⠋⡀⢨⠓⣌⠳⣌⢳⡌⢠⡋⠄⠀⠐⣽⢸⠣⡅⢃⠐⢂⡅⢂⠰⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⡌⢂⡜⠀⠃⠀⠀⡜⢲⡝⠂⣁⡀⠈⡀⠄⡐⠐⡠⢈⠂⢄⠒⠀⠈⣿⣷⣀⣠⣤⣤⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⠁⠀⠀⠀⠀⠀⠶⠾⣿⣿⣿⣿⣿⣿⣿⣿⠄⠀⠱⠀⡹⢄⣛⠈⣁⡘⢂⣇⠈⠆⠀⢘⣯⠃⡜⣀⠊⢄⡒⠈⡔⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⡐⢌⠒⠆⡈⠀⢠⠘⣌⢻⠌⣸⣿⠇⠀⢀⠐⠠⢁⡐⠠⠌⠂⢌⠀⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣄⣀⠀⠀⠀⠉⠙⠻⢿⣿⡏⠀⠐⡀⠅⢀⠣⠎⠐⣿⣧⡀⢺⡀⠌⢡⠘⣮⠑⡌⠤⠈⡔⢨⢁⠰⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⡰⢈⢜⡂⠀⢀⡎⠴⡨⡇⢰⣿⣿⡁⠀⠀⠌⡐⠠⠀⡡⠘⡈⠤⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⣀⣠⣿⡟⠀⡈⠄⡐⠌⡄⢀⠩⠀⢿⣿⡄⠠⡇⠈⠄⠢⣝⠢⠜⡠⢃⡐⢃⠄⢊⠁⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠠⡐⠡⡸⠀⢠⢃⡜⢰⠱⠇⣼⣿⣿⠄⠀⠈⠄⠠⠁⠠⢂⠡⡐⠐⡀⢠⣶⡎⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠠⠀⢂⠄⠃⠀⣬⣂⠀⢼⣿⣧⠀⢳⠈⠌⢡⢘⡇⡱⠄⡃⠌⡆⡘⠤⠁⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⡰⢁⠣⡅⡘⢄⠺⡄⢣⠛⢰⣿⣿⣿⠀⠀⠌⠠⠁⣴⣆⠁⢂⠔⠡⠀⣿⣿⣿⡸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⡅⠀⢠⣄⡀⢈⣿⣿⣄⠈⣿⣿⣄⠈⠧⡘⢀⠎⡖⢱⡈⠔⡡⢚⡀⢆⡁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡯⠀⡔⢡⢊⡵⢈⠜⣸⠐⣣⠁⣾⣿⣿⠿⠀⠀⠌⢀⣾⣿⡏⠀⠌⡠⢁⠀⠻⣿⣿⣷⡜⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢋⣄⠀⡘⠀⠠⠘⢻⡻⣿⣿⣿⣿⠆⣿⣿⣧⠀⢃⠆⡡⢂⢝⠢⠜⡠⢁⠧⡈⢄⠒⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠰⡈⢆⣩⠆⣅⠚⣤⠙⡄⠸⣽⣿⣿⠁⠀⠀⣠⣿⣿⡿⠰⠀⢂⡁⢂⠀⣆⡈⠻⣿⣿⣦⡹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⣻⢖⡶⣹⢖⡶⣤⠦⠄⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣡⣿⡟⠠⠁⣀⠃⢀⢸⣿⣿⣿⣿⣿⡇⢿⣿⣿⡄⠈⢆⠡⢂⠌⣇⠓⠤⡉⢖⠡⠊⡌⢈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠅⢢⠑⡌⡲⢌⡄⣋⠴⣉⠀⣩⣿⣿⡏⠀⢀⣴⣿⣿⣿⢁⣿⡅⢀⠒⠠⢀⣿⣷⣆⡈⠛⠿⠛⠛⠻⠿⠿⠿⢿⣿⣿⣿⣿⣷⡈⠻⣜⣧⣻⡜⡧⠋⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⣋⣴⣿⡿⠀⠀⠐⠤⠁⣾⣇⢹⣿⡿⢿⣿⣿⢸⣿⣿⣯⠀⢨⠂⡅⢊⡼⡘⠤⡑⢎⠰⢡⠘⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢢⡉⠴⣙⠤⡘⢤⡓⠌⢠⣾⣿⣿⠀⣠⣾⣿⣿⣿⠇⣾⣿⣯⠀⠌⡡⠠⣿⣿⣿⣿⣦⣄⣀⠀⠂⠀⠀⠀⠀⠀⠀⠀⠉⠛⢿⣷⣬⣌⣁⣭⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣋⣥⣾⣿⣿⣿⢃⡆⠀⢩⢀⣾⣿⣿⡌⢿⣿⣎⣿⣿⡄⣿⣿⣿⡆⢀⠳⣸⠠⡹⢄⠣⡘⢼⢀⠣⡘⡀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡷⢀⠣⡘⢔⡫⢰⡉⢦⠍⠂⣸⣿⣿⡿⠁⣾⡟⣽⣿⡟⣰⣿⣿⣿⡀⢂⠡⢐⣿⣿⣿⣿⣿⣿⣿⣷⣤⠀⠀⠀⠋⠀⠀⠂⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⣁⣀⣀⣉⣙⣛⣻⣿⣧⣞⠀⠨⢀⣾⣿⣿⣿⣿⡘⣿⣿⡜⣿⣷⠸⣿⣿⣷⡀⠱⢸⠅⣟⠄⢣⠑⢮⠠⢃⠴⠁⢺⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢈⣒⣉⠆⠛⣀⣈⣐⢃⣴⣿⣿⣿⠅⢘⣿⣽⢿⡿⢠⣿⣿⣿⣿⣧⠀⠆⢈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⢢⣤⡠⢤⣤⣤⡴⢿⣿⣿⣿⣿⣿⣿⡿⠿⣛⣭⠇⣠⡴⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⢠⣾⣿⣿⣿⣿⣿⣧⢹⣿⣷⣽⣿⡆⢿⣿⣿⣷⡄⢚⣉⣈⠋⠠⣍⣺⠀⠉⠦⣉⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠠⠚⣃⣋⣰⣿⣿⣿⣟⣡⣿⣿⣿⣿⣿⣿⣿⡤⣶⢀⣾⣿⣿⣿⣿⣿⣂⠨⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣬⠛⡓⢦⡙⠻⣿⣷⣬⣍⣛⣭⣵⣶⣾⡿⢋⣡⠞⣉⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⡆⢩⣦⣛⣿⣿⣾⣿⣿⣿⣧⡙⢿⣿⣯⡑⣈⣑⣋⠳⢢⡀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⡄⢰⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡔⡌⢿⣽⣿⣿⣿⣿⣿⣿⠄⠠⣿⣿⣿⣿⣿⣿⣿⣿⡿⢃⡿⣷⣶⠉⢢⡽⣷⣬⣙⠻⢿⣿⣿⠟⢋⣡⣴⢟⣡⡾⣽⣦⢿⣝⡻⣿⣿⣿⣿⣿⣿⠛⢀⣾⣿⣿⣿⣿⣿⣿⣿⡿⠻⠉⣄⣾⣿⣿⣿⣿⣿⣿⣿⣿⣼⣿⣿⣷⡘⣿⣿⡗⠀⢷⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣇⢸⡁⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⢽⠃⠙⢿⣿⣿⣿⣷⠀⢿⣿⣿⣿⣿⡿⠛⠁⣶⢏⣼⣿⣿⣷⣦⣿⣿⣿⣿⡗⢢⡙⣡⢾⣿⣿⣿⣿⣿⣿⣿⣿⣷⣟⣿⡈⠻⠿⣿⣿⣿⣷⣾⣿⣿⣿⣿⣿⡿⠟⠡⡄⣊⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡗⠠⣸⠇⢹⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⠇⠸⣷⡀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣀⠸⣦⣈⠻⢿⣿⣧⣿⣿⣿⣿⣿⠀⠘⡇⢻⣼⣿⣿⣿⣿⣿⣿⣿⣿⠿⢿⣦⠙⢹⠏⣿⣿⣿⣿⣿⣿⠟⢿⣿⣿⣿⠀⠳⣀⢻⣿⣿⣿⣿⣿⣿⣿⠿⢋⣤⡞⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⣰⡿⢀⢸⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠙⢷⡄⠹⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⠀⠈⠙⢷⣆⡙⢿⣿⣿⣿⣿⣿⠁⢠⣆⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⣀⣄⣤⣤⣌⡐⢼⣿⣿⡿⠁⢰⡦⢙⠿⡿⢀⣃⠈⣿⣿⣿⣿⣿⣿⠛⣡⡶⠟⠁⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⢀⣼⠟⠀⠠⠈⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⠈⠛⢷⣄⡙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡗⠀⠀⠀⠀⠙⢷⣆⠹⣿⣿⡿⠃⠄⣸⡆⠀⢻⣿⣿⣿⣿⣿⣿⡟⠁⢰⡿⣯⢿⡽⣯⢿⡀⢻⣿⠁⢠⣧⣶⡏⣰⠁⢸⣇⠀⠈⣿⣿⣿⠏⢀⣶⠟⠁⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠉⣀⣴⠿⠉⠀⠀⠀⠁⠸⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠉⠻⢶⣄⡙⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠙⣷⡌⠻⣧⠐⣰⣿⣷⠀⠈⢿⣿⣿⣿⡿⠋⡁⠔⢺⠿⣽⢫⡽⣯⣟⠲⠄⡙⠿⣦⣍⡛⣰⠏⠀⣿⣿⡄⠀⢨⡿⠃⣴⡟⠁⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏⢁⣁⣴⠞⠋⠁⠀⠀⠀⠀⠀⠀⠀⢹⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⣦⣄⡉⠛⠻⢿⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣄⠁⣰⣾⣿⣿⣧⠀⠈⠉⢉⣀⣴⡾⣷⡀⠈⢻⣓⣯⣙⡳⠃⣀⣶⣌⣐⠠⢠⡉⢀⠀⣶⡿⣿⣿⣄⠘⠁⣼⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⢯⢿⣿⡿⡿⠻⠛⢉⣀⣤⡾⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿
⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠻⠶⣤⡈⠉⠙⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⢁⠂⠀⣿⠀⠙⣿⣿⣽⣿⣿⣿⣷⣾⡿⣷⣻⢷⣻⠀⢠⣟⡾⣽⢣⠀⣾⣿⣿⣻⣷⣶⣶⣿⣯⣿⣷⢿⣿⡟⠀⢸⠃⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⢀⣠⡴⠚⠋⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿
⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠙⠲⢍⡖⣆⠦⠤⠤⠤⢠⣄⡌⣤⢤⣤⣙⠖⢠⣿⣿⣻⣾⣿⣿⣿⣷⣿⣳⣽⣻⣿⠇⢸⣞⡽⣽⢫⠐⠿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⡃⠐⣮⣤⢤⠤⣤⠤⣄⠤⠤⢤⢒⡤⢎⡜⠣⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿
⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠓⠉⠓⠾⠜⠻⠞⠙⠁⣰⣾⣿⣿⣽⣿⣿⣿⣿⣿⣷⣿⣞⣿⣿⠀⢸⣎⣟⢮⣏⠀⢺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⠙⠋⠛⠲⠛⠜⠉⠛⠈⠁⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿
⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢺⠀⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣿⠀⢸⡮⣝⡞⡼⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⢸⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣿⣿
⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⡿⣼⡹⣜⡱⠂⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡅⠈⢸⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿
⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⡿⢃⠀⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢠⢳⢧⡝⢦⡹⡁⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠄⢈⠻⣿⣧⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡰⢻⣿⣿⠇⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⢰⣋⢶⡹⢎⡵⣁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣝⢿⣿⣿⡄⠈⠰⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣰⣿⣿⡘⣿⠅⠠⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡜⡶⣩⢳⢎⡵⢸⣿⣿⣿⡏⠹⠿⠿⠟⠻⠛⡟⠻⠟⠀⣿⣿⣷⡀⠠⠙⣿⡏⣸⣿⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⡇⠝⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠂⢼⡹⣱⢏⡾⣘⠖⠘⣿⣿⣿⣿⣦⣉⣉⣭⣭⣤⣭⣤⣈⣸⣿⣿⣿⣧⠀⠘⢿⢀⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⠀⠊⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢧⡛⡽⢪⠵⣩⠞⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠹⣿⣿⣿⣿⣆⠈⠀⢸⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⡿⠂⠀⢸⣿⣿⣿⣟⡳⢯⣛⠿⣻⢿⡿⣿⠻⣟⣿⣿⣿⣿⠀⣎⠵⣩⢳⡙⢦⢫⠄⢻⣿⣿⣿⣿⢿⣻⡟⣿⢫⠟⡵⢪⢇⣹⣿⣿⣿⣿⡇⢈⠹⣿⣿⣿⣿⣿⣿⣿⣷⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⣠⣾⢿⣿⡗⢮⡑⢣⠘⡬⢑⠮⡑⢎⡱⢎⡞⡽⢹⡃⠀⡎⢧⠣⢧⣝⢣⢏⠆⡸⣏⠿⣱⢎⢳⡘⡜⢢⢋⡜⢌⡑⢊⠴⢻⣿⣿⣿⡇⠈⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
# Resolved: Overlay FS failure (and so containers)
15-12-2025 03:02 AM EET: Degraded control panels' performances, following by full cascade docker failure
15-12-2025 04:36 AM EET: Identified: Services are terminated due to server software (Overlay FS) + hardware issues (HDD).
15-12-2025 08:45 PM EET: Restored NextCloud service with few tweaks to lower I/O
---
16-12-2025 08:34 PM EET: Ordered new HDD, ETA 22nd of December - 2nd of January
---
17-12-2025 02:47 AM EET: To avoid additional I/O into kuma's database, disabled uptime monitoring for non-critical services, such as:
- Game servers
- Gitea (no public projects being hosted yet)
- Landings
- Cloud services
- PenPot
- Auth provider
- Secondary management tools
- Chernuha's non-important infrastructure
These can be identified by seeing ">2m ago" under monitor's heartbeats.
---
09-01-2026 02:32 PM EET: NextCloud's frontend files are corrupted due to the unknown issue. All user data is integrity-verified. To prevent user data corruption, NextCloud service will be restored after new hardware will be available.
---
14-01-2026 08:42 PM EET: After planned updating and restarting server, critical firmware software were corrupted because of physical degradation of the disk. Server inaccessible in any way
---
15-01-2026 11:30 AM EET: A new NAS-Grade HDD (Seagate IronWolf Pro) was ordered. ETA 16-01-2026 EET Before 12:00 PM
---
16-01-2026 12:47 AM EET: New server system is installed, data backed up. Experiencing docker memory leak.
---
17-01-2026 01:27 PM EET: All services except NextCloud and Satisfactory server are online.
17-01-2026 03:48 PM EET: Nextcloud is online. Satisfactory will be provided on-demand. Monitoring status
---
##### Status: All services are online
**Solution: moving all infrastructure onto new NAS-Grade HDD with fresh OS install**
+3
View File
@@ -0,0 +1,3 @@
KENER_SECRET_KEY=your_secret_key_here
ORIGIN=http://localhost:3000
TZ=Etc/UTC
+59
View File
@@ -0,0 +1,59 @@
services:
kener:
image: rajnandan1/kener:4.0.23
container_name: kener
restart: unless-stopped
# ports:
# - 3000:3000/tcp
environment:
- KENER_SECRET_KEY=${KENER_SECRET_KEY?Kener requires a secret key}
- ORIGIN=${ORIGIN:-http://localhost:3000}
- REDIS_URL=redis://redis:6379
- TZ:${TZ:-Etc/UTC}
depends_on:
redis:
condition: service_healthy
volumes:
- db:/app/database
- uploads:/app/uploads
labels:
- "traefik.enable=true"
- "traefik.http.services.kener.loadbalancer.server.port=3000"
# Prod Router
- "traefik.http.routers.kener.rule=Host(`status.forust.xyz`)"
- "traefik.http.routers.kener.entrypoints=websecure"
- "traefik.http.routers.kener.tls=true"
# Local Router
- "traefik.http.routers.kener-local.rule=Host(`status.workstation.internal`)"
- "traefik.http.routers.kener-local.entrypoints=websecure"
- "traefik.http.routers.kener-local.tls=true"
# Dev Router
- "traefik.http.routers.kener-dev.rule=Host(`status.gigaforust.internal`)"
- "traefik.http.routers.kener-dev.entrypoints=websecure"
- "traefik.http.routers.kener-dev.tls=true"
networks:
- proxy
- kener
redis:
image: redis:7-alpine
container_name: kener-redis
restart: unless-stopped
volumes:
- redis:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 6s
timeout: 5s
retries: 5
networks:
- kener
volumes:
db:
uploads:
redis:
networks:
proxy:
external: true
kener:
external: false
+20 -7
View File
@@ -1,10 +1,10 @@
services:
metube:
image: ghcr.io/alexta69/metube
# container_name: metube
container_name: metube
restart: unless-stopped
# ports:
# - "8081:8081"
# - "8081:8081"
environment:
- DOWNLOAD_MODE=limited
- MAX_CONCURRENT_DOWNLOADS=3
@@ -13,11 +13,24 @@ services:
volumes:
- ./MeTube_downloads:/downloads
labels:
- traefik.enable=true
- "traefik.docker.network=traefik-proxy"
- "traefik.enable=true"
- "traefik.http.services.metube.loadbalancer.server.port=8081"
# Prod Router
- "traefik.http.routers.metube.rule=Host(`metube.forust.xyz`)"
- "traefik.http.routers.metube.entrypoints=websecure"
- "traefik.http.routers.metube.middlewares=security-chain@file"
- "traefik.http.routers.metube.tls=true"
# Local Router
- "traefik.http.routers.metube-local.rule=Host(`metube.workstation.internal`)"
- "traefik.http.routers.metube-local.entrypoints=websecure"
- "traefik.http.routers.metube-local.tls=true"
# Dev Router
- "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.internal`)"
- "traefik.http.routers.metube-dev.entrypoints=websecure"
- "traefik.http.routers.metube-dev.tls=true"
networks:
- traefik-proxy
- proxy
networks:
traefik-proxy:
external: true
proxy:
external: true
+29 -11
View File
@@ -13,8 +13,8 @@ services:
- N8N_SECURE_COOKIE=false
- N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
volumes:
- ./n8n-node-data:/home/node/.n8n
- ./n8n-files:/files
- node-data:/home/node/.n8n
- files:/files
extra_hosts:
- "enterprise.n8n.io:104.26.13.187"
- "enterprise.n8n.io:104.26.12.187"
@@ -23,17 +23,35 @@ services:
- 1.1.1.1
- 8.8.8.8
networks:
- traefik-proxy
- proxy
- n8n
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- glance.name=n8n
- glance.icon=si:n8n
- glance.url=https://n8n.forust.xyz/
- glance.description=n8n is a workflow automation tool that enables you to connect various apps and services to automate tasks and processes.
- "traefik.enable=true"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
# Prod Router
- "traefik.http.routers.n8n.rule=Host(`n8n.forust.xyz`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls=true"
# Local Router
- "traefik.http.routers.n8n-local.rule=Host(`n8n.workstation.internal`)"
- "traefik.http.routers.n8n-local.entrypoints=websecure"
- "traefik.http.routers.n8n-local.tls=true"
# Dev Router
- "traefik.http.routers.n8n-dev.rule=Host(`n8n.gigaforust.internal`)"
- "traefik.http.routers.n8n-dev.entrypoints=websecure"
- "traefik.http.routers.n8n-dev.tls=true"
- glance.name=n8n
- glance.icon=si:n8n
- glance.url=https://n8n.forust.xyz/
- glance.description=n8n is a workflow automation tool that enables you to connect various apps and services to automate tasks and processes.
networks:
n8n:
external: false
traefik-proxy:
external: true
proxy:
external: true
volumes:
node-data:
files:
+29
View File
@@ -0,0 +1,29 @@
## https://github.com/autobrr/netronome?tab=readme-ov-file#environment-variables
# Server settings
NETRONOME__HOST=0.0.0.0 # Listen address
NETRONOME__PORT=7575 # Web UI port
NETRONOME__BASE_URL=/netronome # Base URL for reverse proxy
# Database (SQLite by default)
NETRONOME__DB_TYPE=postgres # sqlite or postgres
NETRONOME__DB_PATH=netronome.db # SQLite database path
# PostgreSQL (when DB_TYPE=postgres)
NETRONOME__DB_TYPE=postgres
NETRONOME__DB_HOST=postgres
NETRONOME__DB_PORT=5432
NETRONOME__DB_USER=netronome
NETRONOME__DB_PASSWORD=netronome
NETRONOME__DB_NAME=netronome
NETRONOME__DB_SSLMODE=disable
# Authentication
NETRONOME__AUTH_WHITELIST=127.0.0.1/32,192.168.1.0/24 # IP whitelist (comma-separated)
NETRONOME__SESSION_SECRET= # Session secret (auto-generated if empty)
# OIDC (optional)
NETRONOME__OIDC_ISSUER=https://accounts.google.com
NETRONOME__OIDC_CLIENT_ID=your-client-id
NETRONOME__OIDC_CLIENT_SECRET=your-secret
NETRONOME__OIDC_REDIRECT_URL=https://example.com/api/auth/oidc/callback
+59
View File
@@ -0,0 +1,59 @@
services:
netronome:
image: ghcr.io/autobrr/netronome:latest
restart: unless-stopped
container_name: netronome
ports:
- "7575:7575"
cap_add:
- NET_RAW
env_file:
- .env
labels:
- "traefik.enable=true"
- "traefik.http.services.netronome.loadbalancer.server.port=7575"
# Prod Router
- "traefik.http.routers.netronome.rule=Host(`nm.forust.xyz`)"
- "traefik.http.routers.netronome.entrypoints=websecure"
- "traefik.http.routers.netronome.tls=true"
# Local Router
- "traefik.http.routers.netronome-local.rule=Host(`nm.workstation.internal`)"
- "traefik.http.routers.netronome-local.entrypoints=websecure"
- "traefik.http.routers.netronome-local.tls=true"
# Dev Router
- "traefik.http.routers.netronome-dev.rule=Host(`nm.gigaforust.internal`)"
- "traefik.http.routers.netronome-dev.entrypoints=websecure"
- "traefik.http.routers.netronome-dev.tls=true"
networks:
- proxy
- netronome
depends_on:
postgres:
condition: service_healthy
postgres:
container_name: netronome-postgres
image: postgres:17-alpine
environment:
- POSTGRES_USER=netronome
- POSTGRES_PASSWORD=netronome
- POSTGRES_DB=netronome
volumes:
- data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U netronome" ]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- netronome
volumes:
data:
networks:
proxy:
external: true
netronome:
external: false
+55 -36
View File
@@ -1,58 +1,77 @@
services:
nextcloud-aio-mastercontainer:
image: ghcr.io/nextcloud-releases/all-in-one:latest # This is the container image used. You can switch to ghcr.io/nextcloud-releases/all-in-one:beta if you want to help testing new releases. See https://github.com/nextcloud/all-in-one#how-to-switch-the-channel
image: ghcr.io/nextcloud-releases/all-in-one:beta
init: true # This setting makes sure that signals from main process inside the container are correctly forwarded to children. See https://docs.docker.com/reference/compose-file/services/#init
restart: unless-stopped # This makes sure that the container starts always together with the host OS. See https://docs.docker.com/reference/compose-file/services/#restart
container_name: nextcloud-aio-mastercontainer # This line is not allowed to be changed as otherwise AIO will not work correctly
restart: unless-stopped
container_name: nextcloud-aio-mastercontainer # Do not change
volumes:
- nextcloud_aio_mastercontainer:/mnt/docker-aio-config # This line is not allowed to be changed as otherwise the built-in backup solution will not work
- /var/run/docker.sock:/var/run/docker.sock:ro # May be changed on macOS, Windows or docker rootless. See the applicable documentation. If adjusting, don't forget to also set 'WATCHTOWER_DOCKER_SOCKET_PATH'!
# network_mode: bridge # This adds the container to the same network as docker run would do. Comment this line and uncomment the line below and the networks section at the end of the file if you want to define a custom MTU size for the docker network
networks:
- nextcloud_aio_mastercontainer:/mnt/docker-aio-config # Do not change (backup)
- /var/run/docker.sock:/var/run/docker.sock:ro
- /dev/dri:/dev/dri
networks:
- nextcloud-aio
- traefik-proxy # Optional: Connects the mastercontainer to the traefik-proxy network in order to make the built-in reverse proxy detection work.
# ports:
# - 8081:80 # Can be removed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
# - 8888:8080 # This is the AIO interface, served via https and self-signed certificate. See https://github.com/nextcloud/all-in-one#explanation-of-used-ports
# - 8443:8443 # Can be removed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
- proxy # Optional: Connects the mastercontainer to the proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
# ports:
# - 8081:80 # may be removed if under reverse-proxy
# - 8443:8443
# - 8888:8080 # AIO
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- glance.name=Nextcloud
# - glance.icon=si:nextcloud
- glance.url=https://nextcloud.forust.xyz/
- glance.description=Nextcloud is a suite of client-server software for creating and using file hosting services.
- "traefik.enable=true"
# AIO Services configuration
- "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080"
- "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https"
- "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file"
# Prod Router
# - "traefik.http.routers.nextcloud-aio.rule=Host(`naio.forust.xyz`)"
# - "traefik.http.routers.nextcloud-aio.entrypoints=websecure"
# - "traefik.http.routers.nextcloud-aio.middlewares=security-chain@file"
# - "traefik.http.routers.nextcloud-aio.tls=true"
# Local Router
- "traefik.http.routers.nextcloud-aio-local.rule=Host(`naio.workstation.internal`)"
- "traefik.http.routers.nextcloud-aio-local.entrypoints=websecure"
- "traefik.http.routers.nextcloud-aio-local.tls=true"
# Dev Router
- "traefik.http.routers.nextcloud-aio-dev.rule=Host(`naio.gigaforust.internal`)"
- "traefik.http.routers.nextcloud-aio-dev.entrypoints=websecure"
- "traefik.http.routers.nextcloud-aio-dev.tls=true"
environment: # Is needed when using any of the options below
AIO_DISABLE_BACKUP_SECTION: false # Setting this to true allows to hide the backup section in the AIO interface. See https://github.com/nextcloud/all-in-one#how-to-disable-the-backup-section
# Glance Metadata
- glance.name=Nextcloud
- glance.url=https://nextcloud.forust.xyz/
- glance.description=Nextcloud is a suite of client-server software for creating and using file hosting services.
environment:
AIO_DISABLE_BACKUP_SECTION: false
APACHE_PORT: 11000 # Is needed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
APACHE_IP_BINDING: 0.0.0.0 # Should be set when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) that is running on the same host. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
APACHE_ADDITIONAL_NETWORK: traefik-proxy # (Optional) Connect the apache container to an additional docker network. Needed when behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) running in a different docker network on same server. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Allows to adjust borgs retention policy. See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy
APACHE_IP_BINDING: 0.0.0.0 # Configure when going with reverse-proxy https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
APACHE_ADDITIONAL_NETWORK: proxy # (Optional) Connect the apache container to an additional docker network. When going with reverse-proxy. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Backup retention See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy
COLLABORA_SECCOMP_DISABLED: false # Setting this to true allows to disable Collabora's Seccomp feature. See https://github.com/nextcloud/all-in-one#how-to-disable-collaboras-seccomp-feature
FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # Allows to adjust the fulltextsearch java options. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options
NEXTCLOUD_DATADIR: /media/forust/nextcloud/ncdata # Allows to set the host directory for Nextcloud's datadir. ⚠️⚠️⚠️ Warning: do not set or adjust this value after the initial Nextcloud installation is done! See https://github.com/nextcloud/all-in-one#how-to-change-the-default-location-of-nextclouds-datadir
NEXTCLOUD_MOUNT: /media/forust/nextcloud # Allows the Nextcloud container to access the chosen directory on the host. See https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host
NEXTCLOUD_UPLOAD_LIMIT: 16G # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud
NEXTCLOUD_MAX_TIME: 3600 # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud
FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # adjust fulltextsearch java options. https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options
NEXTCLOUD_DATADIR: /mnt/nextcloud/ncdata # Allows to set the host directory for Nextcloud's datadir. ⚠️⚠️⚠️ Warning: do not set or adjust this value after the initial Nextcloud installation is done! See https://github.com/nextcloud/all-in-one#how-to-change-the-default-location-of-nextclouds-datadir
NEXTCLOUD_MOUNT: /mnt/ # Allows the Nextcloud container to access the chosen directory on the host. See https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host
NEXTCLOUD_UPLOAD_LIMIT: 16G # https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud
NEXTCLOUD_MAX_TIME: 7200 # Max uploading time See https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud
NEXTCLOUD_MEMORY_LIMIT: 512M # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-php-memory-limit-for-nextcloud
# NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts # CA certificates in this directory will be trusted by the OS of the nextcloud container (Useful e.g. for LDAPS) See https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca
# NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts # CA certificates will be trusted by the OS of the nextcloud container See https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca
NEXTCLOUD_STARTUP_APPS: deck twofactor_totp tasks calendar contacts notes # Allows to modify the Nextcloud apps that are installed on starting AIO the first time. See https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup
NEXTCLOUD_ADDITIONAL_APKS: imagemagick # This allows to add additional packages to the Nextcloud container permanently. Default is imagemagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-os-packages-permanently-to-the-nextcloud-container
NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # This allows to add additional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container
NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud
NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # dditional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container
# NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud
# NEXTCLOUD_KEEP_DISABLED_APPS: false # Setting this to true will keep Nextcloud apps that are disabled in the AIO interface and not uninstall them if they should be installed. See https://github.com/nextcloud/all-in-one#how-to-keep-disabled-apps
SKIP_DOMAIN_VALIDATION: false # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation
SKIP_DOMAIN_VALIDATION: true # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation
# TALK_PORT: 3478 # This a-llows to adjust the port that the talk container is using which is exposed on the host. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-talk-port
# WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. Otherwise mastercontainer updates will fail. For macos it needs to be '/var/run/docker.sock'
# WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. For macos it needs to be '/var/run/docker.sock'
networks:
traefik-proxy:
proxy:
external: true
nextcloud-aio:
name: nextcloud-aio
driver: bridge
external: false
volumes: # If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive
volumes:
# If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive
nextcloud_aio_mastercontainer:
name: nextcloud_aio_mastercontainer # This line is not allowed to be changed as otherwise the built-in backup solution will not work
name: nextcloud_aio_mastercontainer # Do not change
+1
View File
@@ -0,0 +1 @@
PENPOT_SECRET_KEY=somesecretfdsfsdfds
+284
View File
@@ -0,0 +1,284 @@
## Common flags:
# demo-users
# email-verification
# log-emails
# log-invitation-tokens
# login-with-github
# login-with-gitlab
# login-with-google
# login-with-ldap
# login-with-oidc
# login-with-password
# prepl-server
# registration
# secure-session-cookies
# smtp
# smtp-debug
# telemetry
# webhooks
##
## You can read more about all available flags and other
## environment variables here:
## https://help.penpot.app/technical-guide/configuration/#penpot-configuration
#
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
x-flags: &penpot-flags
PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies login-with-github
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001
x-body-size:
# Max body size (30MiB); Used for plain requests, should never be
# greater than multi-part size
&penpot-http-body-size
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280
# Max multipart body size (350MiB)
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
## (eg http sessions, or invitations) are derived.
##
## We recommend to use a trully randomly generated
## 512 bits base64 encoded string here. You can generate one with:
##
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
networks:
penpot:
proxy:
external: true
volumes:
penpot_postgres_v15:
penpot_assets:
penpot_traefik:
# penpot_minio:
services:
## Traefik service declaration example. Consider using it if you are going to expose
## penpot to the internet, or a different host than `localhost`.
# traefik:
# image: traefik:v3.3
# networks:
# - penpot
# command:
# - "--api.insecure=true"
# - "--entryPoints.web.address=:80"
# - "--providers.docker=true"
# - "--providers.docker.exposedbydefault=false"
# - "--entryPoints.websecure.address=:443"
# - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
# - "--certificatesresolvers.letsencrypt.acme.email=<EMAIL_ADDRESS>"
# - "--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json"
# volumes:
# - "penpot_traefik:/traefik"
# - "/var/run/docker.sock:/var/run/docker.sock"
# ports:
# - "80:80"
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-latest}"
restart: always
# ports:
# - 9001:8080
volumes:
- penpot_assets:/opt/data/assets
depends_on:
- penpot-backend
- penpot-exporter
networks:
- penpot
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.services.penpot.loadbalancer.server.port=8080"
# Prod Router
- "traefik.http.routers.penpot.rule=Host(`penpot.forust.xyz`)"
- "traefik.http.routers.penpot.entrypoints=websecure"
- "traefik.http.routers.penpot.tls=true"
# Local Router
- "traefik.http.routers.penpot-local.rule=Host(`penpot.workstation.internal`)"
- "traefik.http.routers.penpot-local.entrypoints=websecure"
- "traefik.http.routers.penpot-local.tls=true"
# Dev Router
- "traefik.http.routers.penpot-dev.rule=Host(`penpot.gigaforust.internal`)"
- "traefik.http.routers.penpot-dev.entrypoints=websecure"
- "traefik.http.routers.penpot-dev.tls=true"
environment:
<<: [ *penpot-flags, *penpot-http-body-size ]
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-latest}"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
networks:
- penpot
## Configuration envronment variables for the backend container.
environment:
<<: [ *penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key ]
## The PREPL host. Mainly used for external programatic access to penpot backend
## (example: admin). By default it will listen on `localhost` but if you are going to use
## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`.
# PENPOT_PREPL_HOST: 0.0.0.0
## Database connection parameters. Don't touch them unless you are using custom
## postgresql connection parameters.
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
## Valkey (or previously redis) is used for the websockets notifications. Don't touch
## unless the valkey container has different parameters or different name.
PENPOT_REDIS_URI: redis://penpot-valkey/0
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage
## service like MiniIO. Look below for minio service setup.
# AWS_ACCESS_KEY_ID: <KEY_ID>
# AWS_SECRET_ACCESS_KEY: <ACCESS_KEY>
# PENPOT_ASSETS_STORAGE_BACKEND: assets-s3
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000
# PENPOT_STORAGE_ASSETS_S3_BUCKET: <BUKET_NAME>
## Telemetry. When enabled, a periodical process will send anonymous data about this
## instance. Telemetry data will enable us to learn how the application is used,
## based on real scenarios. If you want to help us, please leave it enabled. You can
## audit what data we send with the code available on github.
PENPOT_TELEMETRY_ENABLED: true
PENPOT_TELEMETRY_REFERER: compose
# PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com
# PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com
# PENPOT_SMTP_HOST: penpot-mailcatch
# PENPOT_SMTP_PORT: 1025
# PENPOT_SMTP_USERNAME:
# PENPOT_SMTP_PASSWORD:
# PENPOT_SMTP_TLS: false
# PENPOT_SMTP_SSL: false
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-latest}"
restart: always
depends_on:
penpot-valkey:
condition: service_healthy
networks:
- penpot
environment:
<<: [ *penpot-secret-key ]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: http://penpot-frontend:8080
## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-postgres:
image: "postgres:15"
restart: always
stop_signal: SIGINT
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U penpot" ]
interval: 2s
timeout: 10s
retries: 5
start_period: 2s
volumes:
- penpot_postgres_v15:/var/lib/postgresql/data
networks:
- penpot
environment:
- POSTGRES_INITDB_ARGS=--data-checksums
- POSTGRES_DB=penpot
- POSTGRES_USER=penpot
- POSTGRES_PASSWORD=penpot
penpot-valkey:
image: valkey/valkey:8.1
restart: always
healthcheck:
test: [ "CMD-SHELL", "valkey-cli ping | grep PONG" ]
interval: 1s
timeout: 3s
retries: 5
start_period: 3s
networks:
- penpot
environment:
# You can increase the max memory size if you have sufficient resources,
# although this should not be necessary.
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the
## port 1080 for read all emails the penpot platform has sent. Should be only used as a
## temporal solution while no real SMTP provider is configured.
# penpot-mailcatch:
# image: sj26/mailcatcher:latest
# restart: always
# expose:
# - '1025'
# ports:
# - "1080:1080"
# networks:
# - penpot
## Example configuration of MiniIO (S3 compatible object storage service); If you don't
## have preference, then just use filesystem, this is here just for the completeness.
# minio:
# image: "minio/minio:latest"
# command: minio server /mnt/data --console-address ":9001"
# restart: always
#
# volumes:
# - "penpot_minio:/mnt/data"
#
# environment:
# - MINIO_ROOT_USER=minioadmin
# - MINIO_ROOT_PASSWORD=minioadmin
#
# ports:
# - 9000:9000
# - 9001:9001
+23 -14
View File
@@ -1,30 +1,39 @@
services:
portainer:
image: portainer/portainer-ce:2.41.0
container_name: portainer
image: portainer/portainer-ce:lts
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./portainer_data:/data
- data:/data
ports:
- 9443:9443
- 9000:9000
# - 8000:8000 # Remove if you do not intend to use Edge Agents
networks:
- traefik-proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
- "traefik.http.services.portainer.loadbalancer.server.port=9000"
# Prod Router
- "traefik.http.routers.portainer.rule=Host(`portainer.forust.xyz`)"
- "traefik.http.routers.portainer.entrypoints=websecure"
- "traefik.http.routers.portainer.tls=true"
# Local Router
- "traefik.http.routers.portainer-local.rule=Host(`portainer.workstation.internal`)"
- "traefik.http.routers.portainer-local.entrypoints=websecure"
- "traefik.http.routers.portainer-local.tls=true"
# Dev Router
- "traefik.http.routers.portainer-dev.rule=Host(`portainer.gigaforust.internal`)"
- "traefik.http.routers.portainer-dev.entrypoints=websecure"
- "traefik.http.routers.portainer-dev.tls=true"
# Glance Metadata
- glance.name=Portainer
# - glance.icon=si:portainer
- glance.url=https://portainer.forust.xyz/
- glance.description=Portainer is a lightweight management UI which allows you to easily manage your Docker environments.
networks:
- proxy
volumes:
portainer_data:
name: portainer_data
data:
networks:
default:
name: portainer_network
traefik-proxy:
proxy:
external: true
+15
View File
@@ -0,0 +1,15 @@
# Read the documentation before using the `docker-compose.yml` file:
# https://docs.searxng.org/admin/installation-docker.html
#
# Additional ENVs:
# https://docs.searxng.org/admin/settings/settings_general.html#settings-general
# https://docs.searxng.org/admin/settings/settings_server.html#settings-server
# Use a specific version tag. E.g. "latest" or "2026.3.25-541c6c3cb".
#SEARXNG_VERSION=latest
# Listen to a specific address.
#SEARXNG_HOST=[::]
# Listen to a specific port.
SEARXNG_PORT=8080
+45
View File
@@ -0,0 +1,45 @@
# Read the documentation before using the `docker-compose.yml` file:
# https://docs.searxng.org/admin/installation-docker.html
services:
core:
container_name: searxng-core
image: docker.io/searxng/searxng:${SEARXNG_VERSION:-latest}
restart: unless-stopped
# ports:
# - ${SEARXNG_PORT:-8080}
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.services.searxng.loadbalancer.server.port=8080"
# Prod Router
- "traefik.http.routers.searxng.rule=Host(`s.forust.xyz` || `searxng.forust.xyz`)"
- "traefik.http.routers.searxng.entrypoints=websecure"
- "traefik.http.routers.searxng.tls=true"
# Local Router
- "traefik.http.routers.searxng-local.rule=Host(`s.workstation.internal` || `searxng.workstation.internal`)"
- "traefik.http.routers.searxng-local.entrypoints=websecure"
- "traefik.http.routers.searxng-local.tls=true"
# Dev Router
- "traefik.http.routers.searxng-dev.rule=Host(`searxng.gigaforust.internal`)"
- "traefik.http.routers.searxng-dev.entrypoints=websecure"
- "traefik.http.routers.searxng-dev.tls=true"
networks:
- proxy
volumes:
- ./core-config/:/etc/searxng/:Z
- core-data:/var/cache/searxng/
valkey:
container_name: searxng-valkey
image: docker.io/valkey/valkey:9-alpine
command: valkey-server --save 30 1 --loglevel warning
restart: unless-stopped
volumes:
- valkey-data:/data/
volumes:
core-data:
valkey-data:
networks:
proxy:
external: true
+34
View File
@@ -0,0 +1,34 @@
services:
termix:
image: ghcr.io/lukegus/termix:latest
container_name: termix
restart: unless-stopped
# ports:
# - "3331:8080"
volumes:
- data:/app/data
environment:
PORT: "8080"
labels:
- "traefik.enable=true"
- "traefik.http.services.termix.loadbalancer.server.port=8080"
# Prod Router
- "traefik.http.routers.termix.rule=Host(`termix.forust.xyz`)"
- "traefik.http.routers.termix.entrypoints=websecure"
- "traefik.http.routers.termix.tls=true"
# Local Router
- "traefik.http.routers.termix-local.rule=Host(`termix.workstation.internal`)"
- "traefik.http.routers.termix-local.entrypoints=websecure"
- "traefik.http.routers.termix-local.tls=true"
# Dev Router
- "traefik.http.routers.termix-dev.rule=Host(`termix.gigaforust.internal`)"
- "traefik.http.routers.termix-dev.entrypoints=websecure"
- "traefik.http.routers.termix-dev.tls=true"
networks:
- proxy
networks:
proxy:
external: true
volumes:
data:
+3
View File
@@ -0,0 +1,3 @@
# ===================================
# Traefik envs
EMAIL=bobrovod@national.shitposting.agency
+44 -32
View File
@@ -1,69 +1,81 @@
services:
traefik:
image: traefik:v3.5
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
command:
# API
- "--api.insecure=false"
- "--api.insecure=true"
- "--api.dashboard=true"
# Providers
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=traefik-proxy"
- "--providers.docker.network=proxy"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
# EntryPoints
- "--entryPoints.web.address=:80"
- "--entryPoints.websecure.address=:443"
- "--entryPoints.websecure.http.tls=true"
- "--entryPoints.web.http.redirections.entryPoint.to=websecure"
- "--entryPoints.web.http.redirections.entryPoint.scheme=https"
- "--entryPoints.web.http.redirections.entryPoint.to=websecure"
- "--entryPoints.websecure.address=:443"
- "--entryPoints.websecure.http.middlewares=error-pages@docker"
- "--entryPoints.websecure.http.tls=true"
- "--entryPoints.ssh.address=:2221"
# Let's Encrypt STAGING. CURRENTLY USING CF ORIGIN CA INSTEAD
# - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}"
# - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
# Let's Encrypt
- "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
# # STAGING
# - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
# - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
# - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
# Cloudflare
- "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22"
- "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22"
# Logging
- "--log.level=INFO"
- "--log.filePath=/var/log/traefik/traefik.log"
- "--log.format=json"
- "--accesslog=true"
- "--accesslog.filepath=/var/log/traefik/access.log"
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-proxy"
# Prod Router (Dash)
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.forust.xyz`)"
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard.middlewares=security-chain@file"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.tls=true"
# Local Router
- "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.internal`)"
- "traefik.http.routers.traefik-dashboard-local.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard-local.service=api@internal"
- "traefik.http.routers.traefik-dashboard-local.tls=true"
# Dev Router
- "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.internal`)"
- "traefik.http.routers.traefik-dashboard-dev.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard-dev.service=api@internal"
- "traefik.http.routers.traefik-dashboard-dev.tls=true"
# Glance Metadata
- glance.name=Traefik
- glance.url=https://traefik.forust.xyz/
- glance.description=Traefik is a modern reverse proxy and load balancer
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dynamic:/etc/traefik/dynamic:ro
- ./certs:/certs:ro
- ./logs:/var/log/traefik
# - ./traefik/letsencrypt:/letsencrypt
- ./letsencrypt:/letsencrypt
ports:
- "80:80"
- "443:443"
networks:
- traefik-proxy
environment:
- TZ=Europe/Bratislava
- proxy
networks:
traefik-proxy:
external: true
proxy:
external: true
+1 -310
View File
@@ -1,313 +1,4 @@
http:
routers:
# Traefik Dashboard (ADMIN, local only)
traefik-dashboard:
rule: "Host(`traefik.workstation`)"
entryPoints:
- websecure
service: api@internal
middlewares:
# - auth
- security-headers
tls: {}
# Nextcloud AIO Interface (ADMIN, local is better)
nextcloud-aio:
rule: "Host(`nextcloud-aio.workstation`)"
entryPoints:
- websecure
service: nextcloud-aio
middlewares:
- security-headers
tls: {}
# MeTube
metube:
rule: "Host(`metube.forust.xyz`)"
entryPoints:
- websecure
service: metube
middlewares:
- metube-auth
- security-headers
tls: {}
# Portainer (public (account required))
portainer:
rule: "Host(`portainer.forust.xyz`)"
entryPoints:
- websecure
service: portainer
middlewares:
- security-headers
tls: {}
# # Uptime Kuma
# uptime-kuma:
# rule: "Host(`uptime-kuma.forust.xyz`)"
# entryPoints:
# - websecure
# service: uptime-kuma
# # middlewares:
# # - security-headers
# tls: {}
# AdGuard Home (public (account required))
adguard:
rule: "Host(`adguard.forust.xyz`)"
entryPoints:
- websecure
service: adguard
middlewares:
- security-headers
tls: {}
# Nextcloud Main (public (account required))
nextcloud:
rule: "Host(`nextcloud.forust.xyz`)"
entrypoints:
- websecure
service: nextcloud
middlewares:
- nextcloud-chain
tls: {}
# Dockmon (public (account required))
dockmon:
rule: "Host(`dockmon.forust.xyz`)"
entryPoints:
- websecure
service: dockmon
middlewares:
# - dockmon-auth
- security-headers
tls: {}
# Gitea (public (account required))
gitea:
rule: "Host(`gitea.forust.xyz`)"
entryPoints:
- websecure
- ssh
service: gitea
middlewares:
- security-headers
tls: {}
# # Glance (local only)
# glance:
# rule: "Host(`glance.workstation`)"
# entryPoints:
# - websecure
# service: glance
# middlewares:
# - security-headers
# tls: {}
# Watercrawl (local only)
watercrawl:
rule: "Host(`watercrawl.workstation`)"
entryPoints:
- websecure
service: watercrawl
middlewares:
- security-headers
tls: {}
# N8N (local only)
n8n:
rule: "Host(`n8n.workstation`)"
entryPoints:
- websecure
service: n8n
middlewares:
- security-headers
tls: {}
services:
# Portainer
portainer:
loadBalancer:
servers:
- url: "https://portainer:9443"
serversTransport: insecureTransport
# AdGuard Home
adguard:
loadBalancer:
servers:
- url: "http://adguardhome:3000"
# Nextcloud AIO Interface
nextcloud-aio:
loadBalancer:
servers:
- url: "https://nextcloud-aio-mastercontainer:8080"
serversTransport: insecureTransport
# Nextcloud Main
nextcloud:
loadBalancer:
servers:
- url: "http://nextcloud-aio-apache:11000"
# # Uptime Kuma
# uptime-kuma:
# loadBalancer:
# servers: "http://uptime-kuma:3001"
# Dockmon
dockmon:
loadBalancer:
servers:
- url: "https://dockmon:443"
serversTransport: insecureTransport
# # Glance
# glance:
# loadBalancer:
# servers:
# - url: "http://glance:8080"
# Watercrawl
watercrawl:
loadBalancer:
servers:
- url: "http://nginx:80"
# N8N
n8n:
loadBalancer:
servers:
- url: "http://n8n:5678"
# Gitea
gitea:
loadBalancer:
servers:
- url: "http://gitea:3000"
# MeTube
metube:
loadBalancer:
servers:
- url: "http://metube:8081"
serversTransports:
insecureTransport:
insecureSkipVerify: true
middlewares:
# HTTPS Redirect
redirect-https:
redirectScheme:
scheme: https
permanent: true
# Metube Basic Auth
metube-auth:
basicAuth:
users:
- "admin:$2y$05$3Q6gyLFW3NFNp4C6elnyfupntqB6VNB/tcIAeo8NEzvaqPxOjN0iC"
# - "jeepik:$2y$05$tIKrmhd7SYOe6yImRRAfpen7hpVdF8PnSbgBTCDZ.GI0Djx.Le2bq"
realm: "MeTube Access"
# Basic Auth Traefik Dashboard
auth:
basicAuth:
users:
- "admin:$$apr1$$57E60OUM$$JoYwmLr/uZKaTy6U4IQd9."
# - "jeepik:$2y$05$Q8QqJwSjpycVYONyk4id/.rDFApW9oL8tycRMlGttNySsDv71Rnsu"
# - "vv:"
realm: "Traefik Dashboard"
# Basic Auth Dockmon
dockmon-auth:
basicAuth:
users:
- "admin:$$apr1$$.bCpmIHl$$dxPEKdw5aZLAwo8wUz52b1"
realm: "Dockmon Access"
# Cloudflare IP Whitelist
cloudflare-ipwhitelist:
ipWhiteList:
sourceRange:
- "173.245.48.0/20"
- "103.21.244.0/22"
- "103.22.200.0/22"
- "103.31.4.0/22"
- "141.101.64.0/18"
- "108.162.192.0/18"
- "190.93.240.0/20"
- "188.114.96.0/20"
- "197.234.240.0/22"
- "198.41.128.0/17"
- "162.158.0.0/15"
- "104.16.0.0/13"
- "104.24.0.0/14"
- "172.64.0.0/13"
- "131.0.72.0/22"
# Security Headers
security-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: "SAMEORIGIN"
customResponseHeaders:
X-Content-Type-Options: "nosniff"
Referrer-Policy: "strict-origin-when-cross-origin"
# Nextcloud specific headers
nextcloud-secure-headers:
headers:
hostsProxyHeaders:
- "X-Forwarded-Host"
- "X-Forwarded-Proto"
referrerPolicy: "same-origin"
customFrameOptionsValue: "SAMEORIGIN"
# Rate limiting
rate-limit:
rateLimit:
average: 100
burst: 50
period: 1m
# Nextcloud chain
nextcloud-chain:
chain:
middlewares:
- nextcloud-secure-headers
- security-headers
# TLS Configuration
tls:
certificates:
# Cloudflare Origin CA *.forust.xyz
- certFile: /certs/cloudflare.pem
keyFile: /certs/cloudflare.key
# Local certificate
- certFile: /certs/workstation+1.pem
keyFile: /certs/workstation+1-key.pem
stores:
default:
defaultCertificate:
# Fallback local certificate
certFile: /certs/workstation+1.pem
keyFile: /certs/workstation+1-key.pem
options:
default:
minVersion: VersionTLS12
sniStrict: true
cipherSuites:
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
insecureSkipVerify: true
+23
View File
@@ -0,0 +1,23 @@
http:
routers:
fs1-public:
rule: "Host(`fs1.domain.xyz`)"
entrypoints:
- websecure
service: fs1
middlewares:
- security-chain@file
tls: {}
fs1-workstation:
rule: "Host(`fs1.workstation.internal`)"
entrypoints:
- websecure
service: fs1
tls: {}
services:
fs1:
loadBalancer:
servers:
- url: "http://127.0.0.1:3923" # Copyparty port example
+87
View File
@@ -0,0 +1,87 @@
http:
middlewares:
# HTTPS Redirect
redirect-https:
redirectScheme:
scheme: https
permanent: true
# Cloudflare IP Whitelist
cloudflare-ipwhitelist:
ipWhiteList:
sourceRange:
- "173.245.48.0/20"
- "103.21.244.0/22"
- "103.22.200.0/22"
- "103.31.4.0/22"
- "141.101.64.0/18"
- "108.162.192.0/18"
- "190.93.240.0/20"
- "188.114.96.0/20"
- "197.234.240.0/22"
- "198.41.128.0/17"
- "162.158.0.0/15"
- "104.16.0.0/13"
- "104.24.0.0/14"
- "172.64.0.0/13"
- "131.0.72.0/22"
# Authentik + secure headers
security-chain:
chain:
middlewares:
- authentik@file
- security-headers@file
authentik:
forwardAuth:
address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true
maxResponseBodySize: 1048576
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
# Security Headers
security-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: "SAMEORIGIN"
customResponseHeaders:
X-Content-Type-Options: "nosniff"
Referrer-Policy: "strict-origin-when-cross-origin"
# Nextcloud specific headers
nextcloud-secure-headers:
headers:
hostsProxyHeaders:
- "X-Forwarded-Host"
- "X-Forwarded-Proto"
referrerPolicy: "same-origin"
customFrameOptionsValue: "SAMEORIGIN"
# Rate limiting
rate-limit:
rateLimit:
average: 100
burst: 50
period: 1m
# Nextcloud chain
nextcloud-chain:
chain:
middlewares:
- nextcloud-secure-headers@file
- security-headers@file
+38
View File
@@ -0,0 +1,38 @@
http:
routers:
# Nextcloud prod
nextcloud:
rule: "Host(`nextcloud.forust.xyz`)"
entrypoints:
- websecure
service: nextcloud
middlewares:
- nextcloud-chain
tls: {}
# Nextcloud dev
nextcloud-local:
rule: "Host(`nextcloud.workstation.internal`)"
entrypoints:
- websecure
service: nextcloud
middlewares:
- nextcloud-chain
tls: {}
# Nextcloud dev
nextcloud-dev:
rule: "Host(`nextcloud.gigaforust.internal`)"
entrypoints:
- websecure
service: nextcloud
middlewares:
- nextcloud-chain
tls: {}
services:
# Nextcloud Main
nextcloud:
loadBalancer:
servers:
- url: "http://nextcloud-aio-apache:11000"
+8
View File
@@ -0,0 +1,8 @@
http:
routers:
acme-challenge-exempt:
rule: "PathPrefix(`/.well-known/acme-challenge`)"
entryPoints:
- web
service: noop@internal
priority: 100
+21
View File
@@ -0,0 +1,21 @@
# TLS Configuration
tls:
certificates:
# Cloudflare Origin CA *.forust.xyz
# - certFile: /certs/cloudflare.pem
# keyFile: /certs/cloudflare.key
# stores:
# default:
# defaultCertificate:
# # Fallback local certificate
# certFile: /certs/local.pem
# keyFile: /certs/local-key.pem
options:
default:
minVersion: VersionTLS12
sniStrict: false
cipherSuites:
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
+24 -15
View File
@@ -1,23 +1,32 @@
services:
uptime-kuma:
image: louislam/uptime-kuma:2
restart: unless-stopped
container_name: uptime-kuma
restart: unless-stopped
volumes:
- ./data:/app/data
ports:
# <Host Port>:<Container Port>
- "3001:3001"
- data:/app/data
# ports:
# - "3001:3001"
labels:
- "traefik.enable=true"
- "traefik.http.routers.uptime-kuma.rule=Host(`uptime-kuma.forust.xyz`)"
- "traefik.http.routers.uptime-kuma.entrypoints=websecure"
- "traefik.http.routers.uptime-kuma.tls=true"
# - "traefik.http.routers.uptime-kuma.tls.certresolver=myresolver"
- "traefik.http.services.uptime-kuma.loadBalancer.server.port=3001"
- "traefik.enable=true"
- "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001"
# Prod Router
- "traefik.http.routers.uptime-kuma.rule=Host(`uptime.forust.xyz`)"
- "traefik.http.routers.uptime-kuma.entrypoints=websecure"
- "traefik.http.routers.uptime-kuma.tls=true"
# Local Router
- "traefik.http.routers.uptime-kuma-local.rule=Host(`uptime.workstation.internal`)"
- "traefik.http.routers.uptime-kuma-local.entrypoints=websecure"
- "traefik.http.routers.uptime-kuma-local.tls=true"
# Dev Router
- "traefik.http.routers.uptime-kuma-dev.rule=Host(`uptime.gigaforust.internal`)"
- "traefik.http.routers.uptime-kuma-dev.entrypoints=websecure"
- "traefik.http.routers.uptime-kuma-dev.tls=true"
networks:
- traefik-proxy
- proxy
volumes:
data:
networks:
traefik-proxy:
external: true
proxy:
external: true
+11
View File
@@ -1 +1,12 @@
.unused
Downloads
.venv
volumes
.env
.env.*
my_account.session
.gitignore
README.md
.git
uv.lock
.deepsource.toml
+3
View File
@@ -0,0 +1,3 @@
API_ID=""
API_HASH=""
STRINGSESSION=""
+14
View File
@@ -0,0 +1,14 @@
# apiflash api key only for webshot plugin
APIFLASH_KEY=""
# gemini api key only for gemini plugin
GEMINI_KEY=""
# VT api key only for VirusTotal plugin
VT_KEY=""
# rmbg api key only for removebg plugin
RMBG_KEY=""
# cohere api key only for cohere plugin
COHERE_KEY=""
# sqlite/sqlite3 or mongo/mongodb
DATABASE_TYPE=""
# file name for sqlite3, database name for mongodb
DATABASE_NAME=""
+4 -10
View File
@@ -1,5 +1,6 @@
.DS_Store
.gitattributes
.vscode
/modules/__pycache__/
__pycache__/
@@ -12,14 +13,7 @@ __pycache__/
.venv/
/downloads/
/Downloads/
/modules/custom_modules
.idea
config.ini
unknown_errors.txt
moonlogs.txt
thumb.jpg
antipm_pic.jpg
musicbot/
.trunk/
previous_profiles/
.python-version
k8s/*/*secret*.yaml
!k8s/account-secrets.yaml.example
+16 -8
View File
@@ -1,10 +1,18 @@
FROM python:3.11
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git wget ffmpeg mediainfo && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/
RUN python -m pip install --no-cache-dir --upgrade pip && \
python -m pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
RUN apt-get -qq update && apt-get -qq install -y git wget ffmpeg mediainfo\
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv --copies /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]
ENV PYTHONUNBUFFERED=1
CMD ["python", "-u", "main.py"]
+35
View File
@@ -0,0 +1,35 @@
services:
forust:
build:
context: .
dockerfile: Dockerfile
image: gcr.forust.xyz/forust/userbot:latest
pull_policy: build
restart: unless-stopped
env_file:
- .env
- .env.forust
volumes:
- ./volumes/data_forust:/app/data
- ./Downloads:/app/downloads
- ./volumes/logs_forust:/app/logs
dns:
- 8.8.8.8
- 1.1.1.1
anna:
image: gcr.forust.xyz/forust/userbot:latest
pull_policy: build
depends_on:
- forust
restart: unless-stopped
env_file:
- .env
- .env.anna
volumes:
- ./volumes/data_anna:/app/data
- ./Downloads:/app/downloads
- ./volumes/logs_anna:/app/logs
dns:
- 8.8.8.8
- 1.1.1.1
-57
View File
@@ -1,57 +0,0 @@
services:
userbot_forust:
build:
context: .
dockerfile: Dockerfile
container_name: userbot_forust
restart: unless-stopped
env_file:
- .env
- .env.forust
volumes:
- ./volumes/data_forust:/app/data
- ./Downloads:/app/downloads
- ./volumes/logs_forust-:/app/logs
dns:
- 8.8.8.8
- 1.1.1.1
userbot_anna:
build:
context: .
dockerfile: Dockerfile
container_name: userbot_anna
restart: unless-stopped
env_file:
- .env
- .env.anna
volumes:
- ./volumes/data_anna:/app/data
- ./Downloads:/app/downloads
- ./volumes/logs_anna:/app/logs
dns:
- 8.8.8.8
- 1.1.1.1
develop:
watch:
- action: sync
path: modules
target: /app/modules
- action: sync
path: utils
target: /app/utils
- action: sync
path: main.py
target: /app/main.py
- action: rebuild
path: .env
- action: rebuild
path: .env.anna
volumes:
downloads:
networks:
userbot_network:
driver: bridge
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: userbot-common-config
data:
DATABASE_TYPE: ""
DATABASE_NAME: ""
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- userbots.yaml
- common-secret.yaml
- common-config.yaml
- forust-secrets.yaml
- anna-secrets.yaml
+114
View File
@@ -0,0 +1,114 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: forust-userbot-deployment
labels:
app: forust-userbot
spec:
replicas: 1
selector:
matchLabels:
app: forust-userbot
template:
metadata:
labels:
app: forust-userbot
spec:
containers:
- name: forust-userbot
image: gcr.forust.xyz/forust/userbot:latest
imagePullPolicy: Always
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "100m"
envFrom:
- secretRef:
name: userbot-common-secrets
- configMapRef:
name: userbot-common-config
- secretRef:
name: userbot-forust-secrets
volumeMounts:
- name: forust-storage
mountPath: /app/data
subPath: data
- name: forust-storage
mountPath: /app/logs
subPath: logs
volumes:
- name: forust-storage
persistentVolumeClaim:
claimName: forust-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: forust-pvc
spec:
resources:
requests:
storage: 1Gi
accessModes:
- ReadWriteOnce
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: anna-userbot-deployment
labels:
app: anna-userbot
spec:
replicas: 1
selector:
matchLabels:
app: anna-userbot
template:
metadata:
labels:
app: anna-userbot
spec:
containers:
- name: anna-userbot
image: gcr.forust.xyz/forust/userbot:latest
imagePullPolicy: Always
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "100m"
envFrom:
- secretRef:
name: userbot-common-secrets
- configMapRef:
name: userbot-common-config
- secretRef:
name: userbot-anna-secrets
volumeMounts:
- name: anna-storage
mountPath: /app/data
subPath: data
- name: anna-storage
mountPath: /app/logs
subPath: logs
volumes:
- name: anna-storage
persistentVolumeClaim:
claimName: anna-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: anna-pvc
spec:
resources:
requests:
storage: 1Gi
accessModes:
- ReadWriteOnce
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-downloads.yaml
@@ -0,0 +1,35 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: forust-userbot-deployment
spec:
template:
spec:
containers:
- name: forust-userbot
volumeMounts:
- name: downloads
mountPath: /app/downloads
volumes:
- name: downloads
hostPath:
path: /home/user/projects/homelab/userbot/Downloads
type: DirectoryOrCreate
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: anna-userbot-deployment
spec:
template:
spec:
containers:
- name: anna-userbot
volumeMounts:
- name: downloads
mountPath: /app/downloads
volumes:
- name: downloads
hostPath:
path: /home/user/projects/homelab/userbot/Downloads
type: DirectoryOrCreate
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-downloads.yaml

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