1
0
Fork 0

Compare commits

..

No commits in common. "master" and "2.0.1" have entirely different histories.

15 changed files with 369 additions and 148 deletions

4
.gitignore vendored
View File

@ -1,4 +1,4 @@
.vscode
*.json
!config.example.json
*.conf
!config.example.conf
httpprocprobed

View File

@ -1,4 +1,4 @@
Copyright (c) 2021-2024 Alexander "Arav" Andreev <me@arav.su>
Copyright (c) 2021,2022 Alexander "Arav" Andreev <me@arav.top>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -2,32 +2,27 @@ TARGET=httpprocprobed
SYSDDIR_=${shell pkg-config systemd --variable=systemdsystemunitdir}
SYSDDIR=${SYSDDIR_:/%=%}
DESTDIR=/
DESTDIR:=
PREFIX:=/usr/local
VERSION:=3.1.1
FLAGS:=-buildmode=pie -modcacherw -mod=readonly -trimpath
LDFLAGS=-ldflags "-s -w -X main.version=${VERSION}" -tags netgo
LDFLAGS=-ldflags "-s -w" -tags netgo
SOURCES := ${wildcard *.go}
.PHONY: install uninstall clean
all: ${TARGET}
${TARGET}: ${SOURCES}
go build ${LDFLAGS} ${FLAGS}
go build ${LDFLAGS}
install:
install -Dm 0755 ${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
install -Dm 0644 configs/config.example.json ${DESTDIR}/etc/${TARGET}.json
install -Dm 0644 LICENSE ${DESTDIR}${PREFIX}/share/licenses/${TARGET}/LICENSE
install -Dm 0755 ${TARGET} ${DESTDIR}usr/bin/${TARGET}
install -Dm 0644 configs/config.example.conf ${DESTDIR}etc/${TARGET}.conf
install -Dm 0644 LICENSE ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
install -Dm 0644 init/systemd.service ${DESTDIR}${SYSDDIR}/${TARGET}.service
uninstall:
rm ${DESTDIR}${PREFIX}/bin/${TARGET}
rm ${DESTDIR}${PREFIX}/share/licenses/${TARGET}
rm ${DESTDIR}usr/bin/${TARGET}
rm ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
rm ${DESTDIR}${SYSDDIR}/${TARGET}.service
clean:
rm httpprocprobed
go clean

46
README.md Normal file
View File

@ -0,0 +1,46 @@
# httpprocprobed Ver 2.0.1
License: MIT+NIGGER.
This utility provides a HTTP `/processes` GET endpoint that returns a list of
processes, and if they are currently running or not.
There are currently three output formats available: JSON, XML, and plain text.
JSON is a default format if `Accept` header didn't provided, or did with
value `application/json`. Its form is `{"process":true|false, ...}`.
XML is provided if `Accept: application/xml` header was given. Its form is
`<ProcessList><Process name="process">true|false</Process>...</ProcessList>`.
Plain text is provided if `Accept: text/plain` header was given. Its form is a
comma separated list of ONLY running process' names.
Configuration file is a simple `key = value` storage consisting of
`listen_address` string field in form `"[<ip|host>]:<port>"`. `indented_output`
boolean in form `true|false`, to enable indentation of JSON and XML output.
And `processes` is a space separated array of process names.
## Installation
### Manually
Run these commands one after the other.
```console
$ make
$ make install
```
In order to uninstall run these commands:
```console
# systemctl stop httpprocprobed
# systemctl disable httpprocprobed
$ make uninstall
# systemctl daemon-reload
```
### For ArchLinux
You can take a [PKGBUILD](/Arav/httpprocprobed/raw/branch/master/build/archlinux/PKGBUILD) file and in a directory with it run `makepkg -i`.

View File

@ -1,27 +0,0 @@
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
pkgname=httpprocprobed
pkgver=3.1.1
pkgrel=1
pkgdesc="HTTPProcProbeD hands out an HTTP endpoint to get if processes are running."
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
url="https://git.arav.su/Arav/httpprocprobed"
license=('MIT')
makedepends=('go>=1.17')
backup=('etc/httpprocprobed.json')
source=("https://git.arav.su/Arav/httpprocprobed/archive/$pkgver.tar.gz")
md5sums=('SKIP')
build() {
cd "$srcdir/$pkgname"
export GOPATH="$srcdir"/gopath
export CGO_CPPFLAGS="${CPPFLAGS}"
export CGO_CFLAGS="${CFLAGS}"
export CGO_CXXFLAGS="${CXXFLAGS}"
export CGO_LDFLAGS="${LDFLAGS}"
make VERSION=$pkgver DESTDIR="$pkgdir/" PREFIX="/usr"
}
package() {
cd "$srcdir/$pkgname"
make DESTDIR="$pkgdir/" PREFIX="/usr" install
}

30
build/archlinux/PKGBUILD Normal file
View File

@ -0,0 +1,30 @@
# Maintainer: Alexander "Arav" Andreev <me@arav.top>
pkgname=httpprocprobed
pkgver=2.0.1
pkgrel=1
pkgdesc="HTTPProcProbeD hands out an HTTP endpoint to get if processes are running."
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
url="https://git.arav.top/Arav/httpprocprobed"
license=('MIT')
groups=()
depends=()
makedepends=('go')
provides=('httpprocprobed')
conflicts=('httpprocprobed')
replaces=()
backup=('etc/httpprocprobed.conf')
options=()
install=
source=('https://git.arav.top/Arav/httpprocprobed/archive/2.0.1.tar.gz')
noextract=()
md5sums=('SKIP')
build() {
cd "$srcdir/$pkgname"
make DESTDIR="$pkgdir/"
}
package() {
cd "$srcdir/$pkgname"
make DESTDIR="$pkgdir/" install
}

View File

@ -0,0 +1,3 @@
listen_address = :28010
indented_output = false
processes =

View File

@ -1,6 +0,0 @@
{
"listen-address": ":28010",
"processes": [
{"alias": "minecraft", "process": "fabric-server-mc"}
]
}

98
configuration.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"bufio"
"errors"
"log"
"os"
"strconv"
"strings"
)
// Configuration holds a configuration for the service.
type Configuration struct {
ListenAddress string
IndentedOutput bool
Processes []string
}
// LoadConfiguration loads configuration from a file.
func LoadConfiguration(path string) (conf *Configuration, err error) {
conf = &Configuration{}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
s := bufio.NewScanner(file)
s.Split(bufio.ScanLines)
for s.Scan() {
kv := strings.Split(s.Text(), " = ")
switch kv[0] {
case "listen_address":
conf.ListenAddress = kv[1]
case "indented_output":
v, err := strconv.ParseBool(kv[1])
if err != nil {
log.Printf("[WARN] could not parse \"indented_output\", valid values are true or false. Defaulted to false.\n")
}
conf.IndentedOutput = v
case "processes":
if kv[1] != "" {
conf.Processes = append(conf.Processes, strings.Split(kv[1], " ")...)
} else {
log.Printf("[WARN] \"processes\" list is empty.\n")
}
}
}
return conf, nil
}
// StoreConfiguration writes configuration into a file.
func (conf *Configuration) StoreConfiguration(path string) (err error) {
var config strings.Builder
config.WriteString("listen_address = ")
config.WriteString(conf.ListenAddress)
config.WriteByte('\n')
config.WriteString("indented_output = ")
config.WriteString(strconv.FormatBool(conf.IndentedOutput))
config.WriteByte('\n')
config.WriteString("processes = ")
config.WriteString(strings.Join(conf.Processes, " "))
if err := os.WriteFile(path, []byte(config.String()), 0644); err != nil {
return err
}
return nil
}
// AddProcess appends a new given process into a configuration file.
func (conf *Configuration) AddProcess(process string, configPath string) error {
for _, v := range conf.Processes {
if v == process {
return errors.New("process is already on list")
}
}
conf.Processes = append(conf.Processes, process)
return nil
}
// RemoveProcess removes a given process from a configuration file.
func (conf *Configuration) RemoveProcess(process string, configPath string) error {
for k, v := range conf.Processes {
if v == process {
newlist := make([]string, len(conf.Processes)-1)
newlist = append(conf.Processes[:k], conf.Processes[k+1:]...)
conf.Processes = newlist
return nil
}
}
return errors.New("process is not on list")
}

