REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

...
9 Commits
Author SHA1 Message Date
ScuroNekoandClaude Sonnet 4.6 38309e74f6 (fix): polling loop exits on HTTP client timeout, not only on context cancel
Golang lint / lint (push) Successful in 1m40s
(fix): HTTP client timeout too close to poll timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:44:22 +03:00
ScuroNeko c2f6406819 Merge branch 'dev' of scuroneko.dev:ScuroNeko/Laniakea into dev
Golang lint / lint (pull_request) Successful in 2m59s
Golang lint / lint (push) Successful in 2m56s
2026-06-11 13:34:48 +03:00
ScuroNekoandClaude Sonnet 4.6 9e3450df31 (security): auto-generate webhook secret when unset, redact token in tgapi standalone logs (fix): webhookLogger nil panic, dead warning block (tests): update secret-path test for new auto-gen behaviour (doc): CHANGELOG v1.0.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:33:34 +03:00
ScuroNeko effd26bd9a Merge branch 'main' into dev
Golang lint / lint (pull_request) Successful in 2m42s
Golang lint / lint (push) Successful in 2m44s
2026-05-20 13:43:20 +03:00
ScuroNeko a7c8d68925 Merge pull request 'v0.5.0' (#8) from dev into main
Reviewed-on: https://git.nix13.pw/ScuroNeko/Laniakea/pulls/8
2026-02-12 11:51:01 +03:00
ScuroNeko 5f17b88787 Merge pull request 'v0.3.10' (#7) from dev into main
Reviewed-on: https://git.nix13.pw/ScuroNeko/Laniakea/pulls/7
2026-02-04 17:33:51 +03:00
ScuroNeko 6d6f5738cd Merge pull request 'v0.3.2' (#3) from dev into main
Reviewed-on: https://git.nix13.pw/ScuroNeko/Laniakea/pulls/3
2026-01-29 11:47:29 +03:00
ScuroNeko fef718438a v0.3.0 2026-01-29 09:51:50 +03:00
ScuroNeko 7f248fff62 fix 2025-11-05 11:38:09 +03:00
8 changed files with 69 additions and 34 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## v1.0.2
### Fixed
- Fixed long-polling stopping permanently when the HTTP client's internal timeout fired. The polling loop was checking `errors.Is(err, context.DeadlineExceeded)`, which matched HTTP client timeout errors (`*url.Error` wraps `context.DeadlineExceeded`), causing the goroutine to exit as if the bot context was canceled. The check is now `ctx.Err() != nil` so only a real context cancellation stops polling.
- Fixed the HTTP client timeout (45 s) being too close to the long-poll `getUpdates` timeout (30 s default), leaving insufficient margin for connection setup and response transfer. The client timeout is now derived from the configured `PollTimeout` plus a 60-second buffer.
## v1.0.1
### Fixed
- Fixed webhook always accepting unauthenticated requests when `SecretToken` is not configured. A cryptographically random 32-byte token is now generated automatically when `SecretToken` is empty, so the webhook endpoint is always authenticated. The generated token is logged as a warning so the operator can record it.
- Fixed `tgapi.NewAPI` and `tgapi.NewUploader` not installing token redaction on their managed loggers. The bot token is now masked as `<TOKEN>` in debug output even when the `tgapi` package is used standalone without the `laniakea.Bot` wrapper.
## v1.0.0 ## v1.0.0
### Breaking Changes ### Breaking Changes
+21 -16
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"sync" "sync"
"time" "time"
@@ -166,21 +167,6 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
limiter := utils.NewRateLimiter() limiter := utils.NewRateLimiter()
limiter.SetGlobalRate(opts.RateLimit) limiter.SetGlobalRate(opts.RateLimit)
apiOpts := tgapi.NewAPIOpts(opts.Token).
SetAPIURL(opts.APIURL).
UseTestServer(opts.UseTestServer).
SetLimiter(limiter).
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
SetLogFormat(opts.LogFormat).
SetLogFormatter(opts.LogFormatter)
api := tgapi.NewAPI(apiOpts)
uploader := tgapi.NewUploader(api)
prefixes := opts.Prefixes
if len(prefixes) == 0 {
prefixes = []string{"/"}
}
workers := 32 workers := 32
if opts.MaxWorkers > 0 { if opts.MaxWorkers > 0 {
workers = opts.MaxWorkers workers = opts.MaxWorkers
@@ -191,6 +177,25 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
pollTimeout = opts.PollTimeout pollTimeout = opts.PollTimeout
} }
// HTTP client timeout must exceed pollTimeout to avoid spurious deadline
// errors that the polling loop would misinterpret as context cancellation.
httpTimeout := time.Duration(pollTimeout)*time.Second + 60*time.Second
apiOpts := tgapi.NewAPIOpts(opts.Token).
SetAPIURL(opts.APIURL).
UseTestServer(opts.UseTestServer).
SetLimiter(limiter).
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
SetLogFormat(opts.LogFormat).
SetLogFormatter(opts.LogFormatter).
SetHTTPClient(&http.Client{Timeout: httpTimeout})
api := tgapi.NewAPI(apiOpts)
uploader := tgapi.NewUploader(api)
prefixes := opts.Prefixes
if len(prefixes) == 0 {
prefixes = []string{"/"}
}
bot := &Bot[T]{ bot := &Bot[T]{
updateOffset: 0, updateOffset: 0,
errorTemplate: "%s", errorTemplate: "%s",
@@ -472,7 +477,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
default: default:
updates, err := bot.Updates(ctx) updates, err := bot.Updates(ctx)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if ctx.Err() != nil {
return return
} }
retryDelay, ok := pollRetryAfterDelay(err) retryDelay, ok := pollRetryAfterDelay(err)
+11 -6
View File
@@ -137,6 +137,15 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
if len(bot.plugins) == 0 { if len(bot.plugins) == 0 {
return ErrNoPlugins return ErrNoPlugins
} }
autoSecret := ""
if opts.SecretToken == "" {
rndSecret, err := generateToken(32)
if err != nil {
return err
}
opts.SecretToken = rndSecret
autoSecret = rndSecret
}
if opts.URL == "" { if opts.URL == "" {
return ErrNoBotWebhookOptsURL return ErrNoBotWebhookOptsURL
} }
@@ -146,9 +155,6 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
return err return err
} }
if opts.UseStatusPath && opts.SecretToken == "" {
return ErrStatusPathSecretRequired
}
if err := validateWebhookTLSFiles(tlsFiles); err != nil { if err := validateWebhookTLSFiles(tlsFiles); err != nil {
return err return err
} }
@@ -158,10 +164,9 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
} }
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
if opts.SecretToken == "" { if autoSecret != "" {
bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.") bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Using random 32 bytes token:", autoSecret)
} }
i, err := bot.api.GetWebhookInfoWithContext(runCtx) i, err := bot.api.GetWebhookInfoWithContext(runCtx)
if err != nil { if err != nil {
return err return err
+8 -8
View File
@@ -350,20 +350,20 @@ func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing
} }
} }
func TestRunWebhookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { func TestRunWebhookWithContextAutoGeneratesSecretWhenEmpty(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
} }
opts := NewBotWebhookOpts(). // No SecretToken, no URL — function should auto-generate the token
SetURL("https://bot.example.com"). // and then fail with ErrNoBotWebhookOptsURL before any network call.
SetUseStatusPath(true) opts := NewBotWebhookOpts().SetUseStatusPath(true)
err := bot.RunWebhookWithContext(context.Background(), opts) err := bot.RunWebhookWithContext(context.Background(), opts)
if err == nil { if !errors.Is(err, ErrNoBotWebhookOptsURL) {
t.Fatal("expected status-path secret validation error, got nil") t.Fatalf("expected ErrNoBotWebhookOptsURL after auto-generation, got: %v", err)
} }
if !strings.Contains(err.Error(), "SecretToken required") { if opts.SecretToken == "" {
t.Fatalf("unexpected error: %v", err) t.Fatal("expected SecretToken to be auto-generated, got empty string")
} }
} }
+1
View File
@@ -122,6 +122,7 @@ func NewAPI(opts *APIOpts) *API {
"API", utils.GetLoggerLevel(), "API", utils.GetLoggerLevel(),
opts.logFormat, opts.logFormatter, opts.logFormat, opts.logFormatter,
) )
logger.AddReplacer(opts.token, "<TOKEN>")
client := opts.client client := opts.client
if client == nil { if client == nil {
+3 -2
View File
@@ -79,6 +79,7 @@ func NewUploader(api *API) *Uploader {
"UPLOADER", utils.GetLoggerLevel(), "UPLOADER", utils.GetLoggerLevel(),
api.logFormat, api.logFormatter, api.logFormat, api.logFormatter,
) )
logger.AddReplacer(api.token, "<TOKEN>")
return &Uploader{api, logger} return &Uploader{api, logger}
} }
@@ -143,7 +144,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
req.ContentLength = int64(buf.Len()) req.ContentLength = int64(buf.Len())
up.logger.Debugln("UPLOADER REQ", r.method) up.logger.Debugln("UPLOADER REQ", url)
resp, err := up.api.client.Do(req) resp, err := up.api.client.Do(req)
if err != nil { if err != nil {
return zero, err return zero, err
@@ -154,7 +155,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
if err != nil { if err != nil {
return zero, err return zero, err
} }
up.logger.Debugln("UPLOADER RES", r.method, string(body)) up.logger.Debugln("UPLOADER RES", url, string(body))
response, err := parseBody[R](body) response, err := parseBody[R](body)
if err != nil { if err != nil {
+11
View File
@@ -1,6 +1,9 @@
package laniakea package laniakea
import ( import (
"crypto/rand"
"encoding/base64"
"git.scuroneko.dev/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
@@ -27,3 +30,11 @@ const (
// VersionBeta re-exports the module prerelease counter. // VersionBeta re-exports the module prerelease counter.
VersionBeta = utils.VersionBeta VersionBeta = utils.VersionBeta
) )
func generateToken(b int) (string, error) {
bytes := make([]byte, b)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
+2 -2
View File
@@ -2,13 +2,13 @@ package utils
const ( const (
// VersionString is the module version string. // VersionString is the module version string.
VersionString = "1.0.0" VersionString = "1.0.2"
// VersionMajor is the module major version. // VersionMajor is the module major version.
VersionMajor = 1 VersionMajor = 1
// VersionMinor is the module minor version. // VersionMinor is the module minor version.
VersionMinor = 0 VersionMinor = 0
// VersionPatch is the module patch version. // VersionPatch is the module patch version.
VersionPatch = 0 VersionPatch = 2
// VersionBeta is the prerelease counter for the current version. // VersionBeta is the prerelease counter for the current version.
VersionBeta = 0 VersionBeta = 0
) )