Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
21ac3bc
preview
eternal-flame-AD Aug 10, 2025
5abcf5d
plugin connection auth/security
eternal-flame-AD Aug 12, 2025
8a89a6b
temp
eternal-flame-AD Aug 15, 2025
e6fc00f
move ServerMux to server repo
eternal-flame-AD Aug 15, 2025
93c24e8
wip: swap to single connection model
eternal-flame-AD Aug 16, 2025
b5e9886
shim impl
eternal-flame-AD Aug 24, 2025
f59c2eb
userid overflow checks
eternal-flame-AD Aug 24, 2025
1d055c1
use SNI to mux webhooker
eternal-flame-AD Aug 24, 2025
9863b12
fixup! use SNI to mux webhooker
eternal-flame-AD Aug 24, 2025
4493373
fixup! fixup! use SNI to mux webhooker
eternal-flame-AD Aug 24, 2025
bd59ee3
v1 shim
eternal-flame-AD Aug 24, 2025
f237b5a
fixup userid conversion
eternal-flame-AD Aug 24, 2025
ee70ba4
rearrange code order
eternal-flame-AD Aug 24, 2025
4cf2383
protobuf comments
eternal-flame-AD Aug 24, 2025
d6c86da
go mod tidy
eternal-flame-AD Aug 24, 2025
4e13724
more protobuf docs
eternal-flame-AD Aug 24, 2025
1a69730
example tests
eternal-flame-AD Aug 25, 2025
b922133
always test TCP implementation
eternal-flame-AD Aug 25, 2025
c55cc24
create transport package
eternal-flame-AD Aug 25, 2025
5555f78
add basic pipe test
eternal-flame-AD Aug 25, 2025
caadabd
check stream errors in shim
eternal-flame-AD Sep 2, 2025
582f2a1
change test workflow branch
eternal-flame-AD Sep 2, 2025
8b7a780
suggestions in shim_v1.go
eternal-flame-AD Sep 2, 2025
c09e0d3
Upgrade to yaml.v3
eternal-flame-AD Sep 2, 2025
12f5aeb
test typo correction
eternal-flame-AD Sep 2, 2025
eabaf46
hoist PEM reading function
eternal-flame-AD Sep 2, 2025
60a3ba0
cli_flags suggestions
eternal-flame-AD Sep 2, 2025
bdd4117
try to fix pipe_test on windows
eternal-flame-AD Sep 2, 2025
4fee597
Try to fix pipe_test on windows
eternal-flame-AD Sep 2, 2025
b493e1c
remove unused certificates and TLS configs
eternal-flame-AD Sep 2, 2025
cd7743e
use IPv4 loopback address
eternal-flame-AD Sep 2, 2025
35d54d4
rename ServerVersionInfo -> ServerInfo
eternal-flame-AD Sep 2, 2025
7822885
allow passing kex file descriptors through environment variables
eternal-flame-AD Sep 3, 2025
11e6d50
remove SetEnable RPC
eternal-flame-AD Sep 3, 2025
dd63957
compute ping rate
eternal-flame-AD Sep 3, 2025
abf55e8
hoise kex logic to cli
eternal-flame-AD Sep 3, 2025
bfe8e52
check for nil rootCAs
eternal-flame-AD Sep 3, 2025
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
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Test

on:
push:

pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.24.6
- name: Install dependencies
run: go mod download
- name: Test
run: cd v2 && go test -v ./...

test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.24.6
- name: Install dependencies
run: go mod download
- name: Test
run: cd v2 && go test -v ./...
141 changes: 141 additions & 0 deletions v2/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package plugin

import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"flag"
"os"
"slices"
"strconv"
"strings"

"github.com/gotify/plugin-api/v2/transport"
)

// / PluginCli implements the CLI interface for a Gotify plugin.
type PluginCli struct {
flagSet *flag.FlagSet
KexReqFile *os.File
KexRespFile *os.File
Debug bool
}

// ParsePluginCli parses the CLI arguments and returns a PluginCli instance.
func ParsePluginCli(args []string) (*PluginCli, error) {
flagSet := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
var kexReqFileName string
var kexRespFileName string
var debug bool
flagSet.StringVar(&kexReqFileName, "kex-req-file", os.Getenv("GOTIFY_PLUGIN_KEX_REQ_FILE"), "File name for the key exchange for Transport Auth. /proc/self/fd/* can be used to open a file descriptor cross platform.")
flagSet.StringVar(&kexRespFileName, "kex-resp-file", os.Getenv("GOTIFY_PLUGIN_KEX_RESP_FILE"), "File name for the key exchange for Transport Auth. /proc/self/fd/* can be used to open a file descriptor cross platform.")
flagSet.BoolVar(&debug, "debug", slices.Contains([]string{"true", "1", "yes", "y"}, strings.ToLower(os.Getenv("GOTIFY_PLUGIN_DEBUG"))), "Enable debug mode.")
if err := flagSet.Parse(args); err != nil {
return nil, err
}

var kexReqFile *os.File
var kexRespFile *os.File
var err error

if fdNumber, found := strings.CutPrefix(kexReqFileName, "/proc/self/fd/"); found {
fdNumber, err := strconv.ParseUint(fdNumber, 10, 64)
kexReqFile = os.NewFile(uintptr(fdNumber), kexReqFileName)
if err != nil {
return nil, err
}
} else {
kexReqFile, err = os.OpenFile(kexReqFileName, os.O_WRONLY, 0)
if err != nil {
return nil, err
}
}
if fdNumber, found := strings.CutPrefix(kexRespFileName, "/proc/self/fd/"); found {
fdNumber, err := strconv.ParseUint(fdNumber, 10, 64)
if err != nil {
return nil, err
}
kexRespFile = os.NewFile(uintptr(fdNumber), kexRespFileName)
} else {
kexRespFile, err = os.OpenFile(kexRespFileName, os.O_RDONLY, 0)
if err != nil {
return nil, err
}
}

return &PluginCli{
flagSet: flagSet,
KexReqFile: kexReqFile,
KexRespFile: kexRespFile,
Debug: debug,
}, nil
}

// Kex performs the key exchange through secure file descriptors provided in the arguments.
func (f *PluginCli) Kex(modulePath string, certPool *x509.CertPool) (certChain []tls.Certificate, err error) {
// perform key exchange through secure file descriptors
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: transport.BuildPluginTLSName("*", modulePath),
},
}, priv)

if err != nil {
return nil, err
}
if _, err := f.KexReqFile.Write(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
})); err != nil {
return nil, err
}

var certificateChain []tls.Certificate

if err := transport.IteratePEMFile(f.KexRespFile, func(block *pem.Block) (continueIterate bool, err error) {
if block.Type == "CERTIFICATE" {
parsedCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false, err
}
if certPool != nil {
certPool.AddCert(parsedCert)
}
certificateChain = append(certificateChain, tls.Certificate{
Certificate: [][]byte{block.Bytes},
Leaf: parsedCert,
})
return true, nil
}
return true, nil
}); err != nil {
return nil, err
}

if len(certificateChain) == 0 {
return nil, errors.New("no certificate chain found in kex response file")
}

certificateChain[0].PrivateKey = priv

return certificateChain, nil
}

// Close closes any file descriptors associated with the PluginCli instance.
func (f *PluginCli) Close() error {
if err := f.KexReqFile.Close(); err != nil {
return err
}
if err := f.KexRespFile.Close(); err != nil {
return err
}
return nil
}
120 changes: 120 additions & 0 deletions v2/examples_v1/echo/echo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"encoding/json"
"fmt"
"log"
"net/url"

"github.com/gin-gonic/gin"
"github.com/gotify/plugin-api"
)

// GetGotifyPluginInfo returns gotify plugin info.
func GetGotifyPluginInfo() plugin.Info {
return plugin.Info{
ModulePath: "github.com/gotify/server/v2/plugin/example/echo",
Name: "test plugin",
}
}

// EchoPlugin is the gotify plugin instance.
type EchoPlugin struct {
msgHandler plugin.MessageHandler
storageHandler plugin.StorageHandler
config *Config
basePath string
}

// SetStorageHandler implements plugin.Storager
func (c *EchoPlugin) SetStorageHandler(h plugin.StorageHandler) {
c.storageHandler = h
}

// SetMessageHandler implements plugin.Messenger.
func (c *EchoPlugin) SetMessageHandler(h plugin.MessageHandler) {
c.msgHandler = h
}

// Storage defines the plugin storage scheme
type Storage struct {
CalledTimes int `json:"called_times"`
}

// Config defines the plugin config scheme
type Config struct {
MagicString string `yaml:"magic_string"`
}

// DefaultConfig implements plugin.Configurer
func (c *EchoPlugin) DefaultConfig() interface{} {
return &Config{
MagicString: "hello world",
}
}

// ValidateAndSetConfig implements plugin.Configurer
func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error {
c.config = config.(*Config)
return nil
}

// Enable enables the plugin.
func (c *EchoPlugin) Enable() error {
log.Println("echo plugin enabled")
return nil
}

// Disable disables the plugin.
func (c *EchoPlugin) Disable() error {
log.Println("echo plugin disbled")
return nil
}

// RegisterWebhook implements plugin.Webhooker.
func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) {
c.basePath = baseURL
g.GET("/echo", func(ctx *gin.Context) {

storage, _ := c.storageHandler.Load()
conf := new(Storage)
json.Unmarshal(storage, conf)
conf.CalledTimes++
newStorage, _ := json.Marshal(conf)
c.storageHandler.Save(newStorage)

c.msgHandler.SendMessage(plugin.Message{
Title: "Hello received",
Message: fmt.Sprintf("echo server received a hello message %d times", conf.CalledTimes),
Priority: 2,
Extras: map[string]any{
"plugin::name": "echo",
},
})
ctx.Writer.WriteString(fmt.Sprintf("Magic string is: %s\r\nEcho server running at %secho", c.config.MagicString, c.basePath))
})
}

// GetDisplay implements plugin.Displayer.
func (c *EchoPlugin) GetDisplay(location *url.URL) string {
loc := &url.URL{
Path: c.basePath,
}
if location != nil {
loc.Scheme = location.Scheme
loc.Host = location.Host
}
loc = loc.ResolveReference(&url.URL{
Path: "echo",
})
return "Echo plugin running at: " + loc.String()
}

// NewGotifyPluginInstance creates a plugin instance for a user context.
func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {
return &EchoPlugin{}
}

func main() {
panic("this should be built as go plugin")
}
Loading
Loading