Files
thefeed/cmd/server/main.go
T

277 lines
9.4 KiB
Go

package main
import (
"bufio"
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/term"
"github.com/sartoopjj/thefeed/internal/server"
"github.com/sartoopjj/thefeed/internal/version"
)
func main() {
dataDir := flag.String("data-dir", "./data", "Data directory for channels, session, and config")
listen := flag.String("listen", ":53", "DNS listen address (host:port)")
domain := flag.String("domain", "", "DNS domain (e.g., t.example.com)")
key := flag.String("key", "", "Encryption passphrase")
channelsFile := flag.String("channels", "", "Path to channels file (default: {data-dir}/channels.txt)")
xAccountsFile := flag.String("x-accounts", "", "Path to X accounts file (default: {data-dir}/x_accounts.txt)")
xRSSInstances := flag.String("x-rss-instances", "", "Comma-separated X RSS base URLs (e.g., https://nitter.net,http://nitter.net)")
apiID := flag.String("api-id", "", "Telegram API ID (optional if --no-telegram)")
apiHash := flag.String("api-hash", "", "Telegram API Hash (optional if --no-telegram)")
phone := flag.String("phone", "", "Telegram phone number (optional if --no-telegram)")
loginOnly := flag.Bool("login-only", false, "Authenticate to Telegram, save session, and exit")
noTelegram := flag.Bool("no-telegram", false, "Fetch public channels without Telegram login")
sessionPath := flag.String("session", "", "Path to Telegram session file (default: {data-dir}/session.json)")
maxPadding := flag.Int("padding", 32, "Max random padding bytes in DNS responses (anti-DPI, 0=disabled)")
msgLimit := flag.Int("msg-limit", 15, "Maximum messages to fetch per Telegram channel")
fetchIntervalMin := flag.Int("fetch-interval", 10, "Fetch cycle interval in minutes (min 3, default 10)")
allowManage := flag.Bool("allow-manage", false, "Allow remote channel management and sending via DNS")
debug := flag.Bool("debug", false, "Log every decoded DNS query")
dnsMediaEnabled := flag.Bool("dns-media-enabled", false, "Serve media via DNS (slow relay)")
dnsMediaMaxSizeKB := flag.Int("dns-media-max-size", 100, "Per-file cap for the DNS relay in KB (0 = no cap)")
dnsMediaCacheTTLMin := flag.Int("dns-media-cache-ttl", 600, "TTL for DNS-relay cached media, in minutes")
dnsMediaCompression := flag.String("dns-media-compression", "gzip", "Compression for DNS-relay media bytes: none|gzip|deflate")
ghEnabled := flag.Bool("github-relay-enabled", false, "Serve media via GitHub (fast relay)")
ghToken := flag.String("github-relay-token", "", "GitHub PAT with contents:write on the relay repo")
ghRepo := flag.String("github-relay-repo", "", "GitHub repo for the fast relay, e.g. owner/repo")
ghBranch := flag.String("github-relay-branch", "main", "Default branch to commit to (e.g. main, master)")
ghMaxSizeKB := flag.Int("github-relay-max-size", 15*1024, "Per-file cap for the GitHub relay in KB (0 = no cap)")
ghCacheTTLMin := flag.Int("github-relay-ttl", 600, "TTL for GitHub-relay objects in minutes")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "thefeed-server %s\n\nServes Telegram/X feed content over encrypted DNS for censorship-resistant access.\n\nUsage:\n thefeed-server [flags]\n\nFlags:\n", version.Version)
flag.PrintDefaults()
}
flag.Parse()
if *showVersion {
fmt.Printf("thefeed-server %s (commit: %s, built: %s)\n", version.Version, version.Commit, version.Date)
os.Exit(0)
}
// Catch the common --bool true mistake: Go's flag package stops parsing at
// the first positional, so any flags after it are silently dropped. Bool
// flags must use --foo or --foo=true (no space).
if flag.NArg() > 0 {
fmt.Fprintf(os.Stderr, "Error: unexpected positional argument(s): %v\n", flag.Args())
fmt.Fprintln(os.Stderr, "Hint: bool flags must be written as --flag or --flag=true (NOT --flag true).")
os.Exit(1)
}
// Create data directory
if err := os.MkdirAll(*dataDir, 0700); err != nil {
log.Fatalf("Create data dir: %v", err)
}
// Default paths relative to data directory
if *channelsFile == "" {
*channelsFile = filepath.Join(*dataDir, "channels.txt")
}
if *xAccountsFile == "" {
*xAccountsFile = filepath.Join(*dataDir, "x_accounts.txt")
}
if *sessionPath == "" {
*sessionPath = filepath.Join(*dataDir, "session.json")
}
if *domain == "" {
*domain = os.Getenv("THEFEED_DOMAIN")
}
if *key == "" {
*key = os.Getenv("THEFEED_KEY")
}
if !*allowManage && os.Getenv("THEFEED_ALLOW_MANAGE") == "1" {
*allowManage = true
}
// THEFEED_ALLOW_MANAGE=0 explicitly disables, even if flag was set
if os.Getenv("THEFEED_ALLOW_MANAGE") == "0" {
*allowManage = false
}
if *fetchIntervalMin == 10 {
if v := os.Getenv("THEFEED_FETCH_INTERVAL"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
*fetchIntervalMin = n
}
}
}
if *fetchIntervalMin < 3 {
fmt.Fprintf(os.Stderr, "Error: --fetch-interval must be at least 3 minutes (got %d)\n", *fetchIntervalMin)
os.Exit(1)
}
if *msgLimit == 15 {
if v := os.Getenv("THEFEED_MSG_LIMIT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
*msgLimit = n
}
}
}
if *apiID == "" {
*apiID = os.Getenv("TELEGRAM_API_ID")
}
if *apiHash == "" {
*apiHash = os.Getenv("TELEGRAM_API_HASH")
}
if *phone == "" {
*phone = os.Getenv("TELEGRAM_PHONE")
}
if *xRSSInstances == "" {
*xRSSInstances = os.Getenv("THEFEED_X_RSS_INSTANCES")
}
if *domain == "" || *key == "" {
fmt.Fprintln(os.Stderr, "Error: --domain and --key are required")
flag.Usage()
os.Exit(1)
}
// Telegram credentials are required unless --no-telegram
needTelegram := !*noTelegram
if needTelegram {
if *apiID == "" || *apiHash == "" || *phone == "" {
fmt.Fprintln(os.Stderr, "Error: --api-id, --api-hash, and --phone are required (use --no-telegram to skip)")
flag.Usage()
os.Exit(1)
}
}
var id int
if *apiID != "" {
var err error
id, err = strconv.Atoi(*apiID)
if err != nil {
log.Fatalf("Invalid API ID: %v", err)
}
}
// Interactive 2FA password prompt — only when Telegram is enabled
password := os.Getenv("TELEGRAM_PASSWORD")
if password == "" && needTelegram {
hasSession := false
if info, statErr := os.Stat(*sessionPath); statErr == nil && info.Size() > 0 {
hasSession = true
}
if *loginOnly || !hasSession {
fmt.Print("Telegram 2FA password (press Enter if none): ")
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err == nil && len(pwBytes) > 0 {
password = string(pwBytes)
}
}
}
if env := os.Getenv("THEFEED_DNS_MEDIA_ENABLED"); env == "0" {
*dnsMediaEnabled = false
} else if env == "1" {
*dnsMediaEnabled = true
}
if env := os.Getenv("THEFEED_DNS_MEDIA_MAX_SIZE_KB"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*dnsMediaMaxSizeKB = n
}
}
if env := os.Getenv("THEFEED_DNS_MEDIA_CACHE_TTL_MIN"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*dnsMediaCacheTTLMin = n
}
}
if env := os.Getenv("THEFEED_DNS_MEDIA_COMPRESSION"); env != "" {
*dnsMediaCompression = env
}
if !*ghEnabled && os.Getenv("THEFEED_GITHUB_RELAY_ENABLED") == "1" {
*ghEnabled = true
}
if *ghToken == "" {
*ghToken = os.Getenv("THEFEED_GITHUB_RELAY_TOKEN")
}
if *ghRepo == "" {
*ghRepo = os.Getenv("THEFEED_GITHUB_RELAY_REPO")
}
if *ghBranch == "main" {
if v := os.Getenv("THEFEED_GITHUB_RELAY_BRANCH"); v != "" {
*ghBranch = v
}
}
if env := os.Getenv("THEFEED_GITHUB_RELAY_MAX_SIZE_KB"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*ghMaxSizeKB = n
}
}
if env := os.Getenv("THEFEED_GITHUB_RELAY_TTL_MIN"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*ghCacheTTLMin = n
}
}
cfg := server.Config{
ListenAddr: *listen,
Domain: *domain,
Passphrase: *key,
ChannelsFile: *channelsFile,
XAccountsFile: *xAccountsFile,
XRSSInstances: *xRSSInstances,
MaxPadding: *maxPadding,
MsgLimit: *msgLimit,
NoTelegram: *noTelegram,
AllowManage: *allowManage,
Debug: *debug,
DNSMediaEnabled: *dnsMediaEnabled,
DNSMediaMaxSize: int64(*dnsMediaMaxSizeKB) * 1024,
DNSMediaCacheTTL: *dnsMediaCacheTTLMin,
DNSMediaCompression: *dnsMediaCompression,
FetchInterval: time.Duration(*fetchIntervalMin) * time.Minute,
GitHubRelay: server.GitHubRelayConfig{
Enabled: *ghEnabled,
Token: *ghToken,
Repo: *ghRepo,
Branch: *ghBranch,
StatePath: filepath.Join(*dataDir, "gh_relay_state.json"),
MaxBytes: int64(*ghMaxSizeKB) * 1024,
TTLMinutes: *ghCacheTTLMin,
},
Telegram: server.TelegramConfig{
APIID: id,
APIHash: *apiHash,
Phone: *phone,
Password: password,
SessionPath: *sessionPath,
LoginOnly: *loginOnly,
CodePrompt: func(ctx context.Context) (string, error) {
fmt.Print("Enter Telegram auth code: ")
reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(code), nil
},
},
}
srv, err := server.New(cfg)
if err != nil {
log.Fatalf("Create server: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
log.Printf("Starting thefeed server %s on %s for domain %s", version.Version, cfg.ListenAddr, cfg.Domain)
if err := srv.Run(ctx); err != nil && ctx.Err() == nil {
log.Fatalf("Server error: %v", err)
}
log.Println("Server stopped")
}