commit d0388111aeedebfc01eb0b56b5849100c35bcfbf Author: Leo Date: Wed Feb 25 21:45:21 2026 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97673a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +uptimetracker +uptimetracker.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5274943 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# uptimetracker + +A lightweight HTTP uptime monitor written in Go. Checks a list of URLs at a configurable interval, logs results, and sends Discord alerts on state changes. + +## Features + +- Checks multiple targets concurrently +- Alerts on DOWN and recovery via Discord webhook (fires once per state change, no spam) +- Logs to file and stdout simultaneously + +## Configuration + +Edit `config.json`: + +```json +{ + "interval_seconds": 30, + "discord_webhook": "https://discord.com/api/webhooks/...", + "log_file": "uptimetracker.log", + "targets": [ + { "name": "My Site", "url": "https://example.com" } + ] +} +``` + +| Field | Required | Description | +|---|---|---| +| `targets` | yes | List of sites to monitor | +| `interval_seconds` | no | Check frequency, defaults to 60 | +| `discord_webhook` | no | Discord webhook URL for alerts | +| `log_file` | no | Log file path, defaults to `uptimetracker.log` | + +## Usage + +```bash +go build -o uptimetracker . +./uptimetracker # uses config.json in current directory +./uptimetracker /path/to/config.json +``` + +Output: + +``` +2026-02-25 18:00:00 started monitoring 1 target(s) every 30s +2026-02-25 18:00:00 My Site UP 200 45ms +2026-02-25 18:00:30 My Site DOWN 503 120ms +2026-02-25 18:00:30 alert sent: My Site is DOWN +``` + +## Deploy with systemd + +Build a Linux binary: + +```bash +GOOS=linux GOARCH=amd64 go build -o uptimetracker . +``` + +Copy to your server: + +```bash +scp uptimetracker config.json uptimetracker.service user@your-vps:~/uptimetracker/ +``` + +Install and start the service: + +```bash +# Update User= and paths in the service file to match your username +sudo cp ~/uptimetracker/uptimetracker.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now uptimetracker +``` + +View logs: + +```bash +sudo journalctl -fu uptimetracker +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..f9c57f8 --- /dev/null +++ b/config.json @@ -0,0 +1,7 @@ +{ + "interval_seconds": 30, + "discord_webhook": "https://discord.com/api/webhooks/1476280778215264387/CCnXB03_MgwYUUKWQcw2PKFihny8UyDk4knqdPmnS85McjIFdokVW6MlkhBgLcv0yAZv", + "targets": [ + { "name": "OpenIssue", "url": "https://openissue.io" } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..12d9b17 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module uptimetracker + +go 1.24.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..c97cba7 --- /dev/null +++ b/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "text/tabwriter" + "time" +) + +type Target struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type Result struct { + Target Target + Up bool + StatusCode int + Latency time.Duration + CheckedAt time.Time + Err string +} + +type Config struct { + Targets []Target `json:"targets"` + Interval int `json:"interval_seconds"` + DiscordWebhook string `json:"discord_webhook"` + LogFile string `json:"log_file"` +} + +func check(t Target, timeout time.Duration) Result { + r := Result{Target: t, CheckedAt: time.Now()} + client := &http.Client{Timeout: timeout} + start := time.Now() + resp, err := client.Get(t.URL) + r.Latency = time.Since(start) + if err != nil { + r.Err = err.Error() + return r + } + defer resp.Body.Close() + r.StatusCode = resp.StatusCode + r.Up = resp.StatusCode >= 200 && resp.StatusCode < 400 + return r +} + +func sendDiscordAlert(webhookURL, message string) { + payload, _ := json.Marshal(map[string]string{"content": message}) + resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(payload)) + if err != nil { + fmt.Fprintf(os.Stderr, "discord alert failed: %v\n", err) + return + } + resp.Body.Close() +} + +func printResults(w io.Writer, results []Result) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, r := range results { + status := "DOWN" + if r.Up { + status = "UP" + } + code := "-" + if r.StatusCode != 0 { + code = fmt.Sprintf("%d", r.StatusCode) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + r.CheckedAt.Format("2006-01-02 15:04:05"), + r.Target.Name, + status, + code, + r.Latency.Round(time.Millisecond), + ) + } + tw.Flush() +} + +func loadConfig(path string) (Config, error) { + f, err := os.Open(path) + if err != nil { + return Config{}, err + } + defer f.Close() + var cfg Config + if err := json.NewDecoder(f).Decode(&cfg); err != nil { + return Config{}, err + } + if cfg.Interval <= 0 { + cfg.Interval = 60 + } + return cfg, nil +} + +func runOnce(cfg Config, prevState map[string]bool, out io.Writer) { + results := make([]Result, len(cfg.Targets)) + done := make(chan struct{}, len(cfg.Targets)) + for i, t := range cfg.Targets { + go func(i int, t Target) { + results[i] = check(t, 10*time.Second) + done <- struct{}{} + }(i, t) + } + for range cfg.Targets { + <-done + } + + printResults(out, results) + + if cfg.DiscordWebhook != "" { + for _, r := range results { + wasUp, seen := prevState[r.Target.URL] + if !r.Up && (!seen || wasUp) { + msg := fmt.Sprintf("@here **%s** is DOWN (`%s`)", r.Target.Name, r.Target.URL) + if r.Err != "" { + msg += fmt.Sprintf("\nError: %s", r.Err) + } else { + msg += fmt.Sprintf("\nHTTP %d", r.StatusCode) + } + fmt.Fprintf(out, "%s\talert sent: %s is DOWN\n", + r.CheckedAt.Format("2006-01-02 15:04:05"), r.Target.Name) + sendDiscordAlert(cfg.DiscordWebhook, msg) + } else if r.Up && seen && !wasUp { + msg := fmt.Sprintf("**%s** is back UP (`%s`)", r.Target.Name, r.Target.URL) + fmt.Fprintf(out, "%s\talert sent: %s is back UP\n", + r.CheckedAt.Format("2006-01-02 15:04:05"), r.Target.Name) + sendDiscordAlert(cfg.DiscordWebhook, msg) + } + prevState[r.Target.URL] = r.Up + } + } +} + +func main() { + cfgPath := "config.json" + if len(os.Args) > 1 { + cfgPath = os.Args[1] + } + + cfg, err := loadConfig(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) + os.Exit(1) + } + + logPath := cfg.LogFile + if logPath == "" { + logPath = "uptimetracker.log" + } + lf, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening log file: %v\n", err) + os.Exit(1) + } + defer lf.Close() + + out := io.MultiWriter(os.Stdout, lf) + + fmt.Fprintf(out, "%s\tstarted monitoring %d target(s) every %ds\n", + time.Now().Format("2006-01-02 15:04:05"), len(cfg.Targets), cfg.Interval) + + prevState := make(map[string]bool) + runOnce(cfg, prevState, out) + + ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) + defer ticker.Stop() + for range ticker.C { + runOnce(cfg, prevState, out) + } +} diff --git a/uptimetracker.service b/uptimetracker.service new file mode 100644 index 0000000..3a452a6 --- /dev/null +++ b/uptimetracker.service @@ -0,0 +1,12 @@ +[Unit] +Description=Uptime Tracker +After=network.target + +[Service] +ExecStart=/home/user/uptimetracker/uptimetracker /home/user/uptimetracker/config.json +WorkingDirectory=/home/user/uptimetracker +Restart=always +User=user + +[Install] +WantedBy=multi-user.target