2
go.mod
View File

@ -1,3 +1,3 @@
module httpprocprobed
go 1.17
go 1.20

86
httpserver.go Normal file
View File

@ -0,0 +1,86 @@
package main
import (
"context"
"encoding/json"
"encoding/xml"
"log"
"net/http"
"strings"
"time"
)
func CreateAndStartHTTPServer(conf *Configuration) *http.Server {
router := http.NewServeMux()
router.HandleFunc("/processes", AreProcessesUp(&conf.Processes, conf.IndentedOutput))
srv := &http.Server{
Addr: conf.ListenAddress,
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 10 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("ListenAndServe: %s\n", err)
}
}()
return srv
}
func ShutdownHTTPServer(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.SetKeepAlivesEnabled(false)
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("%s\n", err)
}
}
// AreProcessesUp handles a GET /processes request and sends back status of given
// processes.
func AreProcessesUp(processes *[]string, indented bool) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
proclist := make(ProcessList)
for _, proc := range *processes {
pids, err := GetProcessPIDs(proc)
proclist[proc] = err == nil && len(pids) > 0
}
switch r.Header.Get("Accept") {
case "application/xml":
w.Header().Add("Content-Type", "application/xml")
enc := xml.NewEncoder(w)
if indented {
enc.Indent("", "\t")
}
enc.Encode(proclist)
case "text/plain":
w.Header().Add("Content-Type", "text/plain")
var s []string
for k, v := range proclist {
if v {
s = append(s, k)
}
}
w.Write([]byte(strings.Join(s, ",")))
default:
w.Header().Add("Content-Type", "application/json")
enc := json.NewEncoder(w)
if indented {
enc.SetIndent("", "\t")
}
enc.Encode(proclist)
}
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Header().Add("Allow", "GET")
}
}
}

View File

@ -1,12 +1,12 @@
[Unit]
Description=HTTP Process Prober Daemon
Description=HTTPProcProbeD
After=network.target
[Service]
Type=simple
DynamicUser=yes
Restart=on-failure
ExecStart=/usr/bin/httpprocprobed -c /etc/httpprocprobed.json
ExecStart=/usr/bin/httpprocprobed -c /etc/httpprocprobed.conf
ExecReload=kill -HUP $MAINPID
ReadOnlyPaths=/

149
main.go
View File

@ -1,141 +1,106 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
var configPath *string = flag.String("c", "config.json", "path to configuration file")
var configPath *string = flag.String("c", "config.conf", "path to configuration file")
var showVersion *bool = flag.Bool("v", false, "show version")
var listProcesses *bool = flag.Bool("l", false, "list watched processes")
var version string
// Process contains an alias that will be returned when queried, and a process
// name to look for.
// A process is effectively a substring that is being looked in a cmdline file
// in /proc/ dir on unix-like systems.
type Process struct {
Alias string `json:"alias"`
Process string `json:"process"`
}
// ProcessesState is a map of processes' aliases and its statuses.
type ProcessesState map[string]bool
func GetProcessesState(procs *[]Process) (ps ProcessesState) {
ps = make(ProcessesState)
for _, proc := range *procs {
pids, err := GetProcessPIDs(proc.Process)
ps[proc.Alias] = err == nil && len(pids) > 0
}
return
}
type Configuration struct {
ListenAddress string `json:"listen-address"`
Processes []Process `json:"processes"`
}
func LoadConfiguration(path string) (conf *Configuration, err error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
conf = &Configuration{}
if err := json.NewDecoder(f).Decode(conf); err != nil {
return nil, err
}
for i := 0; i < len(conf.Processes); i++ {
if conf.Processes[i].Process == "" {
return nil, fmt.Errorf("an empty process field found")
}
if conf.Processes[i].Alias == "" {
conf.Processes[i].Alias = conf.Processes[i].Process
}
}
return conf, nil
}
var addProcess *string = flag.String("a", "", "add process to list")
var removeProcess *string = flag.String("r", "", "remove process from list")
func main() {
log.SetFlags(0)
flag.Parse()
if *showVersion {
fmt.Fprintln(os.Stderr, "httpprocprobed ver.", version, "\nCopyright (c) 2021-2024 Alexander \"Arav\" Andreev <me@arav.su>")
fmt.Println("httpprocprobed ver. 2.0.1")
fmt.Println("Copyright (c) 2021-2023 Alexander \"Arav\" Andreev <me@arav.top>")
fmt.Println("This program is licensed under terms of MIT+NIGGER license.")
os.Exit(0)
}
conf, err := LoadConfiguration(*configPath)
if err != nil {
log.Fatalf("Cannot load configuration file: %s\n", err)
log.Fatalf("[ERR] Cannot load configuration file: %s\n", err)
}
router := http.NewServeMux()
router.HandleFunc("/processes", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Header().Add("Allow", "GET")
return
if *listProcesses {
for _, v := range conf.Processes {
fmt.Printf("%s, ", v)
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(GetProcessesState(&conf.Processes)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("Failed to encode a process list: %s\n", err)
}
})
srv := &http.Server{
Addr: conf.ListenAddress,
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 10 * time.Second,
fmt.Println()
os.Exit(0)
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("ListenAndServe: %s\n", err)
if *addProcess != "" {
err := conf.AddProcess(*addProcess, *configPath)
if err != nil {
log.Fatalf("[ERR] Cannot add process: %s\n", err)
}
}()
defer func() {
srv.SetKeepAlivesEnabled(false)
if err := srv.Shutdown(context.Background()); err != nil {
log.Fatalf("%s\n", err)
}
if *removeProcess != "" {
err := conf.RemoveProcess(*removeProcess, *configPath)
if err != nil {
log.Fatalf("[ERR] Cannot remove process: %s\n", err)
}
}()
}
// If we modified a list then let's look for a running program and
// send SIGHUP to it to reload a list. Here we assume that there
// is only one process running, so we just filter our PID.
if *addProcess != "" || *removeProcess != "" {
if err := conf.StoreConfiguration(*configPath); err != nil {
log.Fatalf("[ERR] Cannot write configuration. Error: %s\n", err)
}
pids, _ := GetProcessPIDs("httpprocprobed")
if len(pids) > 1 {
var trgt_pid int
if pids[0] == os.Getpid() {
trgt_pid = pids[1]
} else {
trgt_pid = pids[0]
}
if proc, err := os.FindProcess(trgt_pid); err == nil {
proc.Signal(syscall.SIGHUP)
}
}
os.Exit(0)
}
srv := CreateAndStartHTTPServer(conf)
syssignal := make(chan os.Signal, 1)
signal.Notify(syssignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
log.Printf("httpprocprobed is running on \"%s\".", conf.ListenAddress)
for {
switch <-syssignal {
case os.Interrupt:
fallthrough
case syscall.SIGINT | syscall.SIGTERM:
return
ShutdownHTTPServer(srv)
log.Println("Server shutted down.")
os.Exit(0)
case syscall.SIGHUP:
newconf, err := LoadConfiguration(*configPath)
if err != nil {
log.Fatalf("Failed to reload a list of processes from configuration: %s\n", err)
}
conf.Processes = newconf.Processes
log.Println("Successfully reloaded a list of watched processes.")
}
}
}

View File

@ -1,5 +1,4 @@
//go:build unix
// +build unix
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd
package main

32
processlist.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"encoding/xml"
)
// ProcessList is a map of processes' names and its statuses.
type ProcessList map[string]bool
// MarshalXML implements XML Marshaler interface for a ProcessList.
func (l *ProcessList) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if len(*l) == 0 {
return nil
}
if err := e.EncodeToken(start); err != nil {
return err
}
for key, val := range *l {
e.Encode(struct {
XMLName xml.Name
Name string `xml:"name,attr"`
IsUp bool `xml:",chardata"`
}{
XMLName: xml.Name{Local: "Process"},
Name: key,
IsUp: val})
}
return e.EncodeToken(start.End())
}