refactor
This commit is contained in:
124
ansible/tasks/global/utils/dynamic-dns-cf-setup.md
Normal file
124
ansible/tasks/global/utils/dynamic-dns-cf-setup.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Dynamic DNS OnePassword Setup
|
||||
|
||||
This document explains how to set up the required OnePassword entries for the Dynamic DNS automation.
|
||||
|
||||
## Overview
|
||||
|
||||
The Dynamic DNS task automatically retrieves credentials from OnePassword using the Ansible OnePassword lookup plugin. This eliminates the need for vault files and provides better security.
|
||||
|
||||
## Required OnePassword Entries
|
||||
|
||||
### 1. CloudFlare API Token
|
||||
|
||||
**Location:** `CloudFlare API Token` in `Dotfiles` vault, field `password`
|
||||
|
||||
**Setup Steps:**
|
||||
|
||||
1. Go to [CloudFlare API Tokens](https://dash.cloudflare.com/profile/api-tokens)
|
||||
2. Click "Create Token"
|
||||
3. Use the "Edit zone DNS" template
|
||||
4. Configure permissions:
|
||||
- Zone: DNS: Edit
|
||||
- Zone Resources: Include all zones (or specific zones for your domains)
|
||||
5. Add IP address filtering if desired (optional but recommended)
|
||||
6. Click "Continue to summary" and "Create Token"
|
||||
7. Copy the token and save it in OnePassword:
|
||||
- Title: `CloudFlare API Token`
|
||||
- Vault: `Dotfiles`
|
||||
- Field: `password` (this should be the main password field)
|
||||
|
||||
### 2. Telegram Bot Credentials
|
||||
|
||||
**Location:** `Telegram DynDNS Bot` in `Dotfiles` vault, fields `password` and `chat_id`
|
||||
|
||||
**Setup Steps:**
|
||||
|
||||
#### Create Telegram Bot:
|
||||
|
||||
1. Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||
2. Send `/start` then `/newbot`
|
||||
3. Follow the prompts to create your bot
|
||||
4. Save the bot token (format: `123456789:ABCdefGHijklMNopQRstUVwxyz`)
|
||||
|
||||
#### Get Chat ID:
|
||||
|
||||
1. Send any message to your new bot
|
||||
2. Visit: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
|
||||
3. Look for `"chat":{"id":YOUR_CHAT_ID}` in the response
|
||||
4. Save the chat ID (format: `987654321` or `-987654321` for groups)
|
||||
|
||||
#### Save in OnePassword:
|
||||
|
||||
- Title: `Telegram DynDNS Bot`
|
||||
- Vault: `Dotfiles`
|
||||
- Fields:
|
||||
- `password`: Your bot token (123456789:ABCdefGHijklMNopQRstUVwxyz)
|
||||
- `chat_id`: Your chat ID (987654321)
|
||||
|
||||
## Verification
|
||||
|
||||
You can test that the OnePassword lookups work by running:
|
||||
|
||||
```bash
|
||||
# Test CloudFlare token lookup
|
||||
ansible localhost -m debug -a "msg={{ lookup('community.general.onepassword', 'CloudFlare API Token', vault='Dotfiles', field='password') }}"
|
||||
|
||||
# Test Telegram bot token
|
||||
ansible localhost -m debug -a "msg={{ lookup('community.general.onepassword', 'Telegram DynDNS Bot', vault='Dotfiles', field='password') }}"
|
||||
|
||||
# Test Telegram chat ID
|
||||
ansible localhost -m debug -a "msg={{ lookup('community.general.onepassword', 'Telegram DynDNS Bot', vault='Dotfiles', field='chat_id') }}"
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Credentials are never stored in version control
|
||||
- Environment file (`~/.local/bin/dynamic-dns.env`) has 600 permissions
|
||||
- OnePassword CLI must be authenticated before running Ansible
|
||||
- Make sure to run `op signin` before executing the playbook
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### OnePassword CLI Not Authenticated
|
||||
|
||||
```bash
|
||||
op signin
|
||||
```
|
||||
|
||||
### Missing Fields in OnePassword
|
||||
|
||||
Ensure the exact field names match:
|
||||
|
||||
- CloudFlare: field must be named `password`
|
||||
- Telegram: fields must be named `password` and `chat_id`
|
||||
|
||||
### Invalid CloudFlare Token
|
||||
|
||||
- Check token has `Zone:DNS:Edit` permissions
|
||||
- Verify token is active in CloudFlare dashboard
|
||||
- Test with: `curl -H "Authorization: Bearer YOUR_TOKEN" https://api.cloudflare.com/client/v4/user/tokens/verify`
|
||||
|
||||
### Telegram Not Working
|
||||
|
||||
- Ensure you've sent at least one message to your bot
|
||||
- Verify chat ID format (numbers only, may start with -)
|
||||
- Test with: `go run dynamic-dns-cf.go --test-telegram`
|
||||
|
||||
## Usage
|
||||
|
||||
Once set up, the dynamic DNS will automatically:
|
||||
|
||||
- Update DNS records every 15 minutes
|
||||
- Send Telegram notifications when IP changes
|
||||
- Log all activity to system journal (`journalctl -t dynamic-dns`)
|
||||
|
||||
## Domains Configured
|
||||
|
||||
The automation updates these domains:
|
||||
|
||||
- `vleeuwen.me`
|
||||
- `mvl.sh`
|
||||
- `mennovanleeuwen.nl`
|
||||
|
||||
To modify the domain list, edit the wrapper script at:
|
||||
`~/.local/bin/dynamic-dns-update.sh`
|
||||
903
ansible/tasks/global/utils/dynamic-dns-cf.go
Normal file
903
ansible/tasks/global/utils/dynamic-dns-cf.go
Normal file
@@ -0,0 +1,903 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CloudFlare API structures
|
||||
type CloudFlareResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []CloudFlareError `json:"errors"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Messages []CloudFlareMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type CloudFlareError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type CloudFlareMessage struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type DNSRecord struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
}
|
||||
|
||||
type Zone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type TokenVerification struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type NotificationInfo struct {
|
||||
RecordName string
|
||||
OldIP string
|
||||
NewIP string
|
||||
IsNew bool
|
||||
}
|
||||
|
||||
// Configuration
|
||||
type Config struct {
|
||||
APIToken string
|
||||
RecordNames []string
|
||||
IPSources []string
|
||||
DryRun bool
|
||||
Verbose bool
|
||||
Force bool
|
||||
TTL int
|
||||
TelegramBotToken string
|
||||
TelegramChatID string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// Default IP sources
|
||||
var defaultIPSources = []string{
|
||||
"https://ifconfig.co/ip",
|
||||
"https://ip.seeip.org",
|
||||
"https://ipv4.icanhazip.com",
|
||||
"https://api.ipify.org",
|
||||
}
|
||||
|
||||
func main() {
|
||||
config := &Config{
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
// Command line flags
|
||||
var ipSourcesFlag string
|
||||
var recordsFlag string
|
||||
var listZones bool
|
||||
var testTelegram bool
|
||||
flag.StringVar(&recordsFlag, "record", "", "DNS A record name(s) to update - comma-separated for multiple (required)")
|
||||
flag.StringVar(&ipSourcesFlag, "ip-sources", "", "Comma-separated list of IP detection services (optional)")
|
||||
flag.BoolVar(&config.DryRun, "dry-run", false, "Show what would be done without making changes")
|
||||
flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose logging")
|
||||
flag.BoolVar(&listZones, "list-zones", false, "List all accessible zones and exit")
|
||||
flag.BoolVar(&config.Force, "force", false, "Force update even if IP hasn't changed")
|
||||
flag.BoolVar(&testTelegram, "test-telegram", false, "Send a test Telegram notification and exit")
|
||||
flag.IntVar(&config.TTL, "ttl", 300, "TTL for DNS record in seconds")
|
||||
|
||||
// Custom usage function
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "CloudFlare Dynamic DNS Tool\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Updates CloudFlare DNS A records with your current public IP address.\n")
|
||||
fmt.Fprintf(os.Stderr, "Supports multiple records, dry-run mode, and Telegram notifications.\n\n")
|
||||
|
||||
fmt.Fprintf(os.Stderr, "USAGE:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s [OPTIONS]\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, "REQUIRED ENVIRONMENT VARIABLES:\n")
|
||||
fmt.Fprintf(os.Stderr, " CLOUDFLARE_API_TOKEN CloudFlare API token with Zone:DNS:Edit permissions\n")
|
||||
fmt.Fprintf(os.Stderr, " Get from: https://dash.cloudflare.com/profile/api-tokens\n\n")
|
||||
|
||||
fmt.Fprintf(os.Stderr, "OPTIONAL ENVIRONMENT VARIABLES:\n")
|
||||
fmt.Fprintf(os.Stderr, " TELEGRAM_BOT_TOKEN Telegram bot token for notifications\n")
|
||||
fmt.Fprintf(os.Stderr, " TELEGRAM_CHAT_ID Telegram chat ID to send notifications to\n\n")
|
||||
|
||||
fmt.Fprintf(os.Stderr, "OPTIONS:\n")
|
||||
flag.PrintDefaults()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nEXAMPLES:\n")
|
||||
fmt.Fprintf(os.Stderr, " # Update single record\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -record home.example.com\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, " # Update multiple records\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -record \"home.example.com,api.example.com,vpn.mydomain.net\"\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, " # Dry run with verbose output\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -dry-run -verbose -record home.example.com\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, " # Force update even if IP hasn't changed\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -force -record home.example.com\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, " # Custom TTL and IP sources\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -record home.example.com -ttl 600 -ip-sources \"https://ifconfig.co/ip,https://api.ipify.org\"\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, " # List accessible CloudFlare zones\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -list-zones\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, " # Test Telegram notifications\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -test-telegram\n\n", os.Args[0])
|
||||
|
||||
fmt.Fprintf(os.Stderr, "SETUP:\n")
|
||||
fmt.Fprintf(os.Stderr, " 1. Create CloudFlare API token:\n")
|
||||
fmt.Fprintf(os.Stderr, " - Go to https://dash.cloudflare.com/profile/api-tokens\n")
|
||||
fmt.Fprintf(os.Stderr, " - Use 'Edit zone DNS' template\n")
|
||||
fmt.Fprintf(os.Stderr, " - Select your zones\n")
|
||||
fmt.Fprintf(os.Stderr, " - Copy token and set CLOUDFLARE_API_TOKEN environment variable\n\n")
|
||||
|
||||
fmt.Fprintf(os.Stderr, " 2. Optional: Setup Telegram notifications:\n")
|
||||
fmt.Fprintf(os.Stderr, " - Message @BotFather on Telegram to create a bot\n")
|
||||
fmt.Fprintf(os.Stderr, " - Get your chat ID by messaging your bot, then visit:\n")
|
||||
fmt.Fprintf(os.Stderr, " https://api.telegram.org/bot<BOT_TOKEN>/getUpdates\n")
|
||||
fmt.Fprintf(os.Stderr, " - Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables\n\n")
|
||||
|
||||
fmt.Fprintf(os.Stderr, "NOTES:\n")
|
||||
fmt.Fprintf(os.Stderr, " - Records can be in different CloudFlare zones\n")
|
||||
fmt.Fprintf(os.Stderr, " - Only updates when IP actually changes (unless -force is used)\n")
|
||||
fmt.Fprintf(os.Stderr, " - Supports both root domains and subdomains\n")
|
||||
fmt.Fprintf(os.Stderr, " - Telegram notifications sent only when IP changes\n")
|
||||
fmt.Fprintf(os.Stderr, " - Use -dry-run to test without making changes\n\n")
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Validate required arguments (unless listing zones or testing telegram)
|
||||
if recordsFlag == "" && !listZones && !testTelegram {
|
||||
fmt.Fprintf(os.Stderr, "Error: -record flag is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Parse record names
|
||||
if recordsFlag != "" {
|
||||
config.RecordNames = strings.Split(recordsFlag, ",")
|
||||
// Trim whitespace from each record name
|
||||
for i, record := range config.RecordNames {
|
||||
config.RecordNames[i] = strings.TrimSpace(record)
|
||||
}
|
||||
}
|
||||
|
||||
// Get API token from environment
|
||||
config.APIToken = os.Getenv("CLOUDFLARE_API_TOKEN")
|
||||
if config.APIToken == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: CLOUDFLARE_API_TOKEN environment variable is required\n")
|
||||
fmt.Fprintf(os.Stderr, "Get your API token from: https://dash.cloudflare.com/profile/api-tokens\n")
|
||||
fmt.Fprintf(os.Stderr, "Create a token with 'Zone:DNS:Edit' permissions for your zone\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get optional Telegram credentials
|
||||
config.TelegramBotToken = os.Getenv("TELEGRAM_BOT_TOKEN")
|
||||
config.TelegramChatID = os.Getenv("TELEGRAM_CHAT_ID")
|
||||
|
||||
if config.Verbose && config.TelegramBotToken != "" && config.TelegramChatID != "" {
|
||||
fmt.Println("Telegram notifications enabled")
|
||||
}
|
||||
|
||||
// Parse IP sources
|
||||
if ipSourcesFlag != "" {
|
||||
config.IPSources = strings.Split(ipSourcesFlag, ",")
|
||||
} else {
|
||||
config.IPSources = defaultIPSources
|
||||
}
|
||||
|
||||
if config.Verbose {
|
||||
fmt.Printf("Config: Records=%v, TTL=%d, DryRun=%v, Force=%v, IPSources=%v\n",
|
||||
config.RecordNames, config.TTL, config.DryRun, config.Force, config.IPSources)
|
||||
}
|
||||
|
||||
// If testing telegram, do that and exit (skip API token validation)
|
||||
if testTelegram {
|
||||
if err := testTelegramNotification(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error testing Telegram: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate API token
|
||||
if err := validateToken(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error validating API token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if config.Verbose {
|
||||
fmt.Println("API token validated successfully")
|
||||
}
|
||||
|
||||
// If listing zones, do that and exit
|
||||
if listZones {
|
||||
if err := listAllZones(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error listing zones: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get current public IP
|
||||
currentIP, err := getCurrentIP(config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting current IP: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if config.Verbose {
|
||||
fmt.Printf("Current public IP: %s\n", currentIP)
|
||||
fmt.Printf("Processing %d record(s)\n", len(config.RecordNames))
|
||||
}
|
||||
|
||||
// Process each record
|
||||
var totalUpdates int
|
||||
var allNotifications []NotificationInfo
|
||||
|
||||
for _, recordName := range config.RecordNames {
|
||||
if config.Verbose {
|
||||
fmt.Printf("\n--- Processing record: %s ---\n", recordName)
|
||||
}
|
||||
|
||||
// Find the zone for the record
|
||||
zoneName, zoneID, err := findZoneForRecord(config, recordName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error finding zone for %s: %v\n", recordName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if config.Verbose {
|
||||
fmt.Printf("Found zone: %s (ID: %s)\n", zoneName, zoneID)
|
||||
}
|
||||
|
||||
// Find existing DNS record
|
||||
record, err := findDNSRecordByName(config, zoneID, recordName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error finding DNS record %s: %v\n", recordName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare IPs
|
||||
if record != nil {
|
||||
if record.Content == currentIP && !config.Force {
|
||||
fmt.Printf("DNS record %s already points to %s - no update needed\n", recordName, currentIP)
|
||||
continue
|
||||
}
|
||||
|
||||
if config.Verbose {
|
||||
if record.Content == currentIP {
|
||||
fmt.Printf("DNS record %s already points to %s, but forcing update\n",
|
||||
recordName, currentIP)
|
||||
} else {
|
||||
fmt.Printf("DNS record %s currently points to %s, needs update to %s\n",
|
||||
recordName, record.Content, currentIP)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if config.Verbose {
|
||||
fmt.Printf("DNS record %s does not exist, will create it\n", recordName)
|
||||
}
|
||||
}
|
||||
|
||||
// Update or create record
|
||||
if config.DryRun {
|
||||
if record != nil {
|
||||
if record.Content == currentIP && config.Force {
|
||||
fmt.Printf("DRY RUN: Would force update DNS record %s (already %s)\n",
|
||||
recordName, currentIP)
|
||||
} else {
|
||||
fmt.Printf("DRY RUN: Would update DNS record %s from %s to %s\n",
|
||||
recordName, record.Content, currentIP)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("DRY RUN: Would create DNS record %s with IP %s\n",
|
||||
recordName, currentIP)
|
||||
}
|
||||
|
||||
// Collect notification info for dry-run
|
||||
if record == nil || record.Content != currentIP || config.Force {
|
||||
var oldIPForNotification string
|
||||
if record != nil {
|
||||
oldIPForNotification = record.Content
|
||||
}
|
||||
allNotifications = append(allNotifications, NotificationInfo{
|
||||
RecordName: recordName,
|
||||
OldIP: oldIPForNotification,
|
||||
NewIP: currentIP,
|
||||
IsNew: record == nil,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var wasUpdated bool
|
||||
var oldIP string
|
||||
|
||||
if record != nil {
|
||||
oldIP = record.Content
|
||||
err = updateDNSRecordByName(config, zoneID, record.ID, recordName, currentIP)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error updating DNS record %s: %v\n", recordName, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Successfully updated DNS record %s to %s\n", recordName, currentIP)
|
||||
wasUpdated = true
|
||||
} else {
|
||||
err = createDNSRecordByName(config, zoneID, recordName, currentIP)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating DNS record %s: %v\n", recordName, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Successfully created DNS record %s with IP %s\n", recordName, currentIP)
|
||||
wasUpdated = true
|
||||
}
|
||||
|
||||
// Collect notification info for actual updates
|
||||
if wasUpdated && (record == nil || oldIP != currentIP || config.Force) {
|
||||
allNotifications = append(allNotifications, NotificationInfo{
|
||||
RecordName: recordName,
|
||||
OldIP: oldIP,
|
||||
NewIP: currentIP,
|
||||
IsNew: record == nil,
|
||||
})
|
||||
totalUpdates++
|
||||
}
|
||||
}
|
||||
|
||||
// Send batch notification if there were any changes
|
||||
if len(allNotifications) > 0 {
|
||||
sendBatchTelegramNotification(config, allNotifications, config.DryRun)
|
||||
}
|
||||
|
||||
if !config.DryRun && config.Verbose {
|
||||
fmt.Printf("\nProcessed %d record(s), %d update(s) made\n", len(config.RecordNames), totalUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
func validateToken(config *Config) error {
|
||||
req, err := http.NewRequest("GET", "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cfResp CloudFlareResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfResp.Success {
|
||||
return fmt.Errorf("token validation failed: %v", cfResp.Errors)
|
||||
}
|
||||
|
||||
var tokenInfo TokenVerification
|
||||
if err := json.Unmarshal(cfResp.Result, &tokenInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tokenInfo.Status != "active" {
|
||||
return fmt.Errorf("token is not active, status: %s", tokenInfo.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCurrentIP(config *Config) (string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, source := range config.IPSources {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Trying IP source: %s\n", source)
|
||||
}
|
||||
|
||||
resp, err := config.Client.Get(source)
|
||||
if err != nil {
|
||||
lastError = err
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to get IP from %s: %v\n", source, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
lastError = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
lastError = fmt.Errorf("HTTP %d from %s", resp.StatusCode, source)
|
||||
continue
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body))
|
||||
if ip != "" {
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
lastError = fmt.Errorf("empty response from %s", source)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to get IP from any source, last error: %v", lastError)
|
||||
}
|
||||
|
||||
func findZoneForRecord(config *Config, recordName string) (string, string, error) {
|
||||
// Extract domain from record name (e.g., "sub.example.com" -> try "example.com", "com")
|
||||
parts := strings.Split(recordName, ".")
|
||||
|
||||
if config.Verbose {
|
||||
fmt.Printf("Finding zone for record: %s\n", recordName)
|
||||
}
|
||||
|
||||
for i := 0; i < len(parts); i++ {
|
||||
zoneName := strings.Join(parts[i:], ".")
|
||||
|
||||
|
||||
|
||||
req, err := http.NewRequest("GET",
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", zoneName), nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var cfResp CloudFlareResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&cfResp)
|
||||
resp.Body.Close()
|
||||
|
||||
if err != nil || !cfResp.Success {
|
||||
continue
|
||||
}
|
||||
|
||||
var zones []Zone
|
||||
if err := json.Unmarshal(cfResp.Result, &zones); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(zones) > 0 {
|
||||
return zones[0].Name, zones[0].ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no zone found for record %s", recordName)
|
||||
}
|
||||
|
||||
func findDNSRecordByName(config *Config, zoneID string, recordName string) (*DNSRecord, error) {
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s",
|
||||
zoneID, recordName)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cfResp CloudFlareResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !cfResp.Success {
|
||||
return nil, fmt.Errorf("API error: %v", cfResp.Errors)
|
||||
}
|
||||
|
||||
var records []DNSRecord
|
||||
if err := json.Unmarshal(cfResp.Result, &records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, nil // Record doesn't exist
|
||||
}
|
||||
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func updateDNSRecordByName(config *Config, zoneID, recordID, recordName, ip string) error {
|
||||
data := map[string]interface{}{
|
||||
"type": "A",
|
||||
"name": recordName,
|
||||
"content": ip,
|
||||
"ttl": config.TTL,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID)
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cfResp CloudFlareResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfResp.Success {
|
||||
return fmt.Errorf("API error: %v", cfResp.Errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDNSRecordByName(config *Config, zoneID, recordName, ip string) error {
|
||||
data := map[string]interface{}{
|
||||
"type": "A",
|
||||
"name": recordName,
|
||||
"content": ip,
|
||||
"ttl": config.TTL,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cfResp CloudFlareResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfResp.Success {
|
||||
return fmt.Errorf("API error: %v", cfResp.Errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listAllZones(config *Config) error {
|
||||
req, err := http.NewRequest("GET", "https://api.cloudflare.com/client/v4/zones", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cfResp CloudFlareResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfResp.Success {
|
||||
return fmt.Errorf("API error: %v", cfResp.Errors)
|
||||
}
|
||||
|
||||
var zones []Zone
|
||||
if err := json.Unmarshal(cfResp.Result, &zones); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d accessible zones:\n", len(zones))
|
||||
for _, zone := range zones {
|
||||
fmt.Printf(" - %s (ID: %s)\n", zone.Name, zone.ID)
|
||||
}
|
||||
|
||||
if len(zones) == 0 {
|
||||
fmt.Println("No zones found. Make sure your API token has Zone:Read permissions.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendTelegramNotification(config *Config, record *DNSRecord, oldIP, newIP string, isDryRun bool) {
|
||||
// Skip if Telegram is not configured
|
||||
if config.TelegramBotToken == "" || config.TelegramChatID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var message string
|
||||
dryRunPrefix := ""
|
||||
if isDryRun {
|
||||
dryRunPrefix = "🧪 DRY RUN - "
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
message = fmt.Sprintf("%s🆕 DNS Record Created\n\n"+
|
||||
"Record: %s\n"+
|
||||
"New IP: %s\n"+
|
||||
"TTL: %d seconds",
|
||||
dryRunPrefix, "test-record", newIP, config.TTL)
|
||||
} else {
|
||||
message = fmt.Sprintf("%s🔄 IP Address Changed\n\n"+
|
||||
"Record: %s\n"+
|
||||
"Old IP: %s\n"+
|
||||
"New IP: %s\n"+
|
||||
"TTL: %d seconds",
|
||||
dryRunPrefix, "test-record", oldIP, newIP, config.TTL)
|
||||
}
|
||||
|
||||
// Prepare Telegram API request
|
||||
telegramURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", config.TelegramBotToken)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": config.TelegramChatID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to marshal Telegram payload: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send notification
|
||||
req, err := http.NewRequest("POST", telegramURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to create Telegram request: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to send Telegram notification: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
if config.Verbose {
|
||||
fmt.Println("Telegram notification sent successfully")
|
||||
}
|
||||
} else {
|
||||
if config.Verbose {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Telegram notification failed (HTTP %d): %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testTelegramNotification(config *Config) error {
|
||||
if config.TelegramBotToken == "" || config.TelegramChatID == "" {
|
||||
return fmt.Errorf("Telegram not configured. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables")
|
||||
}
|
||||
|
||||
fmt.Println("Testing Telegram notification...")
|
||||
|
||||
// Send a test message
|
||||
message := "🧪 Dynamic DNS Test\n\n" +
|
||||
"This is a test notification from your CloudFlare Dynamic DNS tool.\n\n" +
|
||||
"✅ Telegram integration is working correctly!"
|
||||
|
||||
telegramURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", config.TelegramBotToken)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": config.TelegramChatID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", telegramURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
fmt.Println("✅ Test notification sent successfully!")
|
||||
if config.Verbose {
|
||||
fmt.Printf("Response: %s\n", string(body))
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("failed to send notification (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func sendBatchTelegramNotification(config *Config, notifications []NotificationInfo, isDryRun bool) {
|
||||
// Skip if Telegram is not configured
|
||||
if config.TelegramBotToken == "" || config.TelegramChatID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if len(notifications) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var message string
|
||||
dryRunPrefix := ""
|
||||
if isDryRun {
|
||||
dryRunPrefix = "🧪 DRY RUN - "
|
||||
}
|
||||
|
||||
if len(notifications) == 1 {
|
||||
// Single record notification
|
||||
notif := notifications[0]
|
||||
if notif.IsNew {
|
||||
message = fmt.Sprintf("%s🆕 DNS Record Created\n\n"+
|
||||
"Record: %s\n"+
|
||||
"New IP: %s\n"+
|
||||
"TTL: %d seconds",
|
||||
dryRunPrefix, notif.RecordName, notif.NewIP, config.TTL)
|
||||
} else if notif.OldIP == notif.NewIP {
|
||||
message = fmt.Sprintf("%s🔄 DNS Record Force Updated\n\n"+
|
||||
"Record: %s\n"+
|
||||
"IP: %s (unchanged)\n"+
|
||||
"TTL: %d seconds\n"+
|
||||
"Note: Forced update requested",
|
||||
dryRunPrefix, notif.RecordName, notif.NewIP, config.TTL)
|
||||
} else {
|
||||
message = fmt.Sprintf("%s🔄 IP Address Changed\n\n"+
|
||||
"Record: %s\n"+
|
||||
"Old IP: %s\n"+
|
||||
"New IP: %s\n"+
|
||||
"TTL: %d seconds",
|
||||
dryRunPrefix, notif.RecordName, notif.OldIP, notif.NewIP, config.TTL)
|
||||
}
|
||||
} else {
|
||||
// Multiple records notification
|
||||
var newCount, updatedCount int
|
||||
for _, notif := range notifications {
|
||||
if notif.IsNew {
|
||||
newCount++
|
||||
} else {
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
message = fmt.Sprintf("%s📋 Multiple DNS Records Updated\n\n", dryRunPrefix)
|
||||
if newCount > 0 {
|
||||
message += fmt.Sprintf("🆕 Created: %d record(s)\n", newCount)
|
||||
}
|
||||
if updatedCount > 0 {
|
||||
message += fmt.Sprintf("🔄 Updated: %d record(s)\n", updatedCount)
|
||||
}
|
||||
message += fmt.Sprintf("\nNew IP: %s\nTTL: %d seconds\n\nRecords:", notifications[0].NewIP, config.TTL)
|
||||
|
||||
for _, notif := range notifications {
|
||||
if notif.IsNew {
|
||||
message += fmt.Sprintf("\n• %s (new)", notif.RecordName)
|
||||
} else if notif.OldIP == notif.NewIP {
|
||||
message += fmt.Sprintf("\n• %s (forced)", notif.RecordName)
|
||||
} else {
|
||||
message += fmt.Sprintf("\n• %s (%s → %s)", notif.RecordName, notif.OldIP, notif.NewIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the notification using the same logic as single notifications
|
||||
telegramURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", config.TelegramBotToken)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": config.TelegramChatID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to marshal Telegram payload: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", telegramURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to create Telegram request: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
if config.Verbose {
|
||||
fmt.Printf("Failed to send Telegram notification: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
if config.Verbose {
|
||||
fmt.Println("Telegram notification sent successfully")
|
||||
}
|
||||
} else {
|
||||
if config.Verbose {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Telegram notification failed (HTTP %d): %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
328
ansible/tasks/global/utils/flitsmeister.go
Normal file
328
ansible/tasks/global/utils/flitsmeister.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Trip struct {
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
StartAddr string
|
||||
EndAddr string
|
||||
KMStart float64
|
||||
KMEnd float64
|
||||
Distance float64
|
||||
License string
|
||||
BusinessCost float64
|
||||
Type string
|
||||
}
|
||||
|
||||
type MonthStats struct {
|
||||
TotalKM float64
|
||||
Trips int
|
||||
Longest float64
|
||||
Shortest float64
|
||||
TotalDuration time.Duration
|
||||
LongestGap time.Duration
|
||||
OdoAnomalies int
|
||||
AvgSpeed float64
|
||||
AvgTripDuration time.Duration
|
||||
FuelCost float64
|
||||
}
|
||||
|
||||
func main() {
|
||||
fuelPrice := flag.Float64("fuelprice", 0, "Fuel price per liter (EUR)")
|
||||
fuelEfficiency := flag.Float64("efficiency", 0, "Fuel efficiency (km per liter)")
|
||||
lPer100km := flag.Float64("lper100km", 0, "Fuel consumption (liters per 100km)")
|
||||
flag.Parse()
|
||||
|
||||
if len(flag.Args()) < 1 {
|
||||
fmt.Println("Usage: go run main.go -fuelprice <price> [-efficiency <km/l> | -lper100km <l/100km>] <filename.csv>")
|
||||
flag.PrintDefaults()
|
||||
return
|
||||
}
|
||||
|
||||
// Convert l/100km to km/l if provided
|
||||
finalEfficiency := *fuelEfficiency
|
||||
if *lPer100km > 0 {
|
||||
finalEfficiency = 100.0 / *lPer100km
|
||||
}
|
||||
|
||||
file, err := os.Open(flag.Arg(0))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.Comma = ','
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dutchMonths := map[string]string{
|
||||
"januari": "January", "februari": "February", "maart": "March",
|
||||
"april": "April", "mei": "May", "juni": "June", "juli": "July",
|
||||
"augustus": "August", "september": "September", "oktober": "October",
|
||||
"november": "November", "december": "December",
|
||||
}
|
||||
|
||||
tripsByMonth := make(map[string][]Trip)
|
||||
startAddrCount := make(map[string]int)
|
||||
endAddrCount := make(map[string]int)
|
||||
fuelEnabled := *fuelPrice > 0 && finalEfficiency > 0
|
||||
|
||||
// Parse CSV
|
||||
for _, record := range records[1:] {
|
||||
if len(record) < 13 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse start time
|
||||
startTime, err := parseDutchTime(record[1], dutchMonths)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse end time
|
||||
endTime, err := parseDutchTime(record[2], dutchMonths)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse distance data
|
||||
kmStart, _ := strconv.ParseFloat(strings.ReplaceAll(record[5], ",", ""), 64)
|
||||
kmEnd, _ := strconv.ParseFloat(strings.ReplaceAll(record[6], ",", ""), 64)
|
||||
distance, _ := strconv.ParseFloat(strings.ReplaceAll(record[7], ",", ""), 64)
|
||||
|
||||
trip := Trip{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
StartAddr: record[3],
|
||||
EndAddr: record[4],
|
||||
KMStart: kmStart,
|
||||
KMEnd: kmEnd,
|
||||
Distance: distance,
|
||||
License: record[8],
|
||||
BusinessCost: parseFloat(record[11]),
|
||||
Type: strings.TrimSpace(record[12]),
|
||||
}
|
||||
|
||||
monthKey := fmt.Sprintf("%d-%02d", startTime.Year(), startTime.Month())
|
||||
tripsByMonth[monthKey] = append(tripsByMonth[monthKey], trip)
|
||||
startAddrCount[trip.StartAddr]++
|
||||
endAddrCount[trip.EndAddr]++
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
months := sortedKeys(tripsByMonth)
|
||||
statsByMonth := calculateStats(tripsByMonth, fuelEnabled, *fuelPrice, finalEfficiency)
|
||||
|
||||
// Print results
|
||||
printMainTable(statsByMonth, months, fuelEnabled, tripsByMonth)
|
||||
printTopAddresses(startAddrCount, endAddrCount)
|
||||
}
|
||||
|
||||
func parseDutchTime(datetime string, monthMap map[string]string) (time.Time, error) {
|
||||
parts := strings.Split(datetime, " ")
|
||||
if len(parts) < 4 {
|
||||
return time.Time{}, fmt.Errorf("invalid time format")
|
||||
}
|
||||
|
||||
engMonth, ok := monthMap[strings.ToLower(parts[1])]
|
||||
if !ok {
|
||||
return time.Time{}, fmt.Errorf("unknown month")
|
||||
}
|
||||
|
||||
timeStr := fmt.Sprintf("%s %s %s %s", parts[0], engMonth, parts[2], parts[3])
|
||||
return time.Parse("2 January 2006 15:04", timeStr)
|
||||
}
|
||||
|
||||
func calculateStats(tripsByMonth map[string][]Trip, fuelEnabled bool, fuelPrice, fuelEfficiency float64) map[string]MonthStats {
|
||||
stats := make(map[string]MonthStats)
|
||||
|
||||
for month, trips := range tripsByMonth {
|
||||
var s MonthStats
|
||||
var prevEnd time.Time
|
||||
var longestGap time.Duration
|
||||
|
||||
sumSpeed := 0.0
|
||||
speedCount := 0
|
||||
|
||||
sort.Slice(trips, func(i, j int) bool {
|
||||
return trips[i].StartTime.Before(trips[j].StartTime)
|
||||
})
|
||||
|
||||
for i, t := range trips {
|
||||
s.TotalKM += t.Distance
|
||||
s.Trips++
|
||||
|
||||
duration := t.EndTime.Sub(t.StartTime)
|
||||
s.TotalDuration += duration
|
||||
if duration.Hours() > 0 {
|
||||
sumSpeed += t.Distance / duration.Hours()
|
||||
speedCount++
|
||||
}
|
||||
|
||||
if t.Distance > s.Longest {
|
||||
s.Longest = t.Distance
|
||||
}
|
||||
if t.Distance < s.Shortest || s.Shortest == 0 {
|
||||
s.Shortest = t.Distance
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
gap := t.StartTime.Sub(prevEnd)
|
||||
if gap > longestGap {
|
||||
longestGap = gap
|
||||
}
|
||||
if math.Abs(trips[i-1].KMEnd-t.KMStart) > 0.01 {
|
||||
s.OdoAnomalies++
|
||||
}
|
||||
}
|
||||
prevEnd = t.EndTime
|
||||
}
|
||||
|
||||
s.LongestGap = longestGap
|
||||
if speedCount > 0 {
|
||||
s.AvgSpeed = sumSpeed / float64(speedCount)
|
||||
} else {
|
||||
s.AvgSpeed = 0
|
||||
}
|
||||
if s.Trips > 0 {
|
||||
s.AvgTripDuration = time.Duration(int64(s.TotalDuration) / int64(s.Trips))
|
||||
}
|
||||
|
||||
if fuelEnabled {
|
||||
s.FuelCost = (s.TotalKM / fuelEfficiency) * fuelPrice
|
||||
}
|
||||
|
||||
stats[month] = s
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func printMainTable(stats map[string]MonthStats, months []string, fuelEnabled bool, tripsByMonth map[string][]Trip) {
|
||||
fmt.Println("\n=== Monthly Driving Overview ===")
|
||||
|
||||
headers := []string{"Month", "Total", "Trips", "AvgKM", "Longest", "Shortest",
|
||||
"DriveTime", "AvgTripDur", "OdoErr", "AvgSpeed"}
|
||||
format := "%-10s | %-16s | %-7s | %-14s | %-24s | %-26s | %-18s | %-18s | %-10s | %-18s"
|
||||
if fuelEnabled {
|
||||
headers = append(headers, "Fuel Cost (EUR)")
|
||||
format += " | %-18s"
|
||||
}
|
||||
fmt.Printf(format+"\n", toInterfaceSlice(headers)...) // print header
|
||||
fmt.Println(strings.Repeat("-", 180))
|
||||
|
||||
for _, month := range months {
|
||||
s := stats[month]
|
||||
trips := tripsByMonth[month]
|
||||
|
||||
// Find longest and shortest trip durations
|
||||
var longestDur, shortestDur time.Duration
|
||||
var longestDist, shortestDist float64
|
||||
if len(trips) > 0 {
|
||||
for i, t := range trips {
|
||||
dur := t.EndTime.Sub(t.StartTime)
|
||||
if t.Distance > longestDist || i == 0 {
|
||||
longestDist = t.Distance
|
||||
longestDur = dur
|
||||
}
|
||||
if t.Distance < shortestDist || i == 0 {
|
||||
shortestDist = t.Distance
|
||||
shortestDur = dur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row := []interface{}{
|
||||
month,
|
||||
fmt.Sprintf("%.2f Km", s.TotalKM),
|
||||
fmt.Sprintf("%d", s.Trips),
|
||||
fmt.Sprintf("%.2f Km", safeDiv(s.TotalKM, float64(s.Trips))),
|
||||
fmt.Sprintf("%.2f Km (%s)", longestDist, fmtDuration(longestDur)),
|
||||
fmt.Sprintf("%.2f Km (%s)", shortestDist, fmtDuration(shortestDur)),
|
||||
fmtDuration(s.TotalDuration),
|
||||
fmtDuration(s.AvgTripDuration),
|
||||
fmt.Sprintf("%d", s.OdoAnomalies),
|
||||
fmt.Sprintf("%.2f Km/h", s.AvgSpeed),
|
||||
}
|
||||
if fuelEnabled {
|
||||
row = append(row, fmt.Sprintf("%.2f EUR", s.FuelCost))
|
||||
}
|
||||
fmt.Printf(format+"\n", row...)
|
||||
}
|
||||
}
|
||||
|
||||
func toInterfaceSlice(strs []string) []interface{} {
|
||||
res := make([]interface{}, len(strs))
|
||||
for i, v := range strs {
|
||||
res[i] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func printTopAddresses(start, end map[string]int) {
|
||||
fmt.Println("\n=== Frequent Locations ===")
|
||||
fmt.Println("Top 3 Start Addresses:")
|
||||
printTopN(start, 3)
|
||||
fmt.Println("\nTop 3 End Addresses:")
|
||||
printTopN(end, 3)
|
||||
}
|
||||
|
||||
// Helper functions (safeDiv, fmtDuration, printTopN) remain unchanged from previous version
|
||||
// [Include the helper functions from previous script here]
|
||||
|
||||
func safeDiv(a, b float64) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
}
|
||||
|
||||
func fmtDuration(d time.Duration) string {
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
return fmt.Sprintf("%02dh%02dm", h, m)
|
||||
}
|
||||
|
||||
func printTopN(counter map[string]int, n int) {
|
||||
type kv struct {
|
||||
Key string
|
||||
Value int
|
||||
}
|
||||
var sorted []kv
|
||||
for k, v := range counter {
|
||||
sorted = append(sorted, kv{k, v})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Value > sorted[j].Value })
|
||||
|
||||
for i := 0; i < n && i < len(sorted); i++ {
|
||||
fmt.Printf("%d. %s (%d)\n", i+1, sorted[i].Key, sorted[i].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string][]Trip) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
f, _ := strconv.ParseFloat(strings.ReplaceAll(s, ",", ""), 64)
|
||||
return f
|
||||
}
|
||||
365
ansible/tasks/global/utils/helloworld.go
Normal file
365
ansible/tasks/global/utils/helloworld.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ANSI color codes
|
||||
var colors = map[string]string{
|
||||
"black": "\033[0;30m",
|
||||
"red": "\033[0;31m",
|
||||
"green": "\033[0;32m",
|
||||
"yellow": "\033[0;33m",
|
||||
"blue": "\033[0;34m",
|
||||
"purple": "\033[0;35m",
|
||||
"cyan": "\033[0;36m",
|
||||
"white": "\033[0;37m",
|
||||
"grey": "\033[0;90m",
|
||||
"reset": "\033[0m",
|
||||
}
|
||||
|
||||
// DistroIcon represents a distro icon and color
|
||||
type DistroIcon struct {
|
||||
Icon string
|
||||
Color string
|
||||
}
|
||||
|
||||
// DotfilesStatus represents the git status of dotfiles
|
||||
type DotfilesStatus struct {
|
||||
IsDirty bool
|
||||
Untracked int
|
||||
Modified int
|
||||
Staged int
|
||||
CommitHash string
|
||||
Unpushed int
|
||||
}
|
||||
|
||||
func main() {
|
||||
welcome()
|
||||
}
|
||||
|
||||
func rainbowColor(text string, freq float64, offset float64) string {
|
||||
var result strings.Builder
|
||||
for i, char := range text {
|
||||
if strings.TrimSpace(string(char)) != "" { // Only color non-whitespace characters
|
||||
// Calculate RGB values using sine waves with phase shifts
|
||||
r := int(127*math.Sin(freq*float64(i)+offset+0) + 128)
|
||||
g := int(127*math.Sin(freq*float64(i)+offset+2*math.Pi/3) + 128)
|
||||
b := int(127*math.Sin(freq*float64(i)+offset+4*math.Pi/3) + 128)
|
||||
|
||||
// Apply the RGB color to the character
|
||||
result.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm%c\033[0m", r, g, b, char))
|
||||
} else {
|
||||
result.WriteRune(char)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func printLogo() {
|
||||
logo := ` __ ___ _ ____ __ _____ __
|
||||
/ |/ /__ ____ ____ ____ ( )_____ / __ \____ / /_/ __(_) /__ _____
|
||||
/ /|_/ / _ \/ __ \/ __ \/ __ \|// ___/ / / / / __ \/ __/ /_/ / / _ \/ ___/
|
||||
/ / / / __/ / / / / / / /_/ / (__ ) / /_/ / /_/ / /_/ __/ / / __(__ )
|
||||
/_/ /_/\___/_/ /_/_/ /_/\____/ /____/ /_____/\____/\__/_/ /_/_/\___/____/`
|
||||
|
||||
lines := strings.Split(logo, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
fmt.Println(rainbowColor(line, 0.1, 0))
|
||||
} else {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func getLastSSHLogin() string {
|
||||
user := os.Getenv("USER")
|
||||
if user == "" {
|
||||
user = os.Getenv("USERNAME")
|
||||
}
|
||||
if user == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try lastlog first
|
||||
cmd := exec.Command("lastlog", "-u", user)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Try lastlog2
|
||||
cmd = exec.Command("lastlog2", user)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) >= 2 {
|
||||
parts := strings.Fields(lines[1])
|
||||
if len(parts) >= 7 && strings.Contains(parts[1], "ssh") {
|
||||
ip := parts[2]
|
||||
timeStr := strings.Join(parts[3:], " ")
|
||||
return fmt.Sprintf("%sLast SSH login%s%s %s%s from%s %s",
|
||||
colors["cyan"], colors["reset"], colors["yellow"], timeStr, colors["cyan"], colors["yellow"], ip)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkDotfilesStatus() *DotfilesStatus {
|
||||
dotfilesPath := os.Getenv("DOTFILES_PATH")
|
||||
if dotfilesPath == "" {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
dotfilesPath = filepath.Join(homeDir, ".dotfiles")
|
||||
}
|
||||
|
||||
gitPath := filepath.Join(dotfilesPath, ".git")
|
||||
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := &DotfilesStatus{}
|
||||
|
||||
// Check git status
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
cmd.Dir = dotfilesPath
|
||||
output, err := cmd.Output()
|
||||
if err == nil && strings.TrimSpace(string(output)) != "" {
|
||||
status.IsDirty = true
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "??") {
|
||||
status.Untracked++
|
||||
}
|
||||
if strings.HasPrefix(line, " M") || strings.HasPrefix(line, "MM") {
|
||||
status.Modified++
|
||||
}
|
||||
if strings.HasPrefix(line, "M ") || strings.HasPrefix(line, "A ") {
|
||||
status.Staged++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get commit hash
|
||||
cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
|
||||
cmd.Dir = dotfilesPath
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
status.CommitHash = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// Count unpushed commits
|
||||
cmd = exec.Command("git", "log", "--oneline", "@{u}..")
|
||||
cmd.Dir = dotfilesPath
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) > 0 && lines[0] != "" {
|
||||
status.Unpushed = len(lines)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func getCondensedStatus() (string, string) {
|
||||
var statusParts []string
|
||||
var hashInfo string
|
||||
|
||||
// Check trash status
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
trashPath := filepath.Join(homeDir, ".local", "share", "Trash", "files")
|
||||
if entries, err := os.ReadDir(trashPath); err == nil {
|
||||
count := len(entries)
|
||||
if count > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("[!] %d file(s) in trash", count))
|
||||
}
|
||||
}
|
||||
|
||||
// Check dotfiles status
|
||||
dotfilesStatus := checkDotfilesStatus()
|
||||
if dotfilesStatus != nil {
|
||||
if dotfilesStatus.IsDirty {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%sdotfiles is dirty%s", colors["yellow"], colors["reset"]))
|
||||
statusParts = append(statusParts, fmt.Sprintf("%s[%d] untracked%s", colors["red"], dotfilesStatus.Untracked, colors["reset"]))
|
||||
statusParts = append(statusParts, fmt.Sprintf("%s[%d] modified%s", colors["yellow"], dotfilesStatus.Modified, colors["reset"]))
|
||||
statusParts = append(statusParts, fmt.Sprintf("%s[%d] staged%s", colors["green"], dotfilesStatus.Staged, colors["reset"]))
|
||||
}
|
||||
|
||||
if dotfilesStatus.CommitHash != "" {
|
||||
hashInfo = fmt.Sprintf("%s[%s%s%s]%s", colors["white"], colors["blue"], dotfilesStatus.CommitHash, colors["white"], colors["reset"])
|
||||
if dotfilesStatus.IsDirty {
|
||||
statusParts = append(statusParts, hashInfo)
|
||||
hashInfo = ""
|
||||
}
|
||||
}
|
||||
|
||||
if dotfilesStatus.Unpushed > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%s[!] You have %d commit(s) to push%s", colors["yellow"], dotfilesStatus.Unpushed, colors["reset"]))
|
||||
}
|
||||
} else {
|
||||
statusParts = append(statusParts, "Unable to check dotfiles status")
|
||||
}
|
||||
|
||||
statusLine := ""
|
||||
if len(statusParts) > 0 {
|
||||
statusLine = strings.Join(statusParts, " - ")
|
||||
}
|
||||
return statusLine, hashInfo
|
||||
}
|
||||
|
||||
func runDotfilesCommand(args ...string) (string, error) {
|
||||
cmd := exec.Command("dotfiles", args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func getDistroIcon() (string, string) {
|
||||
distroIcons := map[string]DistroIcon{
|
||||
"windows": {"\uf17a", colors["blue"]}, // blue
|
||||
"linux": {"\uf17c", colors["yellow"]}, // yellow
|
||||
"ubuntu": {"\uf31b", "\033[38;5;208m"}, // orange (ANSI 208)
|
||||
"debian": {"\uf306", colors["red"]}, // red
|
||||
"arch": {"\uf303", colors["cyan"]}, // cyan
|
||||
"fedora": {"\uf30a", colors["blue"]}, // blue
|
||||
"alpine": {"\uf300", colors["cyan"]}, // cyan
|
||||
"macos": {"\uf179", colors["white"]}, // white
|
||||
"darwin": {"\uf179", colors["white"]}, // white
|
||||
"osx": {"\uf179", colors["white"]}, // white
|
||||
}
|
||||
|
||||
distro, err := runDotfilesCommand("variables", "get", "Platform.Distro", "--format", "raw")
|
||||
if err != nil {
|
||||
distro = strings.ToLower(runtime.GOOS)
|
||||
} else {
|
||||
distro = strings.ToLower(distro)
|
||||
}
|
||||
|
||||
if icon, exists := distroIcons[distro]; exists {
|
||||
return icon.Icon, icon.Color
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
for key, icon := range distroIcons {
|
||||
if strings.Contains(distro, key) {
|
||||
return icon.Icon, icon.Color
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func detectShell() string {
|
||||
// Check for PowerShell profile
|
||||
if os.Getenv("PROFILE") != "" || os.Getenv("PW_SH_PROFILE") != "" || os.Getenv("PSModulePath") != "" {
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
if shell := os.Getenv("SHELL"); shell != "" {
|
||||
return filepath.Base(shell)
|
||||
}
|
||||
|
||||
if comspec := os.Getenv("COMSPEC"); comspec != "" {
|
||||
if strings.HasSuffix(strings.ToLower(comspec), "cmd.exe") {
|
||||
if os.Getenv("PROFILE") != "" {
|
||||
return "Powershell"
|
||||
}
|
||||
return "CMD"
|
||||
}
|
||||
return filepath.Base(comspec)
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func welcome() {
|
||||
printLogo()
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = "unknown-host"
|
||||
}
|
||||
|
||||
// Get distro icon
|
||||
distroIcon, iconColor := getDistroIcon()
|
||||
|
||||
// Get username
|
||||
username := os.Getenv("USER")
|
||||
if username == "" {
|
||||
username = os.Getenv("USERNAME")
|
||||
}
|
||||
if username == "" {
|
||||
username = "user"
|
||||
}
|
||||
|
||||
// Get SSH login info
|
||||
sshLogin := getLastSSHLogin()
|
||||
|
||||
// Get shell and arch
|
||||
shell := detectShell()
|
||||
arch := runtime.GOARCH
|
||||
|
||||
// Capitalize shell and arch for display
|
||||
shellDisp := strings.Title(shell)
|
||||
archDisp := strings.ToUpper(arch)
|
||||
|
||||
// Get package managers
|
||||
pkgMgrs, err := runDotfilesCommand("variables", "get", "Platform.AvailablePackageManagers", "--format", "raw")
|
||||
if err != nil {
|
||||
pkgMgrs = ""
|
||||
}
|
||||
|
||||
// Compact single line: user@hostname with icon, shell, arch
|
||||
fmt.Printf("%s%s%s@%s%s", colors["green"], username, colors["cyan"], colors["yellow"], hostname)
|
||||
if distroIcon != "" {
|
||||
fmt.Printf(" %s%s", iconColor, distroIcon)
|
||||
}
|
||||
fmt.Printf("%s running %s%s%s/%s%s", colors["cyan"], colors["blue"], shellDisp, colors["cyan"], colors["purple"], archDisp)
|
||||
|
||||
if pkgMgrs != "" {
|
||||
// Parse and color package managers
|
||||
pkgMgrs = strings.Trim(pkgMgrs, "[]")
|
||||
pmList := strings.Fields(strings.ReplaceAll(pkgMgrs, ",", ""))
|
||||
pmColors := []string{colors["yellow"], colors["green"], colors["cyan"], colors["red"], colors["blue"]}
|
||||
var coloredPMs []string
|
||||
|
||||
for i, pm := range pmList {
|
||||
color := pmColors[i%len(pmColors)]
|
||||
coloredPMs = append(coloredPMs, fmt.Sprintf("%s%s", color, pm))
|
||||
}
|
||||
|
||||
fmt.Printf("%s [%s%s]", colors["cyan"], strings.Join(coloredPMs, colors["cyan"]+"/"), colors["reset"])
|
||||
} else {
|
||||
fmt.Printf("%s", colors["reset"])
|
||||
}
|
||||
|
||||
// Get status info
|
||||
condensedStatus, hashInfo := getCondensedStatus()
|
||||
|
||||
// Add hash to same line if dotfiles is clean
|
||||
if hashInfo != "" {
|
||||
fmt.Printf(" %s", hashInfo)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Display last SSH login info if available
|
||||
if sshLogin != "" {
|
||||
fmt.Printf("%s%s\n", sshLogin, colors["reset"])
|
||||
}
|
||||
|
||||
// Display condensed status line only if there are issues
|
||||
if condensedStatus != "" {
|
||||
fmt.Printf("%s%s%s\n", colors["yellow"], condensedStatus, colors["reset"])
|
||||
}
|
||||
}
|
||||
748
ansible/tasks/global/utils/inuse.go
Normal file
748
ansible/tasks/global/utils/inuse.go
Normal file
@@ -0,0 +1,748 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Color constants for terminal output
|
||||
const (
|
||||
Red = "\033[0;31m"
|
||||
Green = "\033[0;32m"
|
||||
Yellow = "\033[1;33m"
|
||||
Blue = "\033[0;34m"
|
||||
Cyan = "\033[0;36m"
|
||||
Bold = "\033[1m"
|
||||
NC = "\033[0m" // No Color
|
||||
)
|
||||
|
||||
// ProcessInfo holds information about a process using a port
|
||||
type ProcessInfo struct {
|
||||
PID int
|
||||
ProcessName string
|
||||
Protocol string
|
||||
DockerInfo string
|
||||
}
|
||||
|
||||
// DockerContainer represents a Docker container
|
||||
type DockerContainer struct {
|
||||
Name string
|
||||
Image string
|
||||
Ports []PortMapping
|
||||
Network string
|
||||
}
|
||||
|
||||
// PortMapping represents a port mapping
|
||||
type PortMapping struct {
|
||||
ContainerPort int
|
||||
HostPort int
|
||||
Protocol string
|
||||
IPv6 bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
showUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
arg := os.Args[1]
|
||||
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
showHelp()
|
||||
case "--list", "-l":
|
||||
listDockerServices()
|
||||
default:
|
||||
port, err := strconv.Atoi(arg)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
fmt.Printf("%sError:%s Invalid port number. Must be between 1 and 65535.\n", Red, NC)
|
||||
os.Exit(1)
|
||||
}
|
||||
checkPort(port)
|
||||
}
|
||||
}
|
||||
|
||||
func showUsage() {
|
||||
fmt.Printf("%sUsage:%s inuse <port_number>\n", Red, NC)
|
||||
fmt.Printf("%s inuse --list%s\n", Yellow, NC)
|
||||
fmt.Printf("%s inuse --help%s\n", Yellow, NC)
|
||||
fmt.Printf("%sExample:%s inuse 80\n", Yellow, NC)
|
||||
fmt.Printf("%s inuse --list%s\n", Yellow, NC)
|
||||
}
|
||||
|
||||
func showHelp() {
|
||||
fmt.Printf("%s%sinuse - Check if a port is in use%s\n\n", Cyan, Bold, NC)
|
||||
fmt.Printf("%sUSAGE:%s\n", Bold, NC)
|
||||
fmt.Printf(" inuse <port_number> Check if a specific port is in use\n")
|
||||
fmt.Printf(" inuse --list, -l List all Docker services with listening ports\n")
|
||||
fmt.Printf(" inuse --help, -h Show this help message\n\n")
|
||||
fmt.Printf("%sEXAMPLES:%s\n", Bold, NC)
|
||||
fmt.Printf(" %sinuse 80%s Check if port 80 is in use\n", Green, NC)
|
||||
fmt.Printf(" %sinuse 3000%s Check if port 3000 is in use\n", Green, NC)
|
||||
fmt.Printf(" %sinuse --list%s Show all Docker services with ports\n\n", Green, NC)
|
||||
fmt.Printf("%sDESCRIPTION:%s\n", Bold, NC)
|
||||
fmt.Printf(" The inuse function checks if a specific port is in use and identifies\n")
|
||||
fmt.Printf(" the process using it. It can detect regular processes, Docker containers\n")
|
||||
fmt.Printf(" with published ports, and containers using host networking.\n\n")
|
||||
fmt.Printf("%sOUTPUT:%s\n", Bold, NC)
|
||||
fmt.Printf(" %s✓%s Port is in use - shows process name, PID, and Docker info if applicable\n", Green, NC)
|
||||
fmt.Printf(" %s✗%s Port is free\n", Red, NC)
|
||||
fmt.Printf(" %s⚠%s Port is in use but process cannot be identified\n", Yellow, NC)
|
||||
}
|
||||
|
||||
func listDockerServices() {
|
||||
if !isDockerAvailable() {
|
||||
fmt.Printf("%sError:%s Docker is not available\n", Red, NC)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("%s%sDocker Services with Listening Ports:%s\n\n", Cyan, Bold, NC)
|
||||
|
||||
containers := getRunningContainers()
|
||||
if len(containers) == 0 {
|
||||
fmt.Printf("%sNo running Docker containers found%s\n", Yellow, NC)
|
||||
return
|
||||
}
|
||||
|
||||
foundServices := false
|
||||
for _, container := range containers {
|
||||
if len(container.Ports) > 0 {
|
||||
cleanImage := cleanImageName(container.Image)
|
||||
fmt.Printf("%s📦 %s%s%s %s(%s)%s\n", Green, Bold, container.Name, NC, Cyan, cleanImage, NC)
|
||||
|
||||
for _, port := range container.Ports {
|
||||
ipv6Marker := ""
|
||||
if port.IPv6 {
|
||||
ipv6Marker = " [IPv6]"
|
||||
}
|
||||
fmt.Printf("%s ├─ Port %s%d%s%s → %d (%s)%s%s\n",
|
||||
Cyan, Bold, port.HostPort, NC, Cyan, port.ContainerPort, port.Protocol, ipv6Marker, NC)
|
||||
}
|
||||
fmt.Println()
|
||||
foundServices = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for host networking containers
|
||||
hostContainers := getHostNetworkingContainers()
|
||||
if len(hostContainers) > 0 {
|
||||
fmt.Printf("%s%sHost Networking Containers:%s\n", Yellow, Bold, NC)
|
||||
for _, container := range hostContainers {
|
||||
cleanImage := cleanImageName(container.Image)
|
||||
fmt.Printf("%s🌐 %s%s%s %s(%s)%s %s- uses host networking%s\n",
|
||||
Yellow, Bold, container.Name, NC, Cyan, cleanImage, NC, Yellow, NC)
|
||||
}
|
||||
fmt.Println()
|
||||
foundServices = true
|
||||
}
|
||||
|
||||
if !foundServices {
|
||||
fmt.Printf("%sNo Docker services with exposed ports found%s\n", Yellow, NC)
|
||||
}
|
||||
}
|
||||
|
||||
func checkPort(port int) {
|
||||
// Check if port is in use first
|
||||
if !isPortInUse(port) {
|
||||
fmt.Printf("%s✗ Port %d is FREE%s\n", Red, port, NC)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Port is in use, now find what's using it
|
||||
process := findProcessUsingPort(port)
|
||||
if process != nil {
|
||||
dockerInfo := ""
|
||||
if process.DockerInfo != "" {
|
||||
dockerInfo = " " + process.DockerInfo
|
||||
}
|
||||
fmt.Printf("%s✓ Port %d (%s) in use by %s%s%s %sas PID %s%d%s%s\n",
|
||||
Green, port, process.Protocol, Bold, process.ProcessName, NC, Green, Bold, process.PID, NC, dockerInfo)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a Docker container
|
||||
containerInfo := findDockerContainerUsingPort(port)
|
||||
if containerInfo != "" {
|
||||
fmt.Printf("%s✓ Port %d in use by Docker container %s\n", Green, port, containerInfo)
|
||||
return
|
||||
}
|
||||
|
||||
// If we still haven't found the process, check for host networking containers more thoroughly
|
||||
hostNetworkProcess := findHostNetworkingProcess(port)
|
||||
if hostNetworkProcess != "" {
|
||||
fmt.Printf("%s✓ Port %d likely in use by %s\n", Green, port, hostNetworkProcess)
|
||||
return
|
||||
}
|
||||
|
||||
// If we still haven't found the process
|
||||
fmt.Printf("%s⚠ Port %d is in use but unable to identify the process%s\n", Yellow, port, NC)
|
||||
|
||||
if isDockerAvailable() {
|
||||
hostContainers := getHostNetworkingContainers()
|
||||
if len(hostContainers) > 0 {
|
||||
fmt.Printf("%s Note: Found Docker containers using host networking:%s\n", Cyan, NC)
|
||||
for _, container := range hostContainers {
|
||||
cleanImage := cleanImageName(container.Image)
|
||||
fmt.Printf("%s - %s (%s)%s\n", Cyan, container.Name, cleanImage, NC)
|
||||
}
|
||||
fmt.Printf("%s These containers share the host's network, so one of them might be using this port%s\n", Cyan, NC)
|
||||
} else {
|
||||
fmt.Printf("%s This might be due to insufficient permissions or the process being in a different namespace%s\n", Cyan, NC)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s This might be due to insufficient permissions or the process being in a different namespace%s\n", Cyan, NC)
|
||||
}
|
||||
}
|
||||
|
||||
func isPortInUse(port int) bool {
|
||||
// Try ss first
|
||||
if isCommandAvailable("ss") {
|
||||
cmd := exec.Command("ss", "-tulpn")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
portPattern := fmt.Sprintf(":%d ", port)
|
||||
return strings.Contains(string(output), portPattern)
|
||||
}
|
||||
}
|
||||
|
||||
// Try netstat as fallback
|
||||
if isCommandAvailable("netstat") {
|
||||
cmd := exec.Command("netstat", "-tulpn")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
portPattern := fmt.Sprintf(":%d ", port)
|
||||
return strings.Contains(string(output), portPattern)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func findProcessUsingPort(port int) *ProcessInfo {
|
||||
// Method 1: Try netstat
|
||||
if process := tryNetstat(port); process != nil {
|
||||
return process
|
||||
}
|
||||
|
||||
// Method 2: Try ss
|
||||
if process := trySS(port); process != nil {
|
||||
return process
|
||||
}
|
||||
|
||||
// Method 3: Try lsof
|
||||
if process := tryLsof(port); process != nil {
|
||||
return process
|
||||
}
|
||||
|
||||
// Method 4: Try fuser
|
||||
if process := tryFuser(port); process != nil {
|
||||
return process
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryNetstat(port int) *ProcessInfo {
|
||||
if !isCommandAvailable("netstat") {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("netstat", "-tulpn")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Try with sudo if available
|
||||
if isCommandAvailable("sudo") {
|
||||
cmd = exec.Command("sudo", "netstat", "-tulpn")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
portPattern := fmt.Sprintf(":%d ", port)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, portPattern) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 7 {
|
||||
pidProcess := fields[6]
|
||||
parts := strings.Split(pidProcess, "/")
|
||||
if len(parts) >= 2 {
|
||||
if pid, err := strconv.Atoi(parts[0]); err == nil {
|
||||
processName := parts[1]
|
||||
protocol := fields[0]
|
||||
dockerInfo := getDockerInfo(pid, processName, port)
|
||||
return &ProcessInfo{
|
||||
PID: pid,
|
||||
ProcessName: processName,
|
||||
Protocol: protocol,
|
||||
DockerInfo: dockerInfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func trySS(port int) *ProcessInfo {
|
||||
if !isCommandAvailable("ss") {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("ss", "-tulpn")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Try with sudo if available
|
||||
if isCommandAvailable("sudo") {
|
||||
cmd = exec.Command("sudo", "ss", "-tulpn")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
portPattern := fmt.Sprintf(":%d ", port)
|
||||
pidRegex := regexp.MustCompile(`pid=(\d+)`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, portPattern) {
|
||||
matches := pidRegex.FindStringSubmatch(line)
|
||||
if len(matches) >= 2 {
|
||||
if pid, err := strconv.Atoi(matches[1]); err == nil {
|
||||
processName := getProcessName(pid)
|
||||
if processName != "" {
|
||||
fields := strings.Fields(line)
|
||||
protocol := ""
|
||||
if len(fields) > 0 {
|
||||
protocol = fields[0]
|
||||
}
|
||||
dockerInfo := getDockerInfo(pid, processName, port)
|
||||
return &ProcessInfo{
|
||||
PID: pid,
|
||||
ProcessName: processName,
|
||||
Protocol: protocol,
|
||||
DockerInfo: dockerInfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryLsof(port int) *ProcessInfo {
|
||||
if !isCommandAvailable("lsof") {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-n", "-P")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Try with sudo if available
|
||||
if isCommandAvailable("sudo") {
|
||||
cmd = exec.Command("sudo", "lsof", "-i", fmt.Sprintf(":%d", port), "-n", "-P")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "LISTEN") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
processName := fields[0]
|
||||
if pid, err := strconv.Atoi(fields[1]); err == nil {
|
||||
dockerInfo := getDockerInfo(pid, processName, port)
|
||||
return &ProcessInfo{
|
||||
PID: pid,
|
||||
ProcessName: processName,
|
||||
Protocol: "tcp",
|
||||
DockerInfo: dockerInfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryFuser(port int) *ProcessInfo {
|
||||
if !isCommandAvailable("fuser") {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("fuser", fmt.Sprintf("%d/tcp", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pids := strings.Fields(string(output))
|
||||
for _, pidStr := range pids {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(pidStr)); err == nil {
|
||||
processName := getProcessName(pid)
|
||||
if processName != "" {
|
||||
return &ProcessInfo{
|
||||
PID: pid,
|
||||
ProcessName: processName,
|
||||
Protocol: "tcp",
|
||||
DockerInfo: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getProcessName(pid int) string {
|
||||
cmd := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func getDockerInfo(pid int, processName string, port int) string {
|
||||
if !isDockerAvailable() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if it's docker-proxy (handle truncated names like "docker-pr")
|
||||
if processName == "docker-proxy" || strings.HasPrefix(processName, "docker-pr") {
|
||||
containerName := getContainerByPublishedPort(port)
|
||||
if containerName != "" {
|
||||
image := getContainerImage(containerName)
|
||||
cleanImage := cleanImageName(image)
|
||||
return fmt.Sprintf("%s(Docker: %s, image: %s)%s", Cyan, containerName, cleanImage, NC)
|
||||
}
|
||||
return fmt.Sprintf("%s(Docker proxy)%s", Cyan, NC)
|
||||
}
|
||||
|
||||
// Check if process is in a Docker container using cgroup
|
||||
containerInfo := getContainerByPID(pid)
|
||||
if containerInfo != "" {
|
||||
return fmt.Sprintf("%s(Docker: %s)%s", Cyan, containerInfo, NC)
|
||||
}
|
||||
|
||||
// Check if this process might be in a host networking container
|
||||
hostContainer := checkHostNetworkingContainer(pid, processName)
|
||||
if hostContainer != "" {
|
||||
return fmt.Sprintf("%s(Docker host network: %s)%s", Cyan, hostContainer, NC)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getContainerByPID(pid int) string {
|
||||
cgroupPath := fmt.Sprintf("/proc/%d/cgroup", pid)
|
||||
file, err := os.Open(cgroupPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
containerIDRegex := regexp.MustCompile(`[a-f0-9]{64}`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "docker") {
|
||||
matches := containerIDRegex.FindStringSubmatch(line)
|
||||
if len(matches) > 0 {
|
||||
containerID := matches[0]
|
||||
containerName := getContainerNameByID(containerID)
|
||||
if containerName != "" {
|
||||
return containerName
|
||||
}
|
||||
return containerID[:12]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func findDockerContainerUsingPort(port int) string {
|
||||
if !isDockerAvailable() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for containers with published ports
|
||||
cmd := exec.Command("docker", "ps", "--format", "{{.Names}}", "--filter", fmt.Sprintf("publish=%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
containerName := strings.TrimSpace(string(output))
|
||||
if containerName != "" {
|
||||
image := getContainerImage(containerName)
|
||||
cleanImage := cleanImageName(image)
|
||||
return fmt.Sprintf("%s%s%s %s(published port, image: %s)%s", Bold, containerName, NC, Cyan, cleanImage, NC)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDockerAvailable() bool {
|
||||
return isCommandAvailable("docker")
|
||||
}
|
||||
|
||||
func isCommandAvailable(command string) bool {
|
||||
_, err := exec.LookPath(command)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func getRunningContainers() []DockerContainer {
|
||||
if !isDockerAvailable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", "ps", "--format", "{{.Names}}")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var containers []DockerContainer
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
|
||||
for scanner.Scan() {
|
||||
containerName := strings.TrimSpace(scanner.Text())
|
||||
if containerName != "" {
|
||||
container := DockerContainer{
|
||||
Name: containerName,
|
||||
Image: getContainerImage(containerName),
|
||||
Ports: getContainerPorts(containerName),
|
||||
}
|
||||
containers = append(containers, container)
|
||||
}
|
||||
}
|
||||
|
||||
return containers
|
||||
}
|
||||
|
||||
func getHostNetworkingContainers() []DockerContainer {
|
||||
if !isDockerAvailable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", "ps", "--format", "{{.Names}}", "--filter", "network=host")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var containers []DockerContainer
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
|
||||
for scanner.Scan() {
|
||||
containerName := strings.TrimSpace(scanner.Text())
|
||||
if containerName != "" {
|
||||
container := DockerContainer{
|
||||
Name: containerName,
|
||||
Image: getContainerImage(containerName),
|
||||
Network: "host",
|
||||
}
|
||||
containers = append(containers, container)
|
||||
}
|
||||
}
|
||||
|
||||
return containers
|
||||
}
|
||||
|
||||
func getContainerImage(containerName string) string {
|
||||
cmd := exec.Command("docker", "inspect", containerName)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var inspectData []map[string]interface{}
|
||||
if err := json.Unmarshal(output, &inspectData); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(inspectData) > 0 {
|
||||
if image, ok := inspectData[0]["Config"].(map[string]interface{})["Image"].(string); ok {
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getContainerPorts(containerName string) []PortMapping {
|
||||
cmd := exec.Command("docker", "port", containerName)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ports []PortMapping
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
portRegex := regexp.MustCompile(`(\d+)/(tcp|udp) -> 0\.0\.0\.0:(\d+)`)
|
||||
ipv6PortRegex := regexp.MustCompile(`(\d+)/(tcp|udp) -> \[::\]:(\d+)`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Check for IPv4
|
||||
if matches := portRegex.FindStringSubmatch(line); len(matches) >= 4 {
|
||||
containerPort, _ := strconv.Atoi(matches[1])
|
||||
protocol := matches[2]
|
||||
hostPort, _ := strconv.Atoi(matches[3])
|
||||
|
||||
ports = append(ports, PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: hostPort,
|
||||
Protocol: protocol,
|
||||
IPv6: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for IPv6
|
||||
if matches := ipv6PortRegex.FindStringSubmatch(line); len(matches) >= 4 {
|
||||
containerPort, _ := strconv.Atoi(matches[1])
|
||||
protocol := matches[2]
|
||||
hostPort, _ := strconv.Atoi(matches[3])
|
||||
|
||||
ports = append(ports, PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: hostPort,
|
||||
Protocol: protocol,
|
||||
IPv6: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ports
|
||||
}
|
||||
|
||||
func getContainerByPublishedPort(port int) string {
|
||||
cmd := exec.Command("docker", "ps", "--format", "{{.Names}}", "--filter", fmt.Sprintf("publish=%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func getContainerNameByID(containerID string) string {
|
||||
cmd := exec.Command("docker", "inspect", containerID)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var inspectData []map[string]interface{}
|
||||
if err := json.Unmarshal(output, &inspectData); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(inspectData) > 0 {
|
||||
if name, ok := inspectData[0]["Name"].(string); ok {
|
||||
return strings.TrimPrefix(name, "/")
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func cleanImageName(image string) string {
|
||||
// Remove SHA256 hashes
|
||||
shaRegex := regexp.MustCompile(`sha256:[a-f0-9]*`)
|
||||
cleaned := shaRegex.ReplaceAllString(image, "[image-hash]")
|
||||
|
||||
// Remove registry prefixes, keep only the last part
|
||||
parts := strings.Split(cleaned, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func findHostNetworkingProcess(port int) string {
|
||||
if !isDockerAvailable() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get all host networking containers
|
||||
hostContainers := getHostNetworkingContainers()
|
||||
|
||||
for _, container := range hostContainers {
|
||||
// Check if this container might be using the port
|
||||
if isContainerUsingPort(container.Name, port) {
|
||||
cleanImage := cleanImageName(container.Image)
|
||||
return fmt.Sprintf("%s%s%s %s(Docker host network: %s)%s", Bold, container.Name, NC, Cyan, cleanImage, NC)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isContainerUsingPort(containerName string, port int) bool {
|
||||
// Try to execute netstat inside the container to see if it's listening on the port
|
||||
cmd := exec.Command("docker", "exec", containerName, "sh", "-c",
|
||||
fmt.Sprintf("netstat -tlnp 2>/dev/null | grep ':%d ' || ss -tlnp 2>/dev/null | grep ':%d '", port, port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return len(output) > 0
|
||||
}
|
||||
|
||||
func checkHostNetworkingContainer(pid int, processName string) string {
|
||||
if !isDockerAvailable() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get all host networking containers and check if any match this process
|
||||
hostContainers := getHostNetworkingContainers()
|
||||
|
||||
for _, container := range hostContainers {
|
||||
// Try to find this process inside the container
|
||||
cmd := exec.Command("docker", "exec", container.Name, "sh", "-c",
|
||||
fmt.Sprintf("ps -o pid,comm | grep '%s' | grep -q '%d\\|%s'", processName, pid, processName))
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
cleanImage := cleanImageName(container.Image)
|
||||
return fmt.Sprintf("%s (%s)", container.Name, cleanImage)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
249
ansible/tasks/global/utils/ipaddr
Executable file
249
ansible/tasks/global/utils/ipaddr
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import argparse
|
||||
import requests
|
||||
|
||||
def get_physical_interfaces():
|
||||
"""
|
||||
Retrieve a list of physical network interfaces on the system.
|
||||
|
||||
This function checks the `/sys/class/net/` directory to identify physical
|
||||
network interfaces. It determines if an interface is physical by verifying
|
||||
the presence of a symbolic link to a `device` directory.
|
||||
|
||||
Returns:
|
||||
list: A list of strings, where each string is the name of a physical
|
||||
network interface.
|
||||
"""
|
||||
interfaces_path = '/sys/class/net/'
|
||||
physical_interfaces = []
|
||||
|
||||
for interface in os.listdir(interfaces_path):
|
||||
if not os.path.islink(os.path.join(interfaces_path, interface, 'device')):
|
||||
continue
|
||||
physical_interfaces.append(interface)
|
||||
|
||||
return physical_interfaces
|
||||
|
||||
def get_virtual_interfaces():
|
||||
"""
|
||||
Retrieves a list of virtual network interfaces on the system.
|
||||
|
||||
This function scans the network interfaces available in the '/sys/class/net/'
|
||||
directory and filters out physical interfaces and the loopback interface ('lo').
|
||||
It identifies virtual interfaces by checking if the 'device' path is not a
|
||||
symbolic link.
|
||||
|
||||
Returns:
|
||||
list: A list of virtual network interface names as strings.
|
||||
"""
|
||||
interfaces_path = '/sys/class/net/'
|
||||
virtual_interfaces = []
|
||||
|
||||
for interface in os.listdir(interfaces_path):
|
||||
if os.path.islink(os.path.join(interfaces_path, interface, 'device')):
|
||||
continue
|
||||
if interface == 'lo':
|
||||
continue
|
||||
virtual_interfaces.append(interface)
|
||||
|
||||
return virtual_interfaces
|
||||
|
||||
def get_up_interfaces(interfaces):
|
||||
"""
|
||||
Filters the given list of interfaces to include only those that are up or unknown.
|
||||
|
||||
Args:
|
||||
interfaces (list): A list of interface names.
|
||||
|
||||
Returns:
|
||||
list: A list of interface names that are up or treated as up (e.g., UNKNOWN).
|
||||
"""
|
||||
up_interfaces = []
|
||||
for interface in interfaces:
|
||||
try:
|
||||
result = subprocess.run(['ip', 'link', 'show', interface],
|
||||
capture_output=True, text=True, check=True)
|
||||
if "state UP" in result.stdout or "state UNKNOWN" in result.stdout:
|
||||
up_interfaces.append(interface)
|
||||
except Exception:
|
||||
continue
|
||||
return up_interfaces
|
||||
|
||||
def get_interface_state(interface):
|
||||
"""
|
||||
Retrieve the state and MAC address of a network interface.
|
||||
|
||||
Args:
|
||||
interface (str): The name of the network interface.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the state (str) and MAC address (str) of the interface.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(['ip', 'link', 'show', interface],
|
||||
capture_output=True, text=True, check=True)
|
||||
lines = result.stdout.splitlines()
|
||||
state = "UNKNOWN"
|
||||
mac = "N/A"
|
||||
|
||||
if len(lines) > 0:
|
||||
if "state UP" in lines[0]:
|
||||
state = "UP"
|
||||
elif "state DOWN" in lines[0]:
|
||||
state = "DOWN"
|
||||
elif "state UNKNOWN" in lines[0]:
|
||||
state = "UP" # Treat UNKNOWN as UP
|
||||
|
||||
if len(lines) > 1:
|
||||
mac = lines[1].strip().split()[1] if len(lines[1].strip().split()) > 1 else "N/A"
|
||||
|
||||
return state, mac
|
||||
except Exception:
|
||||
return "UNKNOWN", "N/A"
|
||||
|
||||
def get_external_ips():
|
||||
"""
|
||||
Fetch both IPv4 and IPv6 external IP addresses of the machine.
|
||||
|
||||
This function first attempts to retrieve an IP address using the services
|
||||
`https://ifconfig.co`, `https://ifconfig.io`, and `https://ifconfig.me`. If the
|
||||
first IP fetched is IPv6, it explicitly tries to fetch an IPv4 address using
|
||||
curl's `-4` option.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the IPv4 and IPv6 addresses as strings. If either
|
||||
address cannot be fetched, it will be set to "Unavailable".
|
||||
"""
|
||||
services = ["https://ip.mvl.sh", "https://ifconfig.co", "https://api.ipify.org", "https://myexternalip.com/raw", "https://ifconfig.io", "https://ifconfig.me"]
|
||||
headers = {"User-Agent": "curl"}
|
||||
ipv4, ipv6 = "Unavailable", "Unavailable"
|
||||
|
||||
for service in services:
|
||||
try:
|
||||
response = requests.get(service, headers=headers, timeout=0.2)
|
||||
if response.status_code == 200:
|
||||
ip = response.text.strip()
|
||||
if ":" in ip: # IPv6 address
|
||||
ipv6 = ip
|
||||
# Try to fetch IPv4 explicitly
|
||||
ipv4_response = subprocess.run(
|
||||
["curl", "-4", "--silent", service],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=0.2,
|
||||
check=True
|
||||
)
|
||||
if ipv4_response.returncode == 0:
|
||||
ipv4 = ipv4_response.stdout.strip()
|
||||
else: # IPv4 address
|
||||
ipv4 = ip
|
||||
if ipv4 != "Unavailable" and ipv6 != "Unavailable":
|
||||
break
|
||||
except (requests.RequestException, subprocess.TimeoutExpired):
|
||||
continue
|
||||
|
||||
return ipv4, ipv6
|
||||
|
||||
def display_interface_details(show_physical=False, show_virtual=False, show_all=False, show_external_ip=False, show_ipv6=False):
|
||||
"""
|
||||
Display details of network interfaces based on the specified flags.
|
||||
|
||||
Args:
|
||||
show_physical (bool): Show physical interfaces (UP by default unless combined with show_all).
|
||||
show_virtual (bool): Show virtual interfaces (UP by default unless combined with show_all).
|
||||
show_all (bool): Include all interfaces (UP, DOWN, UNKNOWN).
|
||||
show_external_ip (bool): Fetch and display the external IP address.
|
||||
show_ipv6 (bool): Include IPv6 addresses in the output.
|
||||
|
||||
Notes:
|
||||
- By default, only IPv4 addresses are shown unless `-6` is specified.
|
||||
- IPv6 addresses are displayed in a separate column if `-6` is specified.
|
||||
"""
|
||||
if show_external_ip:
|
||||
ipv4, ipv6 = get_external_ips()
|
||||
print(f"External IPv4: {ipv4}")
|
||||
print(f"External IPv6: {ipv6}")
|
||||
print("-" * 70)
|
||||
|
||||
interfaces = []
|
||||
|
||||
if show_all:
|
||||
if show_physical or not show_virtual: # Default to physical if no `-v`
|
||||
interfaces.extend(get_physical_interfaces())
|
||||
if show_virtual:
|
||||
interfaces.extend(get_virtual_interfaces())
|
||||
else:
|
||||
if show_physical or not show_virtual: # Default to physical if no `-v`
|
||||
interfaces.extend(get_up_interfaces(get_physical_interfaces()))
|
||||
if show_virtual or not show_physical: # Default to virtual if no `-p`
|
||||
interfaces.extend(get_up_interfaces(get_virtual_interfaces()))
|
||||
|
||||
interfaces.sort()
|
||||
|
||||
# Define column widths based on expected maximum content length
|
||||
col_widths = {
|
||||
'interface': 15,
|
||||
'ipv4': 18,
|
||||
'ipv6': 40 if show_ipv6 else 0, # Hide IPv6 column if not showing IPv6
|
||||
'subnet': 10,
|
||||
'state': 10,
|
||||
'mac': 18
|
||||
}
|
||||
|
||||
# Print header with proper formatting
|
||||
header = f"{'Interface':<{col_widths['interface']}} {'IPv4 Address':<{col_widths['ipv4']}}"
|
||||
if show_ipv6:
|
||||
header += f" {'IPv6 Address':<{col_widths['ipv6']}}"
|
||||
header += f" {'Subnet':<{col_widths['subnet']}} {'State':<{col_widths['state']}} {'MAC Address':<{col_widths['mac']}}"
|
||||
print(header)
|
||||
print("-" * (col_widths['interface'] + col_widths['ipv4'] + (col_widths['ipv6'] if show_ipv6 else 0) + col_widths['subnet'] + col_widths['state'] + col_widths['mac']))
|
||||
|
||||
for interface in interfaces:
|
||||
try:
|
||||
result = subprocess.run(['ip', '-br', 'addr', 'show', interface],
|
||||
capture_output=True, text=True, check=True)
|
||||
state, mac = get_interface_state(interface)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().splitlines()
|
||||
ipv4 = "N/A"
|
||||
ipv6 = "N/A"
|
||||
subnet = ""
|
||||
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
ip_with_mask = parts[2]
|
||||
|
||||
# Check if the address is IPv4 or IPv6
|
||||
if ":" in ip_with_mask: # IPv6
|
||||
ipv6 = ip_with_mask.split('/')[0]
|
||||
else: # IPv4
|
||||
ipv4 = ip_with_mask.split('/')[0]
|
||||
subnet = ip_with_mask.split('/')[1] if '/' in ip_with_mask else ""
|
||||
|
||||
row = f"{interface:<{col_widths['interface']}} {ipv4:<{col_widths['ipv4']}}"
|
||||
if show_ipv6:
|
||||
row += f" {ipv6:<{col_widths['ipv6']}}"
|
||||
row += f" {subnet:<{col_widths['subnet']}} {state:<{col_widths['state']}} {mac:<{col_widths['mac']}}"
|
||||
print(row)
|
||||
except Exception as e:
|
||||
print(f"Error fetching details for {interface}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Display network interface information')
|
||||
parser.add_argument('-p', action='store_true', help='Show physical interfaces (UP by default)')
|
||||
parser.add_argument('-v', action='store_true', help='Show virtual interfaces (UP by default)')
|
||||
parser.add_argument('-a', action='store_true', help='Include all interfaces (UP, DOWN, UNKNOWN)')
|
||||
parser.add_argument('-e', action='store_true', help='Fetch and display the external IP address')
|
||||
parser.add_argument('--ipv6', '-6', action='store_true', help='Include IPv6 addresses in the output')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Default to showing both UP physical and virtual interfaces if no flags are specified
|
||||
display_interface_details(show_physical=args.p or not (args.p or args.v or args.a or args.e),
|
||||
show_virtual=args.v or not (args.p or args.v or args.a or args.e),
|
||||
show_all=args.a,
|
||||
show_external_ip=args.e,
|
||||
show_ipv6=args.ipv6)
|
||||
298
ansible/tasks/global/utils/llm
Executable file
298
ansible/tasks/global/utils/llm
Executable file
@@ -0,0 +1,298 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
KOBOLD_PATH="/mnt/data/ai/llm/koboldcpp-linux-x64"
|
||||
KOBOLD_MODEL="/mnt/data/ai/llm/Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf" # Default model
|
||||
SILLYTAVERN_SCREEN="sillytavern"
|
||||
KOBOLD_SCREEN="koboldcpp"
|
||||
|
||||
# Function to check if a screen session exists
|
||||
check_screen() {
|
||||
screen -ls | grep -q "\.${1}\s"
|
||||
}
|
||||
|
||||
# Function to list available models
|
||||
list_models() {
|
||||
echo -e "${BLUE}Available models:${NC}"
|
||||
ls -1 /mnt/data/ai/llm/*.gguf | nl -w2 -s'. '
|
||||
}
|
||||
|
||||
# Function to select a model
|
||||
select_model() {
|
||||
list_models
|
||||
echo
|
||||
read -p "Select model number (or press Enter for default): " model_num
|
||||
|
||||
if [[ -z "$model_num" ]]; then
|
||||
echo -e "${YELLOW}Using default model: $(basename "$KOBOLD_MODEL")${NC}"
|
||||
else
|
||||
selected_model=$(ls -1 /mnt/data/ai/llm/*.gguf | sed -n "${model_num}p")
|
||||
if [[ -n "$selected_model" ]]; then
|
||||
KOBOLD_MODEL="$selected_model"
|
||||
echo -e "${GREEN}Selected model: $(basename "$KOBOLD_MODEL")${NC}"
|
||||
else
|
||||
echo -e "${RED}Invalid selection. Using default model.${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to start SillyTavern
|
||||
start_sillytavern() {
|
||||
echo -e "${YELLOW}Starting SillyTavern in screen session '${SILLYTAVERN_SCREEN}'...${NC}"
|
||||
screen -dmS "$SILLYTAVERN_SCREEN" bash -c "sillytavern --listen 0.0.0.0"
|
||||
sleep 2
|
||||
if check_screen "$SILLYTAVERN_SCREEN"; then
|
||||
echo -e "${GREEN}✓ SillyTavern started successfully!${NC}"
|
||||
echo -e "${BLUE} Access at: http://0.0.0.0:8000${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to start SillyTavern${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to start KoboldCPP
|
||||
start_koboldcpp() {
|
||||
select_model
|
||||
echo -e "${YELLOW}Starting KoboldCPP in screen session '${KOBOLD_SCREEN}'...${NC}"
|
||||
screen -dmS "$KOBOLD_SCREEN" bash -c "cd /mnt/data/ai/llm && ./koboldcpp-linux-x64 --model '$KOBOLD_MODEL' --host 0.0.0.0 --port 5001 --contextsize 8192 --gpulayers 999"
|
||||
sleep 2
|
||||
if check_screen "$KOBOLD_SCREEN"; then
|
||||
echo -e "${GREEN}✓ KoboldCPP started successfully!${NC}"
|
||||
echo -e "${BLUE} Model: $(basename "$KOBOLD_MODEL")${NC}"
|
||||
echo -e "${BLUE} Access at: http://0.0.0.0:5001${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to start KoboldCPP${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to stop a service
|
||||
stop_service() {
|
||||
local service=$1
|
||||
local screen_name=$2
|
||||
|
||||
echo -e "${YELLOW}Stopping ${service}...${NC}"
|
||||
screen -S "$screen_name" -X quit
|
||||
sleep 1
|
||||
if ! check_screen "$screen_name"; then
|
||||
echo -e "${GREEN}✓ ${service} stopped successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to stop ${service}${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to show service status
|
||||
show_status() {
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ Service Status Overview ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
|
||||
local st_running=false
|
||||
local kc_running=false
|
||||
|
||||
# Check SillyTavern
|
||||
if check_screen "$SILLYTAVERN_SCREEN"; then
|
||||
st_running=true
|
||||
echo -e " ${GREEN}●${NC} SillyTavern: ${GREEN}Running${NC} (screen: ${SILLYTAVERN_SCREEN})"
|
||||
echo -e " ${BLUE}→ http://0.0.0.0:8000${NC}"
|
||||
else
|
||||
echo -e " ${RED}●${NC} SillyTavern: ${RED}Not running${NC}"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# Check KoboldCPP
|
||||
if check_screen "$KOBOLD_SCREEN"; then
|
||||
kc_running=true
|
||||
echo -e " ${GREEN}●${NC} KoboldCPP: ${GREEN}Running${NC} (screen: ${KOBOLD_SCREEN})"
|
||||
echo -e " ${BLUE}→ http://0.0.0.0:5001${NC}"
|
||||
else
|
||||
echo -e " ${RED}●${NC} KoboldCPP: ${RED}Not running${NC}"
|
||||
fi
|
||||
|
||||
echo
|
||||
}
|
||||
|
||||
# Function to handle service management
|
||||
manage_services() {
|
||||
local st_running=$(check_screen "$SILLYTAVERN_SCREEN" && echo "true" || echo "false")
|
||||
local kc_running=$(check_screen "$KOBOLD_SCREEN" && echo "true" || echo "false")
|
||||
|
||||
# If both services are running
|
||||
if [[ "$st_running" == "true" ]] && [[ "$kc_running" == "true" ]]; then
|
||||
echo -e "${GREEN}Both services are running.${NC}"
|
||||
echo
|
||||
echo "1) Attach to SillyTavern"
|
||||
echo "2) Attach to KoboldCPP"
|
||||
echo "3) Restart SillyTavern"
|
||||
echo "4) Restart KoboldCPP"
|
||||
echo "5) Stop all services"
|
||||
echo "6) Exit"
|
||||
|
||||
read -p "Your choice (1-6): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo -e "${BLUE}Attaching to SillyTavern... (Use Ctrl+A then D to detach)${NC}"
|
||||
sleep 1
|
||||
screen -r "$SILLYTAVERN_SCREEN"
|
||||
;;
|
||||
2)
|
||||
echo -e "${BLUE}Attaching to KoboldCPP... (Use Ctrl+A then D to detach)${NC}"
|
||||
sleep 1
|
||||
screen -r "$KOBOLD_SCREEN"
|
||||
;;
|
||||
3)
|
||||
stop_service "SillyTavern" "$SILLYTAVERN_SCREEN"
|
||||
echo
|
||||
start_sillytavern
|
||||
;;
|
||||
4)
|
||||
stop_service "KoboldCPP" "$KOBOLD_SCREEN"
|
||||
echo
|
||||
start_koboldcpp
|
||||
;;
|
||||
5)
|
||||
stop_service "SillyTavern" "$SILLYTAVERN_SCREEN"
|
||||
stop_service "KoboldCPP" "$KOBOLD_SCREEN"
|
||||
;;
|
||||
6)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# If only SillyTavern is running
|
||||
elif [[ "$st_running" == "true" ]]; then
|
||||
echo -e "${YELLOW}Only SillyTavern is running.${NC}"
|
||||
echo
|
||||
echo "1) Attach to SillyTavern"
|
||||
echo "2) Start KoboldCPP"
|
||||
echo "3) Restart SillyTavern"
|
||||
echo "4) Stop SillyTavern"
|
||||
echo "5) Exit"
|
||||
|
||||
read -p "Your choice (1-5): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo -e "${BLUE}Attaching to SillyTavern... (Use Ctrl+A then D to detach)${NC}"
|
||||
sleep 1
|
||||
screen -r "$SILLYTAVERN_SCREEN"
|
||||
;;
|
||||
2)
|
||||
start_koboldcpp
|
||||
;;
|
||||
3)
|
||||
stop_service "SillyTavern" "$SILLYTAVERN_SCREEN"
|
||||
echo
|
||||
start_sillytavern
|
||||
;;
|
||||
4)
|
||||
stop_service "SillyTavern" "$SILLYTAVERN_SCREEN"
|
||||
;;
|
||||
5)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# If only KoboldCPP is running
|
||||
elif [[ "$kc_running" == "true" ]]; then
|
||||
echo -e "${YELLOW}Only KoboldCPP is running.${NC}"
|
||||
echo
|
||||
echo "1) Attach to KoboldCPP"
|
||||
echo "2) Start SillyTavern"
|
||||
echo "3) Restart KoboldCPP"
|
||||
echo "4) Stop KoboldCPP"
|
||||
echo "5) Exit"
|
||||
|
||||
read -p "Your choice (1-5): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo -e "${BLUE}Attaching to KoboldCPP... (Use Ctrl+A then D to detach)${NC}"
|
||||
sleep 1
|
||||
screen -r "$KOBOLD_SCREEN"
|
||||
;;
|
||||
2)
|
||||
start_sillytavern
|
||||
;;
|
||||
3)
|
||||
stop_service "KoboldCPP" "$KOBOLD_SCREEN"
|
||||
echo
|
||||
start_koboldcpp
|
||||
;;
|
||||
4)
|
||||
stop_service "KoboldCPP" "$KOBOLD_SCREEN"
|
||||
;;
|
||||
5)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# If no services are running
|
||||
else
|
||||
echo -e "${YELLOW}No services are running.${NC}"
|
||||
echo
|
||||
echo "1) Start both services"
|
||||
echo "2) Start SillyTavern only"
|
||||
echo "3) Start KoboldCPP only"
|
||||
echo "4) Exit"
|
||||
|
||||
read -p "Your choice (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
start_sillytavern
|
||||
echo
|
||||
start_koboldcpp
|
||||
;;
|
||||
2)
|
||||
start_sillytavern
|
||||
;;
|
||||
3)
|
||||
start_koboldcpp
|
||||
;;
|
||||
4)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ LLM Services Manager ║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
|
||||
# Show status
|
||||
show_status
|
||||
|
||||
# Show separator and manage services
|
||||
echo -e "${CYAN}═══════════════════════════════════════${NC}"
|
||||
manage_services
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Quick reference:${NC}"
|
||||
echo "• List sessions: screen -ls"
|
||||
echo "• Attach: screen -r <name>"
|
||||
echo "• Detach: Ctrl+A then D"
|
||||
119
ansible/tasks/global/utils/smart-ssh/README.md
Normal file
119
ansible/tasks/global/utils/smart-ssh/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# SSH Utility - Smart SSH Connection Manager
|
||||
|
||||
A transparent SSH wrapper that automatically chooses between local and remote connections based on network connectivity.
|
||||
|
||||
## What it does
|
||||
|
||||
This utility acts as a drop-in replacement for the `ssh` command that intelligently routes connections:
|
||||
|
||||
- When you type `ssh desktop`, it automatically checks if your local network is available
|
||||
- If local: connects via `desktop-local` (faster local connection)
|
||||
- If remote: connects via `desktop` (Tailscale/VPN connection)
|
||||
- All other SSH usage passes through unchanged (`ssh --help`, `ssh user@host`, etc.)
|
||||
|
||||
## Installation
|
||||
|
||||
The utility is automatically compiled and installed to `~/.local/bin/ssh` via Ansible when you run your dotfiles setup.
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy the example config:
|
||||
```bash
|
||||
mkdir -p ~/.config/ssh-util
|
||||
cp ~/.dotfiles/config/ssh-util/config.yaml ~/.config/ssh-util/
|
||||
```
|
||||
|
||||
2. Edit `~/.config/ssh-util/config.yaml` to match your setup:
|
||||
```yaml
|
||||
smart_aliases:
|
||||
desktop:
|
||||
primary: "desktop-local" # SSH config entry for local connection
|
||||
fallback: "desktop" # SSH config entry for remote connection
|
||||
check_host: "192.168.86.22" # IP to ping for connectivity test
|
||||
timeout: "2s" # Ping timeout
|
||||
```
|
||||
|
||||
3. Ensure your `~/.ssh/config` contains the referenced host entries:
|
||||
```
|
||||
Host desktop
|
||||
HostName mennos-desktop
|
||||
User menno
|
||||
Port 400
|
||||
ForwardAgent yes
|
||||
AddKeysToAgent yes
|
||||
|
||||
Host desktop-local
|
||||
HostName 192.168.86.22
|
||||
User menno
|
||||
Port 400
|
||||
ForwardAgent yes
|
||||
AddKeysToAgent yes
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, simply use SSH as normal:
|
||||
|
||||
```bash
|
||||
# Smart connection - automatically chooses local vs remote
|
||||
ssh desktop
|
||||
|
||||
# All other SSH usage works exactly the same
|
||||
ssh --help
|
||||
ssh --version
|
||||
ssh user@example.com
|
||||
ssh -L 8080:localhost:80 server
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. When you run `ssh <alias>`, the utility checks if `<alias>` is defined in the smart_aliases config
|
||||
2. If yes, it pings the `check_host` IP address
|
||||
3. If ping succeeds: executes `ssh <primary>` instead
|
||||
4. If ping fails: executes `ssh <fallback>` instead
|
||||
5. If not a smart alias: passes through to real SSH unchanged
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SSH utility not found
|
||||
Make sure `~/.local/bin` is in your PATH:
|
||||
```bash
|
||||
echo $PATH | grep -o ~/.local/bin
|
||||
```
|
||||
|
||||
### Config not loading
|
||||
Check the config file exists and has correct syntax:
|
||||
```bash
|
||||
ls -la ~/.config/ssh-util/config.yaml
|
||||
cat ~/.config/ssh-util/config.yaml
|
||||
```
|
||||
|
||||
### Connectivity test failing
|
||||
Test manually:
|
||||
```bash
|
||||
ping -c 1 -W 2 192.168.86.22
|
||||
```
|
||||
|
||||
### Falls back to real SSH
|
||||
If there are any errors loading config or parsing, the utility safely falls back to executing the real SSH binary at `/usr/bin/ssh`.
|
||||
|
||||
## Adding more aliases
|
||||
|
||||
To add more smart aliases, just extend the config:
|
||||
|
||||
```yaml
|
||||
smart_aliases:
|
||||
desktop:
|
||||
primary: "desktop-local"
|
||||
fallback: "desktop"
|
||||
check_host: "192.168.86.22"
|
||||
timeout: "2s"
|
||||
|
||||
server:
|
||||
primary: "server-local"
|
||||
fallback: "server-remote"
|
||||
check_host: "192.168.1.100"
|
||||
timeout: "1s"
|
||||
```
|
||||
|
||||
Remember to create the corresponding entries in your `~/.ssh/config`.
|
||||
90
ansible/tasks/global/utils/smart-ssh/config.yaml
Normal file
90
ansible/tasks/global/utils/smart-ssh/config.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
# SSH Utility Configuration
|
||||
# This file defines smart aliases that automatically choose between local and remote connections
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
enabled: true
|
||||
# Levels: debug, info, warn, error
|
||||
level: "info"
|
||||
# Formats: console, json
|
||||
format: "console"
|
||||
|
||||
smart_aliases:
|
||||
desktop:
|
||||
primary: "desktop-local"
|
||||
fallback: "desktop"
|
||||
check_host: "192.168.1.254"
|
||||
timeout: "2s"
|
||||
|
||||
laptop:
|
||||
primary: "laptop-local"
|
||||
fallback: "laptop"
|
||||
check_host: "192.168.1.253"
|
||||
timeout: "2s"
|
||||
|
||||
# Background SSH Tunnel Definitions
|
||||
tunnels:
|
||||
# Example: Desktop database tunnel
|
||||
desktop-database:
|
||||
type: local
|
||||
local_port: 5432
|
||||
remote_host: database
|
||||
remote_port: 5432
|
||||
ssh_host: desktop # Uses smart alias logic (desktop-local/desktop)
|
||||
|
||||
# Example: Development API tunnel
|
||||
dev-api:
|
||||
type: local
|
||||
local_port: 8080
|
||||
remote_host: api
|
||||
remote_port: 80
|
||||
ssh_host: dev-server
|
||||
|
||||
# Example: SOCKS proxy tunnel
|
||||
socks-proxy:
|
||||
type: dynamic
|
||||
local_port: 1080
|
||||
ssh_host: bastion
|
||||
|
||||
# Modem web interface tunnel
|
||||
modem-web:
|
||||
type: local
|
||||
local_port: 8443
|
||||
remote_host: 192.168.1.1
|
||||
remote_port: 443
|
||||
ssh_host: desktop
|
||||
# Tunnel Management Commands:
|
||||
# ssh --tunnel --open desktop-database (or ssh -TO desktop-database)
|
||||
# ssh --tunnel --close desktop-database (or ssh -TC desktop-database)
|
||||
# ssh --tunnel --list (or ssh -TL)
|
||||
#
|
||||
# Ad-hoc tunnels (not in config):
|
||||
# ssh -TO temp-api --local 8080:api:80 --via server
|
||||
|
||||
# Logging options:
|
||||
# - enabled: true/false - whether to show any logs
|
||||
# - level: debug (verbose), info (normal), warn (warnings only), error (errors only)
|
||||
# - format: console (human readable), json (structured)
|
||||
# Logs are written to stderr so they don't interfere with SSH output
|
||||
|
||||
# How it works:
|
||||
# 1. When you run: ssh desktop
|
||||
# 2. The utility pings 192.168.86.22 with a 2s timeout
|
||||
# 3. If ping succeeds: runs "ssh desktop-local" instead
|
||||
# 4. If ping fails: runs "ssh desktop" instead
|
||||
# 5. All other SSH usage (flags, user@host, etc.) passes through unchanged
|
||||
|
||||
# Your SSH config should contain the actual host definitions:
|
||||
# Host desktop
|
||||
# HostName mennos-desktop
|
||||
# User menno
|
||||
# Port 400
|
||||
# ForwardAgent yes
|
||||
# AddKeysToAgent yes
|
||||
#
|
||||
# Host desktop-local
|
||||
# HostName 192.168.86.22
|
||||
# User menno
|
||||
# Port 400
|
||||
# ForwardAgent yes
|
||||
# AddKeysToAgent yes
|
||||
20
ansible/tasks/global/utils/smart-ssh/go.mod
Normal file
20
ansible/tasks/global/utils/smart-ssh/go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module ssh-util
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/jedib0t/go-pretty/v6 v6.4.9
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
)
|
||||
46
ansible/tasks/global/utils/smart-ssh/go.sum
Normal file
46
ansible/tasks/global/utils/smart-ssh/go.sum
Normal file
@@ -0,0 +1,46 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jedib0t/go-pretty/v6 v6.4.9 h1:vZ6bjGg2eBSrJn365qlxGcaWu09Id+LHtrfDWlB2Usc=
|
||||
github.com/jedib0t/go-pretty/v6 v6.4.9/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
|
||||
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM=
|
||||
github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
1076
ansible/tasks/global/utils/smart-ssh/smart-ssh.go
Normal file
1076
ansible/tasks/global/utils/smart-ssh/smart-ssh.go
Normal file
File diff suppressed because it is too large
Load Diff
1155
ansible/tasks/global/utils/vm-device
Executable file
1155
ansible/tasks/global/utils/vm-device
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user