Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ pip-delete-this-directory.txt

# vscode
.vscode/*

# Go binaries
server-admin-bot
server-admin-bot-*
*.exe
*.tar.gz
*.zip
1 change: 0 additions & 1 deletion internal/bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package bot
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
Expand Down
253 changes: 253 additions & 0 deletions internal/bot/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bot

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -309,6 +310,258 @@ func (b *Bot) handleExecCommand(chatID int64, session *UserSession, text string)
b.api.Send(msg)
}

// handleFileUpload handles file uploads
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function comment states "Acknowledges file uploads, returns download URL" but the implementation doesn't return anything - it's a void function. Additionally, it doesn't acknowledge in the sense of storing/saving the file, only logging the URL. The comment should be updated to accurately reflect what the function actually does, for example: "Handles file upload notifications from Telegram and logs the file URL".

Suggested change
// handleFileUpload handles file uploads
// handleFileUpload handles file upload notifications and logs the file URL

Copilot uses AI. Check for mistakes.
func (b *Bot) handleFileUpload(chatID int64, document *tgbotapi.Document) {
b.logger.Infof("📤 File upload request: %s (%s)", document.FileName, document.FileID)

// Get file
fileURL, err := b.api.GetFileDirectURL(document.FileID)
if err != nil {
b.logger.Errorf("❌ Failed to get file URL: %v", err)
b.sendMessage(chatID, "❌ Failed to download file")
return
}

b.logger.Infof("✅ File uploaded: %s, URL: %s", document.FileName, fileURL)
b.sendMessage(chatID, fmt.Sprintf("✅ File received: **%s** (%d bytes)\n\nUse /ls to see uploaded files",
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success message tells users to "Use /ls to see uploaded files", but the file is not actually saved to the server's filesystem - only the Telegram file URL is retrieved and logged. The /ls command lists local filesystem directories, so uploaded files will not appear there. Either implement actual file saving to make the message accurate, or update the message to reflect that the file remains on Telegram's servers (e.g., "File received and available for download from Telegram").

Suggested change
b.sendMessage(chatID, fmt.Sprintf("✅ File received: **%s** (%d bytes)\n\nUse /ls to see uploaded files",
b.sendMessage(chatID, fmt.Sprintf("✅ File received from Telegram: **%s** (%d bytes)\n\nThe file remains stored on Telegram and is not saved on this server.",

Copilot uses AI. Check for mistakes.
document.FileName, document.FileSize))
}

// handleCatFile handles the /cat command to display file contents
func (b *Bot) handleCatFile(chatID int64, text string) {
parts := strings.Fields(text)
if len(parts) < 2 {
b.sendMessage(chatID, "❌ Usage: /cat <file>")
return
}

filePath := parts[1]
b.logger.Infof("📄 Cat file request: %s", filePath)

// Validate file path for security
if !b.auth.ValidateFilePath(filePath) {
b.logger.Warnf("🚫 Blocked cat access to path: %s", filePath)
b.sendMessage(chatID, "❌ Access to this file is not allowed for security reasons")
return
}

// Read file contents
content, err := os.ReadFile(filePath)
if err != nil {
b.logger.Errorf("❌ Failed to read file %s: %v", filePath, err)
b.sendMessage(chatID, fmt.Sprintf("❌ Error reading file: %v", err))
return
}

// Limit content size
maxSize := 4000
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The maxSize constant is defined locally within the function. Since this is a Telegram message length limit (which is consistently 4096 characters throughout the codebase), consider defining it as a package-level constant to avoid magic numbers and enable reuse. For example, define "const maxTelegramMessageLength = 4096" at the package level.

Copilot uses AI. Check for mistakes.
contentStr := string(content)
if len(contentStr) > maxSize {
contentStr = contentStr[:maxSize] + "\n...(truncated)"
}
Comment on lines +349 to +361
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading arbitrary files with os.ReadFile and converting to string may cause issues with binary files, as binary content cannot be properly represented as UTF-8 text. This could result in corrupted output or panic. Consider checking if the file is a text file first (e.g., using file extension or content sniffing), or handle binary files separately by sending them as documents instead of displaying their content.

Copilot uses AI. Check for mistakes.

response := fmt.Sprintf("📄 **File: %s**\n\n```\n%s\n```", filePath, contentStr)
msg := tgbotapi.NewMessage(chatID, response)
msg.ParseMode = "Markdown"
b.api.Send(msg)
}

// handleDownload handles the /download command to send files to user
func (b *Bot) handleDownload(chatID int64, text string) {
parts := strings.Fields(text)
if len(parts) < 2 {
b.sendMessage(chatID, "❌ Usage: /download <file>")
return
}

filePath := parts[1]
b.logger.Infof("📥 Download request: %s", filePath)

// Validate file path for security
if !b.auth.ValidateFilePath(filePath) {
b.logger.Warnf("🚫 Blocked download access to path: %s", filePath)
b.sendMessage(chatID, "❌ Access to this file is not allowed for security reasons")
return
}

// Check if file exists
fileInfo, err := os.Stat(filePath)
if err != nil {
b.logger.Errorf("❌ File not found: %s - %v", filePath, err)
b.sendMessage(chatID, fmt.Sprintf("❌ File not found: %v", err))
return
}

if fileInfo.IsDir() {
b.sendMessage(chatID, "❌ Cannot download directories. Please specify a file.")
return
}

// Send the file
caption := fmt.Sprintf("📥 %s (%s)", filepath.Base(filePath), formatBytes(uint64(fileInfo.Size())))
if err := b.sendDocument(chatID, filePath, caption); err != nil {
b.logger.Errorf("❌ Failed to send file: %v", err)
b.sendMessage(chatID, fmt.Sprintf("❌ Failed to send file: %v", err))
return
}

b.logger.Infof("✅ File sent: %s", filePath)
}

// handleKillProcess handles the /kill command to terminate processes
func (b *Bot) handleKillProcess(chatID int64, text string) {
parts := strings.Fields(text)
if len(parts) < 2 {
b.sendMessage(chatID, "❌ Usage: /kill <pid>")
return
}

pidStr := parts[1]
pid, err := strconv.Atoi(pidStr)
if err != nil {
b.sendMessage(chatID, "❌ Invalid PID. Must be a number.")
return
}

// Basic safety checks - don't allow killing critical PIDs
if pid <= 0 {
b.sendMessage(chatID, "❌ Invalid PID. Must be a positive number.")
return
}

// Don't allow killing init/systemd (PID 1) or kernel processes (PID < 300 typically)
if pid < 300 {
b.logger.Warnf("🚫 Attempted to kill critical system process: PID %d", pid)
b.sendMessage(chatID, "❌ Cannot kill critical system processes (PID < 300) for safety reasons.")
return
}
Comment on lines +432 to +437
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PID threshold of 300 to prevent killing critical system processes is arbitrary and platform-dependent. On some systems, user processes can have PIDs below 300, and on others, system processes may have higher PIDs. A more robust approach would be to check the process owner (prevent killing root-owned processes) or maintain a list of protected process names (init, systemd, sshd, etc.).

Copilot uses AI. Check for mistakes.

b.logger.Warnf("⚠️ Kill process request: PID %d", pid)

// Find and kill the process
proc, err := os.FindProcess(pid)
if err != nil {
b.logger.Errorf("❌ Process not found: PID %d - %v", pid, err)
b.sendMessage(chatID, fmt.Sprintf("❌ Process not found: %v", err))
return
}
Comment on lines +442 to +447
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Unix-like systems, os.FindProcess always succeeds regardless of whether the process exists. The actual error only occurs when trying to send a signal. This means the function will not properly detect non-existent processes before attempting to kill them. Consider using proc.Signal(syscall.Signal(0)) to test if the process exists first, or handle the kill error more specifically to distinguish between "process not found" and other errors.

Copilot uses AI. Check for mistakes.

if err := proc.Kill(); err != nil {
b.logger.Errorf("❌ Failed to kill process %d: %v", pid, err)
b.sendMessage(chatID, fmt.Sprintf("❌ Failed to kill process: %v", err))
return
}

b.logger.Infof("✅ Process killed: PID %d", pid)
b.sendMessage(chatID, fmt.Sprintf("✅ Process killed: PID %d", pid))
}

// handleLogs handles the /logs command to show system logs
func (b *Bot) handleLogs(chatID int64, text string) {
parts := strings.Fields(text)
lines := 50 // Default number of lines

if len(parts) > 1 {
if n, err := strconv.Atoi(parts[1]); err == nil && n > 0 {
lines = n
if lines > 200 {
lines = 200 // Limit to 200 lines
}
}
}

b.logger.Infof("📝 Logs request: %d lines", lines)

// Try to read system logs
cmd := exec.Command("journalctl", "-n", strconv.Itoa(lines), "--no-pager")
output, err := cmd.Output()

// Fallback to other log sources if journalctl is not available
if err != nil {
// Try reading from /var/log/syslog
cmd = exec.Command("tail", "-n", strconv.Itoa(lines), "/var/log/syslog")
output, err = cmd.Output()

if err != nil {
b.logger.Errorf("❌ Failed to read logs: %v", err)
b.sendMessage(chatID, "❌ Failed to read system logs. Journalctl or syslog not accessible.")
return
}
}

logsText := string(output)
if len(logsText) > 4000 {
logsText = logsText[len(logsText)-4000:]
}

response := fmt.Sprintf("📝 **System Logs (last %d lines):**\n\n```\n%s\n```", lines, logsText)
Comment on lines +492 to +497
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name "logsText" mixes plural "logs" with singular "Text". For consistency with Go naming conventions and the codebase style, consider using either "logText" (singular) or "logsOutput" to be more descriptive of what it contains.

Suggested change
logsText := string(output)
if len(logsText) > 4000 {
logsText = logsText[len(logsText)-4000:]
}
response := fmt.Sprintf("📝 **System Logs (last %d lines):**\n\n```\n%s\n```", lines, logsText)
logText := string(output)
if len(logText) > 4000 {
logText = logText[len(logText)-4000:]
}
response := fmt.Sprintf("📝 **System Logs (last %d lines):**\n\n```\n%s\n```", lines, logText)

Copilot uses AI. Check for mistakes.
msg := tgbotapi.NewMessage(chatID, response)
msg.ParseMode = "Markdown"
b.api.Send(msg)
Comment on lines +493 to +500
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When logs exceed 4000 characters, the truncation takes characters from the end of the output, which means the most recent log entries are shown but earlier context is lost. This makes the log message potentially confusing since it says "last N lines" but then shows a truncated middle portion. Consider either truncating from the beginning to show the most recent entries, or better yet, split into multiple messages to show all requested lines.

Suggested change
if len(logsText) > 4000 {
logsText = logsText[len(logsText)-4000:]
}
response := fmt.Sprintf("📝 **System Logs (last %d lines):**\n\n```\n%s\n```", lines, logsText)
msg := tgbotapi.NewMessage(chatID, response)
msg.ParseMode = "Markdown"
b.api.Send(msg)
// Telegram has a message length limit (~4096 chars), so we send logs in chunks.
// Use a conservative chunk size to account for Markdown formatting and header text.
const maxChunkSize = 3500
for start := 0; start < len(logsText); start += maxChunkSize {
end := start + maxChunkSize
if end > len(logsText) {
end = len(logsText)
}
chunk := logsText[start:end]
var response string
if start == 0 {
// First chunk includes the header.
response = fmt.Sprintf("📝 **System Logs (last %d lines):**\n\n```\n%s\n```", lines, chunk)
} else {
// Subsequent chunks are continuations.
response = fmt.Sprintf("```\n%s\n```", chunk)
}
msg := tgbotapi.NewMessage(chatID, response)
msg.ParseMode = "Markdown"
b.api.Send(msg)
}

Copilot uses AI. Check for mistakes.
}

// handleAdmin handles the /admin command with subcommands
func (b *Bot) handleAdmin(chatID int64, session *UserSession, text string) {
parts := strings.Fields(text)

// If no subcommand, show admin menu
if len(parts) < 2 {
b.handleAdminMenu(chatID, session)
return
}

subcommand := parts[1]
b.logger.Infof("⚙️ Admin command: %s by @%s", subcommand, session.Username)

switch subcommand {
case "reboot":
b.sendMessage(chatID, "⚠️ Reboot command disabled for safety. Please use your system's management console.")
case "shutdown":
b.sendMessage(chatID, "⚠️ Shutdown command disabled for safety. Please use your system's management console.")
case "update":
b.sendMessage(chatID, "🔄 System update initiated... (This is a placeholder - implement actual update logic)")
default:
b.sendMessage(chatID, "❓ Unknown admin command. Available: reboot, shutdown, update")
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message for unknown admin subcommands reveals all available admin commands to any authorized user. While this may be intentional for usability, it could be considered information disclosure. Consider whether listing available admin operations in error responses aligns with your security model, especially since some of these operations (reboot, shutdown) are currently disabled but their existence is advertised.

Suggested change
b.sendMessage(chatID, "❓ Unknown admin command. Available: reboot, shutdown, update")
b.sendMessage(chatID, "❓ Unknown admin command. Use /admin to view available admin options.")

Copilot uses AI. Check for mistakes.
}
}

// handleAdminMenu shows the admin menu
func (b *Bot) handleAdminMenu(chatID int64, session *UserSession) {
b.logger.Infof("⚙️ Admin menu requested by @%s", session.Username)

adminText := `⚙️ **Admin Functions**

**Available Commands:**
• /admin reboot - Reboot system
• /admin shutdown - Shutdown system
• /admin update - Update system

**System Control:**
Use these commands with caution!

**Current Session:**
• User: @` + session.Username + `
• Session started: ` + session.StartTime.Format("2006-01-02 15:04:05")

msg := tgbotapi.NewMessage(chatID, adminText)
msg.ParseMode = "Markdown"
b.api.Send(msg)
}

// Helper function for formatting bytes (moved here to use in handlers)
func formatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
Comment on lines +552 to +563
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This formatBytes function is duplicated from internal/system/info.go (lines 379-390). The duplicate implementation creates maintainability issues as changes to one won't automatically apply to the other. Consider moving this to a shared utility package or importing it from the existing location to avoid duplication.

Copilot uses AI. Check for mistakes.

// handleCallbackQuery handles inline keyboard callbacks
func (b *Bot) handleCallbackQuery(callback *tgbotapi.CallbackQuery) {
session := b.getOrCreateSession(callback.From.ID, callback.From.UserName)
Expand Down