All checks were successful
release-tag / release-image (push) Successful in 1m46s
306 lines
9.0 KiB
Go
306 lines
9.0 KiB
Go
package ntfy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
Bin string
|
|
Config string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type User struct {
|
|
Username string
|
|
Role string
|
|
Tier string
|
|
Access []AccessEntry
|
|
}
|
|
|
|
type AccessEntry struct {
|
|
Topic string
|
|
Perm string // read-write, read-only, write-only, deny, deny-all? etc.
|
|
}
|
|
|
|
type Token struct {
|
|
Token string
|
|
Label string
|
|
Expiry string
|
|
}
|
|
|
|
var (
|
|
reUserLine = regexp.MustCompile(`^user\s+(\S+)\s+\(role:\s*([^,]+),\s*tier:\s*([^)]+)\)`)
|
|
reAccessLine = regexp.MustCompile(`^\s*-\s+(.+?)\s+access\s+to\s+topic\s+(.+)$`) // e.g. "- read-only access to topic test"
|
|
)
|
|
|
|
// Run executes `ntfy ...` with --config.
|
|
func (c *Client) Run(ctx context.Context, args []string, env map[string]string, stdin string) (string, string, int, error) {
|
|
if c.Bin == "" {
|
|
return "", "", 0, errors.New("ntfy binary not set")
|
|
}
|
|
|
|
// Many (newer) ntfy versions support `--config/-c` for server-side commands
|
|
// (serve/user/access/token). Some older builds do not. We try with --config
|
|
// first (if configured) and fall back to running without it if the binary
|
|
// rejects the flag.
|
|
withConfig := args
|
|
if c.Config != "" {
|
|
withConfig = append([]string{"--config", c.Config}, args...)
|
|
}
|
|
|
|
tctx := ctx
|
|
var cancel context.CancelFunc
|
|
if c.Timeout > 0 {
|
|
tctx, cancel = context.WithTimeout(ctx, c.Timeout)
|
|
defer cancel()
|
|
}
|
|
// helper to execute once
|
|
runOnce := func(a []string) (string, string, int, error) {
|
|
cmd := exec.CommandContext(tctx, c.Bin, a...)
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
var outb, errb bytes.Buffer
|
|
cmd.Stdout = &outb
|
|
cmd.Stderr = &errb
|
|
if env != nil {
|
|
// inherit env + add/override provided vars
|
|
cmd.Env = append([]string{}, os.Environ()...)
|
|
for k, v := range env {
|
|
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
}
|
|
err := cmd.Run()
|
|
exit := 0
|
|
if err != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) {
|
|
exit = ee.ExitCode()
|
|
} else if errors.Is(err, context.DeadlineExceeded) {
|
|
return outb.String(), errb.String(), -1, fmt.Errorf("ntfy timeout")
|
|
} else {
|
|
return outb.String(), errb.String(), -1, err
|
|
}
|
|
}
|
|
return outb.String(), errb.String(), exit, nil
|
|
}
|
|
|
|
// first attempt (maybe with --config)
|
|
out, errOut, exit, err := runOnce(withConfig)
|
|
if c.Config == "" {
|
|
return out, errOut, exit, err
|
|
}
|
|
// fallback for older ntfy binaries that don't know --config
|
|
if exit != 0 {
|
|
errTrim := strings.TrimSpace(errOut)
|
|
if strings.Contains(errTrim, "flag provided but not defined: -config") ||
|
|
strings.Contains(errTrim, "unknown flag") && strings.Contains(errTrim, "config") {
|
|
return runOnce(args)
|
|
}
|
|
}
|
|
return out, errOut, exit, err
|
|
}
|
|
|
|
func (c *Client) ListUsers(ctx context.Context) ([]User, error) {
|
|
out, errOut, exit, err := c.Run(ctx, []string{"user", "list"}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return nil, fmt.Errorf("ntfy user list failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return parseUsers(out), nil
|
|
}
|
|
|
|
func parseUsers(out string) []User {
|
|
lines := strings.Split(out, "\n")
|
|
var users []User
|
|
var cur *User
|
|
for _, ln := range lines {
|
|
ln = strings.TrimRight(ln, "\r")
|
|
if m := reUserLine.FindStringSubmatch(ln); m != nil {
|
|
u := User{
|
|
Username: m[1],
|
|
Role: strings.TrimSpace(m[2]),
|
|
Tier: strings.TrimSpace(m[3]),
|
|
}
|
|
users = append(users, u)
|
|
cur = &users[len(users)-1]
|
|
continue
|
|
}
|
|
if cur != nil {
|
|
if strings.HasPrefix(strings.TrimSpace(ln), "-") {
|
|
// Try parse access entry line
|
|
// Example: "- read-only access to topic test"
|
|
// We also accept: "- read-write access to all topics (admin role)" -> store as Topic="*"
|
|
if strings.Contains(ln, "access to all topics") {
|
|
cur.Access = append(cur.Access, AccessEntry{Topic: "*", Perm: "read-write"})
|
|
continue
|
|
}
|
|
m := reAccessLine.FindStringSubmatch(ln)
|
|
if m != nil {
|
|
perm := strings.TrimSpace(m[1])
|
|
topic := strings.TrimSpace(m[2])
|
|
cur.Access = append(cur.Access, AccessEntry{Topic: topic, Perm: perm})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return users
|
|
}
|
|
|
|
func (c *Client) AddUser(ctx context.Context, username, role, tier, password string) error {
|
|
args := []string{"user", "add"}
|
|
if role != "" {
|
|
args = append(args, "--role="+role)
|
|
}
|
|
if tier != "" && tier != "none" {
|
|
args = append(args, "--tier="+tier)
|
|
}
|
|
args = append(args, username)
|
|
env := map[string]string{}
|
|
if password != "" {
|
|
env["NTFY_PASSWORD"] = password
|
|
}
|
|
_, errOut, exit, err := c.Run(ctx, args, env, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy user add failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) DelUser(ctx context.Context, username string) error {
|
|
_, errOut, exit, err := c.Run(ctx, []string{"user", "del", username}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy user del failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ChangePass(ctx context.Context, username, password string) error {
|
|
env := map[string]string{"NTFY_PASSWORD": password}
|
|
_, errOut, exit, err := c.Run(ctx, []string{"user", "change-pass", username}, env, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy user change-pass failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ChangeRole(ctx context.Context, username, role string) error {
|
|
_, errOut, exit, err := c.Run(ctx, []string{"user", "change-role", username, role}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy user change-role failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ChangeTier(ctx context.Context, username, tier string) error {
|
|
_, errOut, exit, err := c.Run(ctx, []string{"user", "change-tier", username, tier}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy user change-tier failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GrantAccess(ctx context.Context, username, topic, perm string) error {
|
|
_, errOut, exit, err := c.Run(ctx, []string{"access", username, topic, perm}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy access failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ResetAccess(ctx context.Context, username string) error {
|
|
_, errOut, exit, err := c.Run(ctx, []string{"access", "--reset", username}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy access --reset failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) TokenList(ctx context.Context, username string) ([]Token, error) {
|
|
out, errOut, exit, err := c.Run(ctx, []string{"token", "list", username}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return nil, fmt.Errorf("ntfy token list failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return parseTokens(out), nil
|
|
}
|
|
|
|
func (c *Client) TokenAdd(ctx context.Context, username, label, expires string) (string, error) {
|
|
args := []string{"token", "add"}
|
|
if expires != "" {
|
|
args = append(args, "--expires="+expires)
|
|
}
|
|
if label != "" {
|
|
args = append(args, "--label="+label)
|
|
}
|
|
args = append(args, username)
|
|
out, errOut, exit, err := c.Run(ctx, args, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return "", fmt.Errorf("ntfy token add failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
// output contains token, usually like: "token tk_xxx added for user xyz"
|
|
tok := extractToken(out)
|
|
if tok == "" {
|
|
// sometimes printed on stderr; try there
|
|
tok = extractToken(errOut)
|
|
}
|
|
if tok == "" {
|
|
return "", fmt.Errorf("token added but could not parse token from output")
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
func (c *Client) TokenRemove(ctx context.Context, username, token string) error {
|
|
_, errOut, exit, err := c.Run(ctx, []string{"token", "remove", username, token}, nil, "")
|
|
if err != nil || exit != 0 {
|
|
return fmt.Errorf("ntfy token remove failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var reToken = regexp.MustCompile(`\b(tk_[A-Za-z0-9]+)\b`)
|
|
|
|
func extractToken(s string) string {
|
|
m := reToken.FindStringSubmatch(s)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
func parseTokens(out string) []Token {
|
|
lines := strings.Split(out, "\n")
|
|
var toks []Token
|
|
// token list output varies; do best-effort parse: each line containing tk_...
|
|
for _, ln := range lines {
|
|
ln = strings.TrimSpace(strings.TrimRight(ln, "\r"))
|
|
if ln == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(ln, "tk_") {
|
|
continue
|
|
}
|
|
// naive: first token is token; remainder may have label/expiry
|
|
m := reToken.FindStringSubmatch(ln)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
t := Token{Token: m[1]}
|
|
rest := strings.TrimSpace(strings.Replace(ln, t.Token, "", 1))
|
|
// attempt label=... exp=...
|
|
if strings.Contains(rest, "label:") {
|
|
if i := strings.Index(rest, "label:"); i >= 0 {
|
|
t.Label = strings.TrimSpace(rest[i+6:])
|
|
}
|
|
}
|
|
toks = append(toks, t)
|
|
}
|
|
return toks
|
|
}
|