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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions internal/cli/notify/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package notify

import (
"context"
"net/url"

"github.com/GaIsBAX/Webhix/internal/cli/notify/telegram"
"github.com/GaIsBAX/Webhix/internal/config"
"github.com/spf13/cobra"
)

type notificationChannel struct {
Provider string `json:"provider"`
Config map[string]string `json:"config"`
}

func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command {
opts := DefaultOptions()
if cfg.SecretKey != "" {
opts.AuthToken = cfg.SecretKey
}

cmd := &cobra.Command{
Use: "notify",
Short: "Manage endpoint notifications",
}

RegisterFlags(cmd, &opts)

cmd.AddCommand(newListCmd(ctx, &opts))
cmd.AddCommand(telegram.NewCommand(ctx, &opts.Server, &opts.AuthToken))

return cmd
}

func newListCmd(ctx context.Context, opts *Options) *cobra.Command {
return &cobra.Command{
Use: "list <token>",
Short: "List all configured notification channels",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var channels []notificationChannel
if err := apiGet(ctx, opts, "/api/endpoints/"+url.PathEscape(args[0])+"/notifications", &channels); err != nil {
return err
}

if len(channels) == 0 {
cmd.Println("No notifications configured.")
return nil
}

for _, ch := range channels {
cmd.Printf("Provider: %s\n", ch.Provider)
for k, v := range ch.Config {
if k == "bot_token" {
v = maskToken(v)
}
cmd.Printf(" %s: %s\n", k, v)
}
}
return nil
},
}
}
13 changes: 13 additions & 0 deletions internal/cli/notify/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package notify

import "github.com/spf13/cobra"

const (
flagServer = "server"
flagAuthToken = "auth-token"
)

func RegisterFlags(cmd *cobra.Command, opt *Options) {
cmd.PersistentFlags().StringVar(&opt.Server, flagServer, opt.Server, "Webhix server URL")
cmd.PersistentFlags().StringVar(&opt.AuthToken, flagAuthToken, opt.AuthToken, "auth token (env: WEBHIX_SECRET_KEY)")
}
82 changes: 82 additions & 0 deletions internal/cli/notify/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package notify

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
)

type Options struct {
Server string
AuthToken string
}

type apiResponse struct {
Success bool `json:"success"`
Body json.RawMessage `json:"body"`
Error *apiError `json:"error"`
}

type apiError struct {
Message string `json:"message"`
}

var httpClient = &http.Client{Timeout: 30 * time.Second}

func DefaultOptions() Options {
return Options{
Server: "http://localhost:8080",
}
}

func (o *Options) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, o.Server+path, body)
if err != nil {
return nil, err
}
if o.AuthToken != "" {
req.Header.Set("Authorization", "Bearer "+o.AuthToken)
}
return req, nil
}

func apiGet(ctx context.Context, opts *Options, path string, out any) error {
req, err := opts.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return err
}

resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
slog.Warn("close response body", "err", err)
}
}()

var ar apiResponse
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
return err
}
if !ar.Success {
if ar.Error != nil {
return errors.New(ar.Error.Message)
}
return fmt.Errorf("server returned %d", resp.StatusCode)
}
return json.Unmarshal(ar.Body, out)
}

func maskToken(token string) string {
if len(token) <= 14 {
return "***"
}
return token[:4] + "..." + token[len(token)-4:]
}
93 changes: 93 additions & 0 deletions internal/cli/notify/telegram/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package telegram

import (
"context"
"net/http"
"net/url"

"github.com/spf13/cobra"
)

func NewCommand(ctx context.Context, server, authToken *string) *cobra.Command {
cmd := &cobra.Command{
Use: "telegram",
Short: "Manage Telegram notifications",
}

cmd.AddCommand(newSetCmd(ctx, server, authToken))
cmd.AddCommand(newTestCmd(ctx, server, authToken))
cmd.AddCommand(newRemoveCmd(ctx, server, authToken))

return cmd
}

func newSetCmd(ctx context.Context, server, authToken *string) *cobra.Command {
opts := Options{}

cmd := &cobra.Command{
Use: "set <token>",
Short: "Configure Telegram notifications for an endpoint",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg := map[string]string{"bot_token": opts.BotToken, "chat_id": opts.ChatID}
if opts.ProxyURL != "" {
cfg["proxy_url"] = opts.ProxyURL
}

body := map[string]any{"provider": "telegram", "config": cfg}
path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram"
if err := do(ctx, http.MethodPut, path, *authToken, body); err != nil {
return err
}

cmd.Println("Telegram notifications configured.")
return nil
},
}

RegisterFlags(cmd, &opts)
must(cmd.MarkFlagRequired(flagBotToken))
must(cmd.MarkFlagRequired(flagChatID))

return cmd
}

func newTestCmd(ctx context.Context, server, authToken *string) *cobra.Command {
return &cobra.Command{
Use: "test <token>",
Short: "Send a test Telegram message",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram/test"
if err := do(ctx, http.MethodPost, path, *authToken, nil); err != nil {
return err
}

cmd.Println("Test message sent.")
return nil
},
}
}

func newRemoveCmd(ctx context.Context, server, authToken *string) *cobra.Command {
return &cobra.Command{
Use: "remove <token>",
Short: "Remove Telegram notifications from an endpoint",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram"
if err := do(ctx, http.MethodDelete, path, *authToken, nil); err != nil {
return err
}

cmd.Println("Telegram notifications removed.")
return nil
},
}
}

func must(err error) {
if err != nil {
panic(err)
}
}
15 changes: 15 additions & 0 deletions internal/cli/notify/telegram/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package telegram

import "github.com/spf13/cobra"

const (
flagBotToken = "bot-token"
flagChatID = "chat"
flagProxyURL = "proxy"
)

func RegisterFlags(cmd *cobra.Command, opt *Options) {
cmd.Flags().StringVar(&opt.BotToken, flagBotToken, opt.BotToken, "Telegram bot token")
cmd.Flags().StringVar(&opt.ChatID, flagChatID, opt.ChatID, "Telegram chat ID")
cmd.Flags().StringVar(&opt.ProxyURL, flagProxyURL, opt.ProxyURL, "Proxy URL (e.g. socks5://127.0.0.1:1080)")
}
67 changes: 67 additions & 0 deletions internal/cli/notify/telegram/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package telegram

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
)

type Options struct {
BotToken string
ChatID string
ProxyURL string
}

var httpClient = &http.Client{Timeout: 30 * time.Second}

func do(ctx context.Context, method, url, authToken string, body any) error {
var r io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
r = bytes.NewReader(data)
}

req, err := http.NewRequestWithContext(ctx, method, url, r)
if err != nil {
return err
}
if authToken != "" {
req.Header.Set("Authorization", "Bearer "+authToken)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}

resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
slog.Warn("close response body", "err", err)
}
}()

if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}

var ar struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if json.NewDecoder(resp.Body).Decode(&ar) == nil && ar.Error != nil {
return errors.New(ar.Error.Message)
}
return fmt.Errorf("server returned %d", resp.StatusCode)
}
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"github.com/GaIsBAX/Webhix/internal/cli/forward"
"github.com/GaIsBAX/Webhix/internal/cli/notify"
"github.com/GaIsBAX/Webhix/internal/cli/serve"
"github.com/GaIsBAX/Webhix/internal/cli/tunnel"
"github.com/GaIsBAX/Webhix/internal/cli/version"
Expand All @@ -29,6 +30,7 @@ func NewRootCommand(

cmd.AddCommand(serve.NewCommand(ctx, cfg, serveFactory))
cmd.AddCommand(forward.NewCommand(ctx, cfg))
cmd.AddCommand(notify.NewCommand(ctx, cfg))
cmd.AddCommand(tunnel.NewCommand(ctx, cfg))
cmd.AddCommand(version.NewCommand(ctx))

Expand Down
Loading