mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 17:08:13 -10:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13bf9d0a01 | ||
|
|
dc736f6095 | ||
|
|
e02d53ce67 | ||
|
|
064cd6a2cc | ||
|
|
eab9d0b653 | ||
|
|
29c4ec0afa | ||
|
|
b7c336cb76 | ||
|
|
d8ddd09c86 | ||
|
|
04bb356207 | ||
|
|
a003a5e3cf | ||
|
|
75e3cad76a | ||
|
|
8aa0cbee7e | ||
|
|
30a50717e1 | ||
|
|
f4c9e7109b | ||
|
|
08f3997756 | ||
|
|
3aebebecbb | ||
|
|
1d7eca8e39 | ||
|
|
773ee5f867 | ||
|
|
7608a4b0a0 | ||
|
|
9ecd36b7b6 | ||
|
|
d8fc5a6df4 | ||
|
|
649a6582e0 | ||
|
|
00e8a0cfca | ||
|
|
e9890bc8f9 | ||
|
|
8b798f8307 | ||
|
|
d4728af1ea | ||
|
|
d1b7d91b1a | ||
|
|
cac6dced34 | ||
|
|
a35bf0c1fc | ||
|
|
67616e50c1 | ||
|
|
54de8a2a97 | ||
|
|
20916c655c | ||
|
|
6c18201ed6 | ||
|
|
29165aff0d | ||
|
|
704285f28d | ||
|
|
a007872fbc | ||
|
|
c09c57da3f | ||
|
|
a21b0da928 | ||
|
|
8a0ba038f6 | ||
|
|
dd072bbdb6 | ||
|
|
1e3c736acf | ||
|
|
3d880914d9 | ||
|
|
1162de9ee3 |
No files matched your search
@@ -38,6 +38,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Go 1.25
|
||||
uses: actions/setup-go@v6
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
/.devcontainer
|
||||
/.idea
|
||||
|
||||
/artifacts
|
||||
/artifacts/**
|
||||
|
||||
*.rc
|
||||
|
||||
/Dockerfile.*
|
||||
/*.sdk.tar.xz
|
||||
|
||||
|
||||
@@ -46,4 +46,3 @@ Please take a moment and fill out [our survey](https://forms.gle/SwVsHFNh24ChG58
|
||||
### License - GPLv3
|
||||
|
||||
Sliver is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
|
||||
|
||||
@@ -73,6 +73,18 @@ func GetConsoleLogsDir() string {
|
||||
return consoleLogsDir
|
||||
}
|
||||
|
||||
// GetMCPLogsDir - Get the Sliver client MCP logs dir ~/.sliver-client/logs/mcp/
|
||||
func GetMCPLogsDir() string {
|
||||
mcpLogsDir := filepath.Join(GetClientLogsDir(), "mcp")
|
||||
if _, err := os.Stat(mcpLogsDir); os.IsNotExist(err) {
|
||||
err = os.MkdirAll(mcpLogsDir, 0700)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
return mcpLogsDir
|
||||
}
|
||||
|
||||
func assetVersion() string {
|
||||
appDir := GetRootAppDir()
|
||||
data, err := os.ReadFile(filepath.Join(appDir, versionFileName))
|
||||
|
||||
@@ -51,6 +51,7 @@ func init() {
|
||||
defer logFile.Close()
|
||||
|
||||
rootCmd.TraverseChildren = true
|
||||
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
|
||||
|
||||
// Create the console client, without any RPC or commands bound to it yet.
|
||||
// This created before anything so that multiple commands can make use of
|
||||
@@ -69,6 +70,9 @@ func init() {
|
||||
// other command trees below, such as the implant one.
|
||||
rootCmd.AddCommand(consoleCmd(con))
|
||||
|
||||
// MCP stdio server.
|
||||
rootCmd.AddCommand(mcpCmd(con))
|
||||
|
||||
// Implant.
|
||||
// The implant command allows users to run commands on slivers from their
|
||||
// system shell. It makes use of pre-runners for connecting to the server
|
||||
|
||||
@@ -40,6 +40,7 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
|
||||
Short: "Start the sliver client console",
|
||||
}
|
||||
|
||||
consoleCmd.Flags().String(RCFlagName, "", "path to rc script file")
|
||||
consoleCmd.RunE, consoleCmd.PersistentPostRunE = consoleRunnerCmd(con, true)
|
||||
return consoleCmd
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
|
||||
func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *cobra.Command, args []string) error) {
|
||||
var ln *grpc.ClientConn
|
||||
|
||||
pre = func(_ *cobra.Command, _ []string) error {
|
||||
pre = func(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
configs := assets.GetConfigs()
|
||||
if len(configs) == 0 {
|
||||
@@ -59,13 +60,18 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
|
||||
return nil
|
||||
}
|
||||
|
||||
rcScript, err := ReadRCScript(cmd)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read rc script: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Don't clobber output when simply running an implant command from system shell.
|
||||
if run {
|
||||
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
|
||||
}
|
||||
|
||||
var rpc rpcpb.SliverRPCClient
|
||||
var err error
|
||||
|
||||
rpc, ln, err = transport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
@@ -76,7 +82,7 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
|
||||
// Wait for any connection state changes and exit if the connection is lost.
|
||||
go handleConnectionLost(ln)
|
||||
|
||||
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run)
|
||||
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
|
||||
}
|
||||
|
||||
// Close the RPC connection once exiting
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package cli
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2023 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/bishopfox/sliver/client/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func mcpCmd(con *console.SliverClient) *cobra.Command {
|
||||
mcpCmd := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Start the MCP stdio server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runMCP(cmd, con)
|
||||
},
|
||||
}
|
||||
mcpCmd.Flags().String("config", "", "path to client config file")
|
||||
return mcpCmd
|
||||
}
|
||||
|
||||
func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
|
||||
configPath, _ := cmd.Flags().GetString("config")
|
||||
config, err := loadMCPClientConfig(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("%s\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
rpc, ln, err := transport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
fmt.Printf("Connection to server failed %s\n", err)
|
||||
return nil
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
con.Rpc = rpc
|
||||
go handleConnectionLost(ln)
|
||||
|
||||
cfg := clientmcp.DefaultConfig()
|
||||
if err := clientmcp.ServeStdio(cfg, con.Rpc); err != nil {
|
||||
fmt.Printf("MCP server error: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMCPClientConfig(path string) (*assets.ClientConfig, error) {
|
||||
if path != "" {
|
||||
configPath := path
|
||||
if !filepath.IsAbs(configPath) {
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
configPath = filepath.Join(assets.GetConfigDir(), configPath)
|
||||
}
|
||||
}
|
||||
return assets.ReadConfig(configPath)
|
||||
}
|
||||
|
||||
configs := assets.GetConfigs()
|
||||
switch len(configs) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("no config files found at %s (use --config)", assets.GetConfigDir())
|
||||
case 1:
|
||||
for _, config := range configs {
|
||||
return config, nil
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("multiple configs found; use --config to select one")
|
||||
}
|
||||
return nil, fmt.Errorf("no config files found")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const RCFlagName = "rc"
|
||||
|
||||
func ReadRCScript(cmd *cobra.Command) (string, error) {
|
||||
if cmd == nil {
|
||||
return "", nil
|
||||
}
|
||||
if cmd.Flags().Lookup(RCFlagName) == nil {
|
||||
return "", nil
|
||||
}
|
||||
rcPath, err := cmd.Flags().GetString(RCFlagName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rcPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(rcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read rc script %q: %w", rcPath, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -8,7 +8,7 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
|
||||
|
||||
- `command.go` – Provides helpers for binding Cobra commands, flag sets, and visibility filters into the client command tree.
|
||||
- `server.go` – Defines the server menu composition, wiring each server-side command group onto the root console structure at runtime.
|
||||
- `sliver.go` – Builds the implant/Sliver menu, attaching per-target command groups and loading alias/extension manifests before exposure.
|
||||
- `main.go` – Builds the implant/Sliver menu, attaching per-target command groups and loading alias/extension manifests before exposure.
|
||||
|
||||
## Sub-packages
|
||||
|
||||
@@ -40,6 +40,7 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
|
||||
- `licenses/` – Implements the 'licenses' command group for the Sliver client console.
|
||||
- `loot/` – Implements the 'loot' command group for the Sliver client console.
|
||||
- `monitor/` – Implements the 'monitor' command group for the Sliver client console.
|
||||
- `mcp/` – Implements the 'mcp' command group for the Sliver client console.
|
||||
- `network/` – Implements the 'network' command group for the Sliver client console.
|
||||
- `operators/` – Implements the 'operators' command group for the Sliver client console.
|
||||
- `pivots/` – Implements the 'pivots' command group for the Sliver client console.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# client/command/aka
|
||||
|
||||
## Overview
|
||||
|
||||
Implements the 'aka' command group for the Sliver client console. "Aka"s are
|
||||
command aliases (like 'alias' in 'bash') that allow operators to specify
|
||||
shorthand commands and default arguments for commonly used commands.
|
||||
|
||||
### Examples:
|
||||
|
||||
- `aka create bg background`: Allows an operator to run `bg` from the implant
|
||||
menu to quickly move back to the server menu
|
||||
- `aka create execw execute -o "C:\Windows\System32\cmd.exe" /c`: Shorthand for
|
||||
executing binaries on Windows targets like: `execw arp -a`
|
||||
|
||||
## Go Files
|
||||
|
||||
- `commands.go` – Wires Cobra commands for listing, creating, and deleting command aliases in the client console.
|
||||
- `create.go` – Handles creating command aliases to use by the client.
|
||||
- `delete.go` – Removes command aliases currently tracked by the client.
|
||||
- `hook.go` – The main hooking logic to intercept commands ran by the client console. The hook will intercept processing before hitting the Cobra tree to transform the command alias to a full command with default arguments from the command alias and user-provided arguments to the alias.
|
||||
- `list.go` - Lists the current command aliases tracked by the client.
|
||||
- `storage.go` - Supports the saving of aliases to disk to keep between runs of the client.
|
||||
@@ -0,0 +1,59 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func ServerCommands(con *console.SliverClient) []*cobra.Command {
|
||||
return commands(con, constants.GenericHelpGroup)
|
||||
}
|
||||
|
||||
func ImplantCommands(con *console.SliverClient) []*cobra.Command {
|
||||
return commands(con, constants.SliverCoreHelpGroup)
|
||||
}
|
||||
|
||||
func commands(con *console.SliverClient, group string) []*cobra.Command {
|
||||
akaCommand := &cobra.Command{
|
||||
Use: "aka",
|
||||
Short: "Manage command aliases (aka's)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
AkaListCmd(cmd, con, args)
|
||||
},
|
||||
GroupID: group,
|
||||
}
|
||||
|
||||
createAka := &cobra.Command{
|
||||
Use: "create <alias> <command> [args...]",
|
||||
Short: "Create a new command alias (aka)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
DisableFlagParsing: true, // No flags with aka create so we capture flags to other commands
|
||||
DisableFlagsInUseLine: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
AkaCreateCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
createAka.InitDefaultHelpFlag()
|
||||
createAka.Flags().Lookup("help").Hidden = true
|
||||
akaCommand.AddCommand(createAka)
|
||||
|
||||
deleteAka := &cobra.Command{
|
||||
Use: "delete <alias>",
|
||||
Short: "Delete a command alias (aka)",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
AkaDeleteCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
akaCommand.AddCommand(deleteAka)
|
||||
|
||||
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, Cmdhook)
|
||||
|
||||
err := LoadAkaAliases()
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to load aka alises: %s\n", err)
|
||||
}
|
||||
|
||||
return []*cobra.Command{akaCommand}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func AkaCreateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
aliasName := args[0]
|
||||
command := args[1]
|
||||
defaultArgs := args[2:] // anything else is going to be the default args to the alias
|
||||
|
||||
if _, exists := akaAliases[aliasName]; exists {
|
||||
con.PrintErrorf("Alias '%s' already exists\n", aliasName)
|
||||
return
|
||||
}
|
||||
|
||||
var desc string
|
||||
if len(defaultArgs) == 0 {
|
||||
desc = fmt.Sprintf("%s", command)
|
||||
} else {
|
||||
desc = fmt.Sprintf("%s %s", command, strings.Join(defaultArgs, " "))
|
||||
}
|
||||
|
||||
alias := &AkaAlias{
|
||||
Name: aliasName,
|
||||
Command: command,
|
||||
DefaultArgs: defaultArgs,
|
||||
Description: desc,
|
||||
}
|
||||
|
||||
akaAliases[aliasName] = alias
|
||||
|
||||
// save this new alias to disk
|
||||
err := SaveAkaAliases()
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to save alias '%s' to disk: %s\n", aliasName, err)
|
||||
// we still technically added it to the map, so the alias did get created in
|
||||
// memory so we should still print the Info message below
|
||||
}
|
||||
|
||||
con.PrintInfof("Create alias for '%s' -> '%v'\n", aliasName, alias.Description)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func AkaDeleteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
aliasName := args[0]
|
||||
if _, exists := akaAliases[aliasName]; !exists {
|
||||
con.PrintErrorf("Alias '%s' does not exist\n", aliasName)
|
||||
return
|
||||
}
|
||||
|
||||
delete(akaAliases, aliasName)
|
||||
|
||||
// Save updated map to disk
|
||||
err := SaveAkaAliases()
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to save aliases to disk: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
con.PrintInfof("Deleted alias '%s'\n", aliasName)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Cmdhook(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return args, nil
|
||||
}
|
||||
|
||||
if aliasArgs, exists := akaAliases[args[0]]; exists {
|
||||
var expandedArgs []string
|
||||
|
||||
expandedArgs = append(expandedArgs, aliasArgs.Command)
|
||||
expandedArgs = append(expandedArgs, aliasArgs.DefaultArgs...)
|
||||
// join the user passed args so the shell processes it without flags if
|
||||
// available
|
||||
if len(args[1:]) > 0 {
|
||||
expandedArgs = append(expandedArgs, strings.Join(args[1:], " "))
|
||||
}
|
||||
|
||||
return expandedArgs, nil
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func AkaListCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if len(akaAliases) == 0 {
|
||||
con.PrintInfof("No command aliases created. Use `aka create` to create one.\n")
|
||||
return
|
||||
}
|
||||
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
tw.AppendHeader(table.Row{
|
||||
"Alias",
|
||||
"Command",
|
||||
})
|
||||
|
||||
for alias, cmd := range akaAliases {
|
||||
tw.AppendRow(table.Row{
|
||||
alias,
|
||||
cmd.Description,
|
||||
})
|
||||
}
|
||||
|
||||
con.Printf("%s\n", tw.Render())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
)
|
||||
|
||||
type AkaAlias struct {
|
||||
Name string `json:"name"` // alias Name
|
||||
Command string `json:"command"` // command being aliased
|
||||
DefaultArgs []string `json:"default_args"` // args passed by the user
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var akaAliases = make(map[string]*AkaAlias)
|
||||
|
||||
const (
|
||||
akaAliasFileName = "aka-aliases.json"
|
||||
)
|
||||
|
||||
func GetAkaAliasesFilePath() string {
|
||||
return filepath.Join(assets.GetRootAppDir(), akaAliasFileName)
|
||||
}
|
||||
|
||||
func LoadAkaAliases() error {
|
||||
filepath := GetAkaAliasesFilePath()
|
||||
data, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
// if file hasn't been created yet, we fail gracefully
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
// otherwise, return the actual error; something else went wrong
|
||||
return err
|
||||
}
|
||||
|
||||
aliases := []AkaAlias{}
|
||||
err = json.Unmarshal(data, &aliases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
akaAliases = make(map[string]*AkaAlias)
|
||||
|
||||
for _, alias := range aliases {
|
||||
akaAliases[alias.Name] = &alias
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SaveAkaAliases() error {
|
||||
filepath := GetAkaAliasesFilePath()
|
||||
aliases := make([]*AkaAlias, 0, len(akaAliases))
|
||||
for _, alias := range akaAliases {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(aliases, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/command/flags"
|
||||
"github.com/bishopfox/sliver/client/command/help"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/rsteube/carapace"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Commands returns the `mcp` command and its subcommands.
|
||||
func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
defaultConfig := clientmcp.DefaultConfig()
|
||||
|
||||
mcpCmd := &cobra.Command{
|
||||
Use: consts.MCPStr,
|
||||
Short: "Manage the MCP server",
|
||||
Long: help.GetHelpFor([]string{consts.MCPStr}),
|
||||
GroupID: consts.NetworkHelpGroup,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
McpCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
|
||||
startCmd := &cobra.Command{
|
||||
Use: consts.StartStr,
|
||||
Short: "Start the MCP server",
|
||||
Long: help.GetHelpFor([]string{consts.MCPStr, consts.StartStr}),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
McpStartCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
flags.Bind("", false, startCmd, func(f *pflag.FlagSet) {
|
||||
f.String("transport", string(defaultConfig.Transport), "mcp transport (http or sse)")
|
||||
f.String("listen", defaultConfig.ListenAddress, "listen address for the MCP server")
|
||||
f.String("name", defaultConfig.ServerName, "server name for MCP initialize")
|
||||
f.String("version", defaultConfig.ServerVersion, "server version for MCP initialize")
|
||||
})
|
||||
flags.BindFlagCompletions(startCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["transport"] = carapace.ActionValues("http", "sse")
|
||||
})
|
||||
mcpCmd.AddCommand(startCmd)
|
||||
|
||||
stopCmd := &cobra.Command{
|
||||
Use: consts.StopStr,
|
||||
Short: "Stop the MCP server",
|
||||
Long: help.GetHelpFor([]string{consts.MCPStr, consts.StopStr}),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
McpStopCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
mcpCmd.AddCommand(stopCmd)
|
||||
|
||||
consoleCmd := &cobra.Command{
|
||||
Use: "console",
|
||||
Aliases: []string{"repl"},
|
||||
Short: "Open an MCP console session",
|
||||
Long: help.GetHelpFor([]string{consts.MCPStr, "console"}),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
McpConsoleCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
flags.Bind("", false, consoleCmd, func(f *pflag.FlagSet) {
|
||||
f.String("transport", string(defaultConfig.Transport), "mcp transport (http or sse)")
|
||||
f.String("url", "", "mcp server URL (defaults to local server settings)")
|
||||
})
|
||||
flags.BindFlagCompletions(consoleCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["transport"] = carapace.ActionValues("http", "sse")
|
||||
})
|
||||
mcpCmd.AddCommand(consoleCmd)
|
||||
|
||||
return []*cobra.Command{mcpCmd}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/bishopfox/sliver/client/version"
|
||||
mcpclient "github.com/mark3labs/mcp-go/client"
|
||||
mcpapi "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// McpConsoleCmd connects to an MCP server and starts a minimal console.
|
||||
func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
rawTransport, _ := cmd.Flags().GetString("transport")
|
||||
transport, err := clientmcp.ParseTransport(rawTransport)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL, _ := cmd.Flags().GetString("url")
|
||||
if targetURL == "" {
|
||||
cfg := clientmcp.GetStatus().Config.WithDefaults()
|
||||
cfg.Transport = transport
|
||||
targetURL, err = cfg.EndpointURL()
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newConsoleClient(transport, targetURL)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := client.Start(ctx); err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = client.Initialize(ctx, mcpapi.InitializeRequest{
|
||||
Params: mcpapi.InitializeParams{
|
||||
ProtocolVersion: mcpapi.LATEST_PROTOCOL_VERSION,
|
||||
ClientInfo: mcpapi.Implementation{
|
||||
Name: "sliver-mcp-console",
|
||||
Version: version.Version,
|
||||
},
|
||||
Capabilities: mcpapi.ClientCapabilities{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
con.PrintInfof("Connected to MCP server (%s)\n", targetURL)
|
||||
runConsoleREPL(ctx, client, cmd.InOrStdin(), cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
func newConsoleClient(transport clientmcp.Transport, targetURL string) (*mcpclient.Client, error) {
|
||||
switch transport {
|
||||
case clientmcp.TransportHTTP:
|
||||
return mcpclient.NewStreamableHttpClient(targetURL)
|
||||
case clientmcp.TransportSSE:
|
||||
return mcpclient.NewSSEMCPClient(targetURL)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported transport %q", transport)
|
||||
}
|
||||
}
|
||||
|
||||
func runConsoleREPL(ctx context.Context, client *mcpclient.Client, in io.Reader, out io.Writer) {
|
||||
reader := bufio.NewReader(in)
|
||||
for {
|
||||
fmt.Fprint(out, "mcp> ")
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
fmt.Fprintln(out)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "read error: %s\n", err)
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
switch line {
|
||||
case "":
|
||||
continue
|
||||
case "exit", "quit":
|
||||
return
|
||||
case "help", "?":
|
||||
printConsoleHelp(out)
|
||||
case "tools":
|
||||
printConsoleTools(ctx, client, out)
|
||||
default:
|
||||
fmt.Fprintf(out, "unknown command: %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printConsoleHelp(out io.Writer) {
|
||||
fmt.Fprintln(out, "commands: tools, help, exit")
|
||||
}
|
||||
|
||||
func printConsoleTools(ctx context.Context, client *mcpclient.Client, out io.Writer) {
|
||||
tools, err := client.ListTools(ctx, mcpapi.ListToolsRequest{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "tools error: %s\n", err)
|
||||
return
|
||||
}
|
||||
if len(tools.Tools) == 0 {
|
||||
fmt.Fprintln(out, "no tools available")
|
||||
return
|
||||
}
|
||||
for _, tool := range tools.Tools {
|
||||
if tool.Description != "" {
|
||||
fmt.Fprintf(out, "- %s: %s\n", tool.Name, tool.Description)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "- %s\n", tool.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// McpCmd prints the current MCP server state.
|
||||
func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
status := clientmcp.GetStatus()
|
||||
state := "stopped"
|
||||
if status.Running {
|
||||
state = "running"
|
||||
}
|
||||
|
||||
con.Printf("Status: %s\n", state)
|
||||
con.Printf("Transport: %s\n", status.Config.Transport)
|
||||
con.Printf("Listen: %s\n", status.Config.ListenAddress)
|
||||
|
||||
endpoint, err := status.Config.EndpointURL()
|
||||
if err == nil {
|
||||
con.Printf("Endpoint: %s\n", endpoint)
|
||||
} else {
|
||||
con.PrintErrorf("Endpoint: %s\n", err)
|
||||
}
|
||||
|
||||
if status.Running && !status.StartedAt.IsZero() {
|
||||
uptime := time.Since(status.StartedAt).Truncate(time.Second)
|
||||
con.Printf("Uptime: %s\n", uptime)
|
||||
}
|
||||
if status.LastError != "" {
|
||||
con.PrintErrorf("Last error: %s\n", status.LastError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
slivermcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// McpStartCmd starts the local MCP server.
|
||||
func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
rawTransport, _ := cmd.Flags().GetString("transport")
|
||||
transport, err := slivermcp.ParseTransport(rawTransport)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
listen, _ := cmd.Flags().GetString("listen")
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
cfg := slivermcp.Config{
|
||||
Transport: transport,
|
||||
ListenAddress: listen,
|
||||
ServerName: name,
|
||||
ServerVersion: version,
|
||||
}.WithDefaults()
|
||||
|
||||
if err := slivermcp.Start(cfg, con.Rpc); err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
con.PrintInfof("Starting MCP server (%s) on %s\n", cfg.Transport, cfg.ListenAddress)
|
||||
endpoint, err := cfg.EndpointURL()
|
||||
if err == nil {
|
||||
con.PrintInfof("Endpoint: %s\n", endpoint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const mcpStopTimeout = 5 * time.Second
|
||||
|
||||
// McpStopCmd stops the local MCP server.
|
||||
func McpStopCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpStopTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := clientmcp.Stop(ctx); err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
con.PrintInfof("MCP server stopped\n")
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/aka"
|
||||
"github.com/bishopfox/sliver/client/command/alias"
|
||||
"github.com/bishopfox/sliver/client/command/armory"
|
||||
"github.com/bishopfox/sliver/client/command/beacons"
|
||||
@@ -39,6 +40,7 @@ import (
|
||||
"github.com/bishopfox/sliver/client/command/jobs"
|
||||
"github.com/bishopfox/sliver/client/command/licenses"
|
||||
"github.com/bishopfox/sliver/client/command/loot"
|
||||
"github.com/bishopfox/sliver/client/command/mcp"
|
||||
"github.com/bishopfox/sliver/client/command/monitor"
|
||||
"github.com/bishopfox/sliver/client/command/operators"
|
||||
"github.com/bishopfox/sliver/client/command/reaction"
|
||||
@@ -101,11 +103,13 @@ func ServerCommands(con *client.SliverClient, serverCmds func() []*cobra.Command
|
||||
crack.Commands,
|
||||
certificates.Commands,
|
||||
clean.Command,
|
||||
aka.ServerCommands,
|
||||
)
|
||||
|
||||
// C2 Network
|
||||
bind(consts.NetworkHelpGroup,
|
||||
jobs.Commands,
|
||||
mcp.Commands,
|
||||
websites.Commands,
|
||||
wireguard.Commands,
|
||||
c2profiles.Commands,
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/command/aka"
|
||||
"github.com/bishopfox/sliver/client/command/alias"
|
||||
"github.com/bishopfox/sliver/client/command/backdoor"
|
||||
"github.com/bishopfox/sliver/client/command/cursed"
|
||||
@@ -78,6 +79,7 @@ func SliverCommands(con *client.SliverClient) console.Commands {
|
||||
// use.Commands,
|
||||
tasks.Commands,
|
||||
pivots.Commands,
|
||||
aka.ImplantCommands,
|
||||
)
|
||||
|
||||
// [ Info ]
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/reeflective/console"
|
||||
"github.com/reeflective/readline"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -159,7 +160,7 @@ func NewConsole(isServer bool) *SliverClient {
|
||||
// Init requires a working RPC connection to the sliver server, and 2 different sets of commands.
|
||||
// If run is true, the console application is started, making this call blocking. Otherwise, commands and
|
||||
// RPC connection are bound to the console (making the console ready to run), but the console does not start.
|
||||
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, sliverCmds console.Commands, run bool) error {
|
||||
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, sliverCmds console.Commands, run bool, rcScript string) error {
|
||||
con.Rpc = rpc
|
||||
con.IsCLI = !run
|
||||
|
||||
@@ -206,6 +207,13 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, slive
|
||||
con.setupAsciicastRecord(asciicastLog, asciicastStream)
|
||||
}
|
||||
|
||||
if rcScript != "" {
|
||||
originalPrintf := con.printf
|
||||
con.printf = fmt.Printf
|
||||
con.runRCScript(serverCmds, sliverCmds, rcScript)
|
||||
con.printf = originalPrintf
|
||||
}
|
||||
|
||||
if !con.IsCLI {
|
||||
return con.App.Start()
|
||||
}
|
||||
@@ -213,6 +221,71 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, slive
|
||||
return nil
|
||||
}
|
||||
|
||||
func (con *SliverClient) runRCScript(serverCmds, sliverCmds console.Commands, rcScript string) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(rcScript))
|
||||
lineNumber := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if err := con.runRCLine(serverCmds, sliverCmds, line); err != nil {
|
||||
con.PrintErrorf("rc line %d error: %s", lineNumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
con.PrintErrorf("rc script error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (con *SliverClient) runRCLine(serverCmds, sliverCmds console.Commands, line string) error {
|
||||
args, err := shellquote.Split(line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
for _, hook := range con.App.PreCmdRunLineHooks {
|
||||
args, err = hook(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-run line error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
menu := con.App.ActiveMenu()
|
||||
if menu.Name() == consts.ImplantMenu {
|
||||
menu.Command = sliverCmds()
|
||||
} else {
|
||||
menu.Command = serverCmds()
|
||||
}
|
||||
|
||||
target, _, _ := menu.Command.Find(args)
|
||||
if err := menu.CheckIsAvailable(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, hook := range con.App.PreCmdRunHooks {
|
||||
if err := hook(); err != nil {
|
||||
return fmt.Errorf("pre-run error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
menu.SetArgs(args)
|
||||
menu.SetContext(context.Background())
|
||||
|
||||
if err := menu.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (con *SliverClient) startEventLoop() {
|
||||
eventStream, err := con.Rpc.Events(context.Background(), &commonpb.Empty{})
|
||||
if err != nil {
|
||||
@@ -341,11 +414,12 @@ func (con *SliverClient) triggerReactions(event *clientpb.Event) {
|
||||
con.ActiveTarget.Set(currentActiveSession, currentActiveBeacon)
|
||||
}()
|
||||
|
||||
if event.EventType == consts.SessionOpenedEvent {
|
||||
switch event.EventType {
|
||||
case consts.SessionOpenedEvent:
|
||||
con.ActiveTarget.Set(nil, nil)
|
||||
|
||||
con.ActiveTarget.Set(event.Session, nil)
|
||||
} else if event.EventType == consts.BeaconRegisteredEvent {
|
||||
case consts.BeaconRegisteredEvent:
|
||||
con.ActiveTarget.Set(nil, nil)
|
||||
|
||||
beacon := &clientpb.Beacon{}
|
||||
|
||||
@@ -276,6 +276,7 @@ const (
|
||||
WgPortFwdStr = "wg-portfwd"
|
||||
MonitorStr = "monitor"
|
||||
MonitorConfigStr = "config"
|
||||
MCPStr = "mcp"
|
||||
SSHStr = "ssh"
|
||||
DLLHijackStr = "dllhijack"
|
||||
InteractiveStr = "interactive"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/version"
|
||||
)
|
||||
|
||||
// Transport identifies the MCP server transport to expose.
|
||||
type Transport string
|
||||
|
||||
const (
|
||||
TransportHTTP Transport = "http"
|
||||
TransportSSE Transport = "sse"
|
||||
)
|
||||
|
||||
// Config controls the local MCP server settings.
|
||||
type Config struct {
|
||||
Transport Transport
|
||||
ListenAddress string
|
||||
ServerName string
|
||||
ServerVersion string
|
||||
}
|
||||
|
||||
// DefaultConfig returns the baseline MCP configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Transport: TransportSSE,
|
||||
ListenAddress: "127.0.0.1:8080",
|
||||
ServerName: "Sliver MCP",
|
||||
ServerVersion: version.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaults fills any empty fields with defaults.
|
||||
func (c Config) WithDefaults() Config {
|
||||
defaults := DefaultConfig()
|
||||
if c.Transport == "" {
|
||||
c.Transport = defaults.Transport
|
||||
}
|
||||
if c.ListenAddress == "" {
|
||||
c.ListenAddress = defaults.ListenAddress
|
||||
}
|
||||
if c.ServerName == "" {
|
||||
c.ServerName = defaults.ServerName
|
||||
}
|
||||
if c.ServerVersion == "" {
|
||||
c.ServerVersion = defaults.ServerVersion
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseTransport validates an input transport string.
|
||||
func ParseTransport(raw string) (Transport, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
if value == "" {
|
||||
return DefaultConfig().Transport, nil
|
||||
}
|
||||
switch Transport(value) {
|
||||
case TransportHTTP, TransportSSE:
|
||||
return Transport(value), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported transport %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ensures the configuration can be used for a server start.
|
||||
func (c Config) Validate() error {
|
||||
if c.Transport != TransportHTTP && c.Transport != TransportSSE {
|
||||
return fmt.Errorf("unsupported transport %q", c.Transport)
|
||||
}
|
||||
if c.ListenAddress == "" {
|
||||
return fmt.Errorf("listen address is required")
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(c.ListenAddress); err != nil {
|
||||
return fmt.Errorf("invalid listen address %q: %w", c.ListenAddress, err)
|
||||
}
|
||||
if c.ServerName == "" {
|
||||
return fmt.Errorf("server name is required")
|
||||
}
|
||||
if c.ServerVersion == "" {
|
||||
return fmt.Errorf("server version is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndpointURL returns the base URL for clients to connect.
|
||||
func (c Config) EndpointURL() (string, error) {
|
||||
cfg := c.WithDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(cfg.ListenAddress)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
|
||||
endpoint := url.URL{
|
||||
Scheme: "http",
|
||||
Host: net.JoinHostPort(host, port),
|
||||
}
|
||||
switch cfg.Transport {
|
||||
case TransportHTTP:
|
||||
endpoint.Path = "/mcp"
|
||||
case TransportSSE:
|
||||
endpoint.Path = "/sse"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported transport %q", cfg.Transport)
|
||||
}
|
||||
return endpoint.String(), nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,67 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
)
|
||||
|
||||
const mcpLogTimeFormat = "2006-01-02_15-04-05"
|
||||
|
||||
func newMCPLogger() (*log.Logger, *os.File, error) {
|
||||
logsDir := assets.GetMCPLogsDir()
|
||||
logPath := filepath.Join(logsDir, fmt.Sprintf("%s.log", time.Now().Format(mcpLogTimeFormat)))
|
||||
logFile, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
logger := log.New(logFile, "", log.LstdFlags|log.Lshortfile)
|
||||
return logger, logFile, nil
|
||||
}
|
||||
|
||||
func (m *mcpManager) logf(format string, args ...any) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
logger := m.logger
|
||||
m.mu.Unlock()
|
||||
if logger != nil {
|
||||
logger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SliverMCPServer) logf(format string, args ...any) {
|
||||
if s == nil || s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Printf(format, args...)
|
||||
}
|
||||
|
||||
func (s *SliverMCPServer) logToolCall(tool, sessionID, beaconID string, extras ...string) {
|
||||
if s == nil || s.logger == nil {
|
||||
return
|
||||
}
|
||||
parts := make([]string, 0, 2+len(extras))
|
||||
if sessionID != "" {
|
||||
parts = append(parts, fmt.Sprintf("session_id=%s", sessionID))
|
||||
}
|
||||
if beaconID != "" {
|
||||
parts = append(parts, fmt.Sprintf("beacon_id=%s", beaconID))
|
||||
}
|
||||
for _, extra := range extras {
|
||||
if extra != "" {
|
||||
parts = append(parts, extra)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
s.logger.Printf("mcp tool=%s", tool)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("mcp tool=%s %s", tool, strings.Join(parts, " "))
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlreadyRunning = errors.New("mcp server already running")
|
||||
ErrNotRunning = errors.New("mcp server not running")
|
||||
)
|
||||
|
||||
// Status describes the running state of the MCP server.
|
||||
type Status struct {
|
||||
Running bool
|
||||
StartedAt time.Time
|
||||
Config Config
|
||||
LastError string
|
||||
}
|
||||
|
||||
type serverTransport interface {
|
||||
Start(addr string) error
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
type mcpManager struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
running bool
|
||||
startedAt time.Time
|
||||
lastErr string
|
||||
server *SliverMCPServer
|
||||
transport serverTransport
|
||||
done chan struct{}
|
||||
logger *log.Logger
|
||||
logFile *os.File
|
||||
}
|
||||
|
||||
func newManager() *mcpManager {
|
||||
return &mcpManager{
|
||||
cfg: DefaultConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
var defaultManager = newManager()
|
||||
|
||||
// GetStatus returns the current MCP server state.
|
||||
func GetStatus() Status {
|
||||
return defaultManager.status()
|
||||
}
|
||||
|
||||
// Start launches the MCP server using the provided configuration.
|
||||
func Start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
return defaultManager.start(cfg, rpc)
|
||||
}
|
||||
|
||||
// ServeStdio runs the MCP server over stdio using the provided configuration.
|
||||
func ServeStdio(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
cfg = cfg.WithDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
logger, logFile, err := newMCPLogger()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logFile.Close()
|
||||
logger.Printf("starting mcp stdio server")
|
||||
srv := newServer(cfg, rpc, logger)
|
||||
err = mcpserver.ServeStdio(srv.server)
|
||||
if err != nil {
|
||||
logger.Printf("mcp stdio server error: %v", err)
|
||||
} else {
|
||||
logger.Printf("mcp stdio server stopped")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop shuts down the MCP server.
|
||||
func Stop(ctx context.Context) error {
|
||||
return defaultManager.stop(ctx)
|
||||
}
|
||||
|
||||
func (m *mcpManager) status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return Status{
|
||||
Running: m.running,
|
||||
StartedAt: m.startedAt,
|
||||
Config: m.cfg,
|
||||
LastError: m.lastErr,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
cfg = cfg.WithDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.running {
|
||||
m.mu.Unlock()
|
||||
return ErrAlreadyRunning
|
||||
}
|
||||
logger, logFile, err := newMCPLogger()
|
||||
if err != nil {
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
mcpServer := newServer(cfg, rpc, logger)
|
||||
var transport serverTransport
|
||||
switch cfg.Transport {
|
||||
case TransportHTTP:
|
||||
transport = mcpserver.NewStreamableHTTPServer(mcpServer.server)
|
||||
case TransportSSE:
|
||||
transport = mcpserver.NewSSEServer(mcpServer.server)
|
||||
default:
|
||||
m.mu.Unlock()
|
||||
logFile.Close()
|
||||
return errors.New("unsupported transport")
|
||||
}
|
||||
|
||||
m.cfg = cfg
|
||||
m.running = true
|
||||
m.startedAt = time.Now()
|
||||
m.lastErr = ""
|
||||
m.server = mcpServer
|
||||
m.transport = transport
|
||||
m.done = make(chan struct{})
|
||||
m.logger = logger
|
||||
m.logFile = logFile
|
||||
addr := cfg.ListenAddress
|
||||
done := m.done
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Printf("starting mcp server transport=%s listen=%s", cfg.Transport, cfg.ListenAddress)
|
||||
go m.run(addr, transport, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mcpManager) run(addr string, transport serverTransport, done chan struct{}) {
|
||||
err := transport.Start(addr)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
m.logf("mcp server error: %v", err)
|
||||
m.mu.Lock()
|
||||
m.lastErr = err.Error()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
m.logf("mcp server stopped")
|
||||
|
||||
m.mu.Lock()
|
||||
m.running = false
|
||||
m.transport = nil
|
||||
m.server = nil
|
||||
logFile := m.logFile
|
||||
m.logFile = nil
|
||||
m.logger = nil
|
||||
m.mu.Unlock()
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
}
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (m *mcpManager) stop(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
if !m.running {
|
||||
m.mu.Unlock()
|
||||
return ErrNotRunning
|
||||
}
|
||||
transport := m.transport
|
||||
done := m.done
|
||||
m.mu.Unlock()
|
||||
|
||||
if transport == nil {
|
||||
return ErrNotRunning
|
||||
}
|
||||
m.logf("mcp server shutdown requested")
|
||||
if err := transport.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if done == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
mcpapi "github.com/mark3labs/mcp-go/mcp"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
listSessionsAndBeaconsToolName = "list_sessions_and_beacons"
|
||||
)
|
||||
|
||||
type sessionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Hostname string `json:"hostname"`
|
||||
Username string `json:"username"`
|
||||
PID int32 `json:"pid"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Locale string `json:"locale"`
|
||||
LastCheckin int64 `json:"last_checkin"`
|
||||
IsDead bool `json:"is_dead"`
|
||||
Integrity string `json:"integrity"`
|
||||
}
|
||||
|
||||
type beaconSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Hostname string `json:"hostname"`
|
||||
Username string `json:"username"`
|
||||
PID int32 `json:"pid"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Locale string `json:"locale"`
|
||||
LastCheckin int64 `json:"last_checkin"`
|
||||
NextCheckin int64 `json:"next_checkin"`
|
||||
Interval int64 `json:"interval"`
|
||||
Jitter int64 `json:"jitter"`
|
||||
TasksCount int64 `json:"tasks_count"`
|
||||
TasksCountCompleted int64 `json:"tasks_count_completed"`
|
||||
IsDead bool `json:"is_dead"`
|
||||
Integrity string `json:"integrity"`
|
||||
}
|
||||
|
||||
type listSessionsAndBeaconsResult struct {
|
||||
Sessions []sessionSummary `json:"sessions"`
|
||||
SessionsCount int `json:"sessions_count"`
|
||||
Beacons []beaconSummary `json:"beacons"`
|
||||
BeaconsCount int `json:"beacons_count"`
|
||||
}
|
||||
|
||||
// SliverMCPServer wraps the MCP server with Sliver RPC access for handlers.
|
||||
type SliverMCPServer struct {
|
||||
Rpc rpcpb.SliverRPCClient
|
||||
server *mcpserver.MCPServer
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func newServer(cfg Config, rpc rpcpb.SliverRPCClient, logger *log.Logger) *SliverMCPServer {
|
||||
base := mcpserver.NewMCPServer(
|
||||
cfg.ServerName,
|
||||
cfg.ServerVersion,
|
||||
mcpserver.WithToolCapabilities(false),
|
||||
)
|
||||
|
||||
listSessionsAndBeaconsTool := mcpapi.NewTool(
|
||||
listSessionsAndBeaconsToolName,
|
||||
mcpapi.WithDescription("List active sessions and beacons."),
|
||||
mcpapi.WithReadOnlyHintAnnotation(true),
|
||||
mcpapi.WithDestructiveHintAnnotation(false),
|
||||
mcpapi.WithIdempotentHintAnnotation(true),
|
||||
)
|
||||
lsTool := mcpapi.NewTool(
|
||||
lsToolName,
|
||||
mcpapi.WithDescription("List the contents of a remote directory."),
|
||||
mcpapi.WithInputSchema[lsArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(true),
|
||||
mcpapi.WithDestructiveHintAnnotation(false),
|
||||
mcpapi.WithIdempotentHintAnnotation(true),
|
||||
)
|
||||
cdTool := mcpapi.NewTool(
|
||||
cdToolName,
|
||||
mcpapi.WithDescription("Change the current directory on a remote session or beacon."),
|
||||
mcpapi.WithInputSchema[cdArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(false),
|
||||
)
|
||||
catTool := mcpapi.NewTool(
|
||||
catToolName,
|
||||
mcpapi.WithDescription("Download and return the contents of a remote file."),
|
||||
mcpapi.WithInputSchema[catArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(true),
|
||||
mcpapi.WithDestructiveHintAnnotation(false),
|
||||
mcpapi.WithIdempotentHintAnnotation(true),
|
||||
)
|
||||
pwdTool := mcpapi.NewTool(
|
||||
pwdToolName,
|
||||
mcpapi.WithDescription("Return the current working directory of a remote session or beacon."),
|
||||
mcpapi.WithInputSchema[pwdArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(true),
|
||||
mcpapi.WithDestructiveHintAnnotation(false),
|
||||
mcpapi.WithIdempotentHintAnnotation(true),
|
||||
)
|
||||
rmTool := mcpapi.NewTool(
|
||||
rmToolName,
|
||||
mcpapi.WithDescription("Remove a file or directory on the remote target."),
|
||||
mcpapi.WithInputSchema[rmArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(true),
|
||||
)
|
||||
mvTool := mcpapi.NewTool(
|
||||
mvToolName,
|
||||
mcpapi.WithDescription("Move or rename a file or directory on the remote target."),
|
||||
mcpapi.WithInputSchema[mvArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(true),
|
||||
)
|
||||
cpTool := mcpapi.NewTool(
|
||||
cpToolName,
|
||||
mcpapi.WithDescription("Copy a file on the remote target."),
|
||||
mcpapi.WithInputSchema[cpArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(true),
|
||||
)
|
||||
mkdirTool := mcpapi.NewTool(
|
||||
mkdirToolName,
|
||||
mcpapi.WithDescription("Create a directory on the remote target."),
|
||||
mcpapi.WithInputSchema[mkdirArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(true),
|
||||
)
|
||||
chmodTool := mcpapi.NewTool(
|
||||
chmodToolName,
|
||||
mcpapi.WithDescription("Change file or directory permissions on the remote target."),
|
||||
mcpapi.WithInputSchema[chmodArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(true),
|
||||
)
|
||||
chownTool := mcpapi.NewTool(
|
||||
chownToolName,
|
||||
mcpapi.WithDescription("Change file or directory ownership on the remote target."),
|
||||
mcpapi.WithInputSchema[chownArgs](),
|
||||
mcpapi.WithReadOnlyHintAnnotation(false),
|
||||
mcpapi.WithDestructiveHintAnnotation(true),
|
||||
)
|
||||
srv := &SliverMCPServer{
|
||||
Rpc: rpc,
|
||||
server: base,
|
||||
logger: logger,
|
||||
}
|
||||
srv.server.AddTool(listSessionsAndBeaconsTool, srv.listSessionsAndBeaconsHandler)
|
||||
srv.server.AddTool(lsTool, srv.lsHandler)
|
||||
srv.server.AddTool(cdTool, srv.cdHandler)
|
||||
srv.server.AddTool(catTool, srv.catHandler)
|
||||
srv.server.AddTool(pwdTool, srv.pwdHandler)
|
||||
srv.server.AddTool(rmTool, srv.rmHandler)
|
||||
srv.server.AddTool(mvTool, srv.mvHandler)
|
||||
srv.server.AddTool(cpTool, srv.cpHandler)
|
||||
srv.server.AddTool(mkdirTool, srv.mkdirHandler)
|
||||
srv.server.AddTool(chmodTool, srv.chmodHandler)
|
||||
srv.server.AddTool(chownTool, srv.chownHandler)
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *SliverMCPServer) listSessionsAndBeaconsHandler(ctx context.Context, _ mcpapi.CallToolRequest) (*mcpapi.CallToolResult, error) {
|
||||
if s.Rpc == nil {
|
||||
return mcpapi.NewToolResultError("rpc client not configured"), nil
|
||||
}
|
||||
|
||||
s.logToolCall(listSessionsAndBeaconsToolName, "", "")
|
||||
|
||||
sessionsResp, err := s.Rpc.GetSessions(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return mcpapi.NewToolResultErrorFromErr("failed to list sessions", err), nil
|
||||
}
|
||||
|
||||
beaconsResp, err := s.Rpc.GetBeacons(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return mcpapi.NewToolResultErrorFromErr("failed to list beacons", err), nil
|
||||
}
|
||||
|
||||
result := listSessionsAndBeaconsResult{
|
||||
Sessions: make([]sessionSummary, 0, len(sessionsResp.GetSessions())),
|
||||
Beacons: make([]beaconSummary, 0, len(beaconsResp.GetBeacons())),
|
||||
}
|
||||
|
||||
for _, session := range sessionsResp.GetSessions() {
|
||||
if session == nil {
|
||||
continue
|
||||
}
|
||||
result.Sessions = append(result.Sessions, sessionSummary{
|
||||
ID: session.ID,
|
||||
Name: session.Name,
|
||||
Transport: session.Transport,
|
||||
RemoteAddress: session.RemoteAddress,
|
||||
Hostname: session.Hostname,
|
||||
Username: session.Username,
|
||||
PID: session.PID,
|
||||
OS: session.OS,
|
||||
Arch: session.Arch,
|
||||
Locale: session.Locale,
|
||||
LastCheckin: session.LastCheckin,
|
||||
IsDead: session.IsDead,
|
||||
Integrity: session.Integrity,
|
||||
})
|
||||
}
|
||||
|
||||
for _, beacon := range beaconsResp.GetBeacons() {
|
||||
if beacon == nil {
|
||||
continue
|
||||
}
|
||||
result.Beacons = append(result.Beacons, beaconSummary{
|
||||
ID: beacon.ID,
|
||||
Name: beacon.Name,
|
||||
Transport: beacon.Transport,
|
||||
RemoteAddress: beacon.RemoteAddress,
|
||||
Hostname: beacon.Hostname,
|
||||
Username: beacon.Username,
|
||||
PID: beacon.PID,
|
||||
OS: beacon.OS,
|
||||
Arch: beacon.Arch,
|
||||
Locale: beacon.Locale,
|
||||
LastCheckin: beacon.LastCheckin,
|
||||
NextCheckin: beacon.NextCheckin,
|
||||
Interval: beacon.Interval,
|
||||
Jitter: beacon.Jitter,
|
||||
TasksCount: beacon.TasksCount,
|
||||
TasksCountCompleted: beacon.TasksCountCompleted,
|
||||
IsDead: beacon.IsDead,
|
||||
Integrity: beacon.Integrity,
|
||||
})
|
||||
}
|
||||
|
||||
result.SessionsCount = len(result.Sessions)
|
||||
result.BeaconsCount = len(result.Beacons)
|
||||
|
||||
return mcpapi.NewToolResultStructuredOnly(result), nil
|
||||
}
|
||||
@@ -154,6 +154,14 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / gogo / goname
|
||||
if go test -tags=server,$TAGS ./server/gogo/goname ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / gogo
|
||||
if go test -tags=server,$TAGS ./server/gogo ; then
|
||||
:
|
||||
|
||||
@@ -4,6 +4,8 @@ go 1.25.3
|
||||
|
||||
replace github.com/rsteube/carapace v0.36.3 => github.com/reeflective/carapace v0.46.3-0.20231214181515-27e49f3c3b69
|
||||
|
||||
replace github.com/reeflective/readline => github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d
|
||||
|
||||
require (
|
||||
filippo.io/age v1.2.1
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb
|
||||
@@ -25,9 +27,9 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/lesnuages/go-winio v0.4.19
|
||||
github.com/lesnuages/snitch v0.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
@@ -68,6 +70,16 @@ require (
|
||||
tailscale.com v1.90.6
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
@@ -133,6 +145,7 @@ require (
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
|
||||
@@ -84,7 +84,11 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/carapace-sh/carapace v1.9.0 h1:IuEP6YeeK4fhrH7MoOyL0I3GMhvROerkw6YGxkx8Fbw=
|
||||
github.com/carapace-sh/carapace v1.9.0/go.mod h1:Zs3DpsawpFm+8fEfRVYE9EzMBYH8u7S3Aw9yAAKX0JM=
|
||||
github.com/carapace-sh/carapace-shlex v1.1.0 h1:58QSJTUxghpa/rEn6+Z9UM/Hy0ipVHDUn9S1mp4i+ZE=
|
||||
@@ -253,6 +257,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -264,8 +270,8 @@ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr
|
||||
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
@@ -274,6 +280,7 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
@@ -282,8 +289,8 @@ github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWr
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
|
||||
@@ -306,6 +313,10 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -339,6 +350,8 @@ github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+N
|
||||
github.com/moloch--/go-keystone v0.0.2/go.mod h1:kpGRJpw9b7Eq3MluHrba+GPKZhSyDrvhLULSwH5nGGM=
|
||||
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e h1:IkFCPlAa0iTiLxck+NqAwBx8JDlnHYm4orOQBbs4BDQ=
|
||||
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e/go.mod h1:eYeI6cQ5YHhHt9i0BBW0zc1DaQnb4ZMXsSPuEV/V5Og=
|
||||
github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d h1:9RbKockJl6ZdgoEh2QqU3An6Ij/NWZtRWS3J6yG0+qo=
|
||||
github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
|
||||
github.com/moloch--/sgn v0.0.4 h1:90IC5rx3TmPEfrqPnENB0yyiqcbcNk9LqezXx7uruF0=
|
||||
github.com/moloch--/sgn v0.0.4/go.mod h1:FyWfCtdynywgLmVR71vTJztZ3WkGSkR91Y4EOijUutE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
@@ -380,8 +393,6 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/reeflective/console v0.1.25 h1:do/r6rcKH6pCQDEzh5PhoVFcqHkqcjd0N6tNjpmYWWQ=
|
||||
github.com/reeflective/console v0.1.25/go.mod h1:It/IqEjo0PowaBuxBS9pEi5EYUgmtvQd0ytMtlS77kk=
|
||||
github.com/reeflective/readline v1.1.3 h1:meGkuEmujZHmalJ9eT3pYkwtkufH5EwYFPTnaph0T0s=
|
||||
github.com/reeflective/readline v1.1.3/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -399,6 +410,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
@@ -455,6 +468,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
|
||||
@@ -463,6 +478,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yiya1989/sshkrb5 v0.0.1 h1:oAKpXLBeZExjYDV9eTL2KypmXU+5oPrJtqDuR5JbWic=
|
||||
github.com/yiya1989/sshkrb5 v0.0.1/go.mod h1:r1bHcNa22x8rrg/Kj/cDF5bX6UtZrHiQgRFvj3Mnpl8=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
|
||||
@@ -6,7 +6,7 @@ Core Go implementation of the Sliver implant runtime and supporting subsystems.
|
||||
|
||||
## Go Files
|
||||
|
||||
- `sliver.go` – Implant entry point that wires transports, handlers, and the task runner together at startup.
|
||||
- `main.go` – Implant entry point that wires transports, handlers, and the task runner together at startup.
|
||||
|
||||
## Sub-packages
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "sliver.h"
|
||||
#include "main.h"
|
||||
|
||||
#ifdef __WIN32
|
||||
#include <windows.h>
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2019 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// {{if or .Config.IsSharedLib }}
|
||||
//#include "main.h"
|
||||
import "C"
|
||||
|
||||
// {{end}}
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/implant/sliver/runner"
|
||||
)
|
||||
|
||||
// {{if or .Config.IsSharedLib .Config.IsShellcode}}
|
||||
var isRunning bool = false
|
||||
|
||||
// {{range .Config.Exports}}
|
||||
//export {{.}}
|
||||
func {{.}} () {
|
||||
if !isRunning {
|
||||
isRunning = true
|
||||
main()
|
||||
}
|
||||
}
|
||||
// {{end}}
|
||||
// {{end}}
|
||||
|
||||
func main() {
|
||||
runner.Main()
|
||||
}
|
||||
File renamed without changes.
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package runner
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
@@ -18,12 +18,6 @@ package main
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// {{if or .Config.IsSharedLib }}
|
||||
//#include "sliver.h"
|
||||
import "C"
|
||||
|
||||
// {{end}}
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
@@ -105,26 +99,11 @@ func (serv *sliverService) Execute(args []string, r <-chan svc.ChangeRequest, ch
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// {{end}}
|
||||
|
||||
// {{if or .Config.IsSharedLib .Config.IsShellcode}}
|
||||
var isRunning bool = false
|
||||
|
||||
// {{range .Config.Exports}}
|
||||
//export {{.}}
|
||||
func {{.}} () {
|
||||
if !isRunning {
|
||||
isRunning = true
|
||||
main()
|
||||
}
|
||||
}
|
||||
// {{end}}
|
||||
// {{end}}
|
||||
|
||||
func main() {
|
||||
func Main() {
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
@@ -272,17 +251,19 @@ func beaconMainLoop(beacon *transports.Beacon) error {
|
||||
time.Sleep(time.Second)
|
||||
beacon.Close()
|
||||
|
||||
// BeaconMain - Is executed in it's own goroutine as the function will block
|
||||
// until all tasks complete (in success or failure), if a task handler blocks
|
||||
// forever it will simply block this set of tasks instead of the entire beacon
|
||||
// BeaconMain - Tasks run in background goroutines, check-ins happen on schedule
|
||||
// Results are collected and sent on subsequent check-ins
|
||||
pendingResults := make(chan *sliverpb.Envelope, 100)
|
||||
errors := make(chan error)
|
||||
shortCircuit := make(chan struct{})
|
||||
|
||||
for {
|
||||
duration := beacon.Duration()
|
||||
nextCheckin = time.Now().Add(duration)
|
||||
|
||||
go func() {
|
||||
oldInterval := beacon.Interval()
|
||||
err := beaconMain(beacon, nextCheckin)
|
||||
err := beaconMain(beacon, nextCheckin, pendingResults)
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] main error: %v", nextCheckin)
|
||||
@@ -310,7 +291,7 @@ func beaconMainLoop(beacon *transports.Beacon) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
|
||||
func beaconMain(beacon *transports.Beacon, nextCheckin time.Time, pendingResults chan *sliverpb.Envelope) error {
|
||||
err := beacon.Start()
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
@@ -325,12 +306,26 @@ func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
|
||||
time.Sleep(time.Second)
|
||||
beacon.Close()
|
||||
}()
|
||||
|
||||
// Collect any pending results from background tasks
|
||||
var completedResults []*sliverpb.Envelope
|
||||
collecting:
|
||||
for {
|
||||
select {
|
||||
case result := <-pendingResults:
|
||||
completedResults = append(completedResults, result)
|
||||
default:
|
||||
break collecting
|
||||
}
|
||||
}
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] sending check in ...")
|
||||
log.Printf("[beacon] sending check in with %d pending results...", len(completedResults))
|
||||
// {{end}}
|
||||
err = beacon.Send(wrapEnvelope(sliverpb.MsgBeaconTasks, &sliverpb.BeaconTasks{
|
||||
ID: InstanceID,
|
||||
NextCheckin: int64(beacon.Duration().Seconds()),
|
||||
Tasks: completedResults,
|
||||
}))
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
@@ -382,30 +377,81 @@ func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ensure extensions are registered before they are called
|
||||
var results []*sliverpb.Envelope
|
||||
// Dispatch tasks to background - results sent to pendingResults as each completes
|
||||
// Extensions must be registered synchronously before other tasks can use them
|
||||
for _, r := range beaconHandleTasklist(tasksExtensionRegister) {
|
||||
results = append(results, r)
|
||||
}
|
||||
for _, r := range beaconHandleTasklist(tasksOther) {
|
||||
results = append(results, r)
|
||||
pendingResults <- r
|
||||
}
|
||||
|
||||
err = beacon.Send(wrapEnvelope(sliverpb.MsgBeaconTasks, &sliverpb.BeaconTasks{
|
||||
ID: InstanceID,
|
||||
Tasks: results,
|
||||
}))
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] error sending results %s", err)
|
||||
// {{end}}
|
||||
}
|
||||
// Dispatch other tasks individually - each sends its result when done
|
||||
beaconDispatchTasks(tasksOther, pendingResults)
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] all results sent to server, cleanup ...")
|
||||
log.Printf("[beacon] tasks dispatched to background, check-in complete")
|
||||
// {{end}}
|
||||
return nil
|
||||
}
|
||||
|
||||
// beaconDispatchTasks dispatches tasks to run in background, sending results as each completes
|
||||
func beaconDispatchTasks(tasks []*sliverpb.Envelope, pendingResults chan *sliverpb.Envelope) {
|
||||
sysHandlers := handlers.GetSystemHandlers()
|
||||
specHandlers := handlers.GetKillHandlers()
|
||||
|
||||
for _, task := range tasks {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] dispatching task %d", task.Type)
|
||||
// {{end}}
|
||||
if handler, ok := sysHandlers[task.Type]; ok {
|
||||
data := task.Data
|
||||
taskID := task.ID
|
||||
// {{if eq .Config.GOOS "windows" }}
|
||||
go func() {
|
||||
handlers.WrapperHandler(handler, data, func(data []byte, err error) {
|
||||
// {{if .Config.Debug}}
|
||||
if err != nil {
|
||||
log.Printf("[beacon] handler function returned an error: %s", err)
|
||||
}
|
||||
log.Printf("[beacon] task completed (id: %d)", taskID)
|
||||
// {{end}}
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: taskID,
|
||||
Data: data,
|
||||
}
|
||||
})
|
||||
}()
|
||||
// {{else}}
|
||||
go func() {
|
||||
handler(data, func(data []byte, err error) {
|
||||
// {{if .Config.Debug}}
|
||||
if err != nil {
|
||||
log.Printf("[beacon] handler function returned an error: %s", err)
|
||||
}
|
||||
log.Printf("[beacon] task completed (id: %d)", taskID)
|
||||
// {{end}}
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: taskID,
|
||||
Data: data,
|
||||
}
|
||||
})
|
||||
}()
|
||||
// {{end}}
|
||||
} else if task.Type == sliverpb.MsgOpenSession {
|
||||
go openSessionHandler(task.Data)
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: task.ID,
|
||||
Data: []byte{},
|
||||
}
|
||||
} else if handler, ok := specHandlers[task.Type]; ok {
|
||||
go handler(task.Data, nil)
|
||||
} else {
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: task.ID,
|
||||
UnknownMessageType: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beaconHandleTasklist(tasks []*sliverpb.Envelope) []*sliverpb.Envelope {
|
||||
results := []*sliverpb.Envelope{}
|
||||
resultsMutex := &sync.Mutex{}
|
||||
+4661
-2851
File diff suppressed because it is too large.
Load diff
@@ -148,6 +148,7 @@ message ImplantConfig {
|
||||
bool ObfuscateSymbols = 12;
|
||||
string TemplateName = 13;
|
||||
bool SGNEnabled = 14;
|
||||
string GoPackage = 15;
|
||||
|
||||
bool IncludeMTLS = 53;
|
||||
bool IncludeHTTP = 16;
|
||||
|
||||
+151
-61
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.3
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.26.0
|
||||
// protoc v6.33.2
|
||||
// source: commonpb/common.proto
|
||||
|
||||
package commonpb
|
||||
@@ -21,16 +21,18 @@ const (
|
||||
)
|
||||
|
||||
type Empty struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *Empty) Reset() {
|
||||
*x = Empty{}
|
||||
mi := &file_commonpb_common_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_commonpb_common_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Empty) String() string {
|
||||
@@ -41,7 +43,7 @@ func (*Empty) ProtoMessage() {}
|
||||
|
||||
func (x *Empty) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_commonpb_common_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -58,20 +60,23 @@ func (*Empty) Descriptor() ([]byte, []int) {
|
||||
|
||||
// Request - Common fields used in all gRPC requests
|
||||
type Request struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Async bool `protobuf:"varint,1,opt,name=Async,proto3" json:"Async,omitempty"`
|
||||
Timeout int64 `protobuf:"varint,2,opt,name=Timeout,proto3" json:"Timeout,omitempty"`
|
||||
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
|
||||
SessionID string `protobuf:"bytes,9,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Async bool `protobuf:"varint,1,opt,name=Async,proto3" json:"Async,omitempty"`
|
||||
Timeout int64 `protobuf:"varint,2,opt,name=Timeout,proto3" json:"Timeout,omitempty"`
|
||||
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
|
||||
SessionID string `protobuf:"bytes,9,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Request) Reset() {
|
||||
*x = Request{}
|
||||
mi := &file_commonpb_common_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_commonpb_common_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Request) String() string {
|
||||
@@ -82,7 +87,7 @@ func (*Request) ProtoMessage() {}
|
||||
|
||||
func (x *Request) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_commonpb_common_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -126,24 +131,26 @@ func (x *Request) GetSessionID() string {
|
||||
}
|
||||
|
||||
// Response - Common fields used in all gRPC responses. Note that the Err field
|
||||
//
|
||||
// only used when the implant needs to return an error to the server.
|
||||
// Client<->Server comms should use normal gRPC error handling.
|
||||
// only used when the implant needs to return an error to the server.
|
||||
// Client<->Server comms should use normal gRPC error handling.
|
||||
type Response struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Err string `protobuf:"bytes,1,opt,name=Err,proto3" json:"Err,omitempty"`
|
||||
Async bool `protobuf:"varint,2,opt,name=Async,proto3" json:"Async,omitempty"`
|
||||
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
|
||||
TaskID string `protobuf:"bytes,9,opt,name=TaskID,proto3" json:"TaskID,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Err string `protobuf:"bytes,1,opt,name=Err,proto3" json:"Err,omitempty"`
|
||||
Async bool `protobuf:"varint,2,opt,name=Async,proto3" json:"Async,omitempty"`
|
||||
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
|
||||
TaskID string `protobuf:"bytes,9,opt,name=TaskID,proto3" json:"TaskID,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Response) Reset() {
|
||||
*x = Response{}
|
||||
mi := &file_commonpb_common_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_commonpb_common_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Response) String() string {
|
||||
@@ -154,7 +161,7 @@ func (*Response) ProtoMessage() {}
|
||||
|
||||
func (x *Response) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_commonpb_common_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -199,18 +206,21 @@ func (x *Response) GetTaskID() string {
|
||||
|
||||
// File - A basic file data type
|
||||
type File struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Name string `protobuf:"bytes,1,opt,name=Name,proto3" json:"Name,omitempty"`
|
||||
Data []byte `protobuf:"bytes,2,opt,name=Data,proto3" json:"Data,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Name string `protobuf:"bytes,1,opt,name=Name,proto3" json:"Name,omitempty"`
|
||||
Data []byte `protobuf:"bytes,2,opt,name=Data,proto3" json:"Data,omitempty"`
|
||||
}
|
||||
|
||||
func (x *File) Reset() {
|
||||
*x = File{}
|
||||
mi := &file_commonpb_common_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_commonpb_common_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *File) String() string {
|
||||
@@ -221,7 +231,7 @@ func (*File) ProtoMessage() {}
|
||||
|
||||
func (x *File) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_commonpb_common_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -252,23 +262,26 @@ func (x *File) GetData() []byte {
|
||||
|
||||
// Process - A basic process data type
|
||||
type Process struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Pid int32 `protobuf:"varint,1,opt,name=Pid,proto3" json:"Pid,omitempty"`
|
||||
Ppid int32 `protobuf:"varint,2,opt,name=Ppid,proto3" json:"Ppid,omitempty"`
|
||||
Executable string `protobuf:"bytes,3,opt,name=Executable,proto3" json:"Executable,omitempty"`
|
||||
Owner string `protobuf:"bytes,4,opt,name=Owner,proto3" json:"Owner,omitempty"`
|
||||
Architecture string `protobuf:"bytes,7,opt,name=Architecture,proto3" json:"Architecture,omitempty"`
|
||||
SessionID int32 `protobuf:"varint,5,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
|
||||
CmdLine []string `protobuf:"bytes,6,rep,name=CmdLine,proto3" json:"CmdLine,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Pid int32 `protobuf:"varint,1,opt,name=Pid,proto3" json:"Pid,omitempty"`
|
||||
Ppid int32 `protobuf:"varint,2,opt,name=Ppid,proto3" json:"Ppid,omitempty"`
|
||||
Executable string `protobuf:"bytes,3,opt,name=Executable,proto3" json:"Executable,omitempty"`
|
||||
Owner string `protobuf:"bytes,4,opt,name=Owner,proto3" json:"Owner,omitempty"`
|
||||
Architecture string `protobuf:"bytes,7,opt,name=Architecture,proto3" json:"Architecture,omitempty"`
|
||||
SessionID int32 `protobuf:"varint,5,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
|
||||
CmdLine []string `protobuf:"bytes,6,rep,name=CmdLine,proto3" json:"CmdLine,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Process) Reset() {
|
||||
*x = Process{}
|
||||
mi := &file_commonpb_common_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_commonpb_common_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Process) String() string {
|
||||
@@ -279,7 +292,7 @@ func (*Process) ProtoMessage() {}
|
||||
|
||||
func (x *Process) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_commonpb_common_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -345,18 +358,21 @@ func (x *Process) GetCmdLine() []string {
|
||||
|
||||
// EnvVar - Environment variable K/V
|
||||
type EnvVar struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Key string `protobuf:"bytes,1,opt,name=Key,proto3" json:"Key,omitempty"`
|
||||
Value string `protobuf:"bytes,2,opt,name=Value,proto3" json:"Value,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=Key,proto3" json:"Key,omitempty"`
|
||||
Value string `protobuf:"bytes,2,opt,name=Value,proto3" json:"Value,omitempty"`
|
||||
}
|
||||
|
||||
func (x *EnvVar) Reset() {
|
||||
*x = EnvVar{}
|
||||
mi := &file_commonpb_common_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_commonpb_common_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *EnvVar) String() string {
|
||||
@@ -367,7 +383,7 @@ func (*EnvVar) ProtoMessage() {}
|
||||
|
||||
func (x *EnvVar) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_commonpb_common_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -453,7 +469,7 @@ func file_commonpb_common_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_commonpb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_commonpb_common_proto_goTypes = []any{
|
||||
var file_commonpb_common_proto_goTypes = []interface{}{
|
||||
(*Empty)(nil), // 0: commonpb.Empty
|
||||
(*Request)(nil), // 1: commonpb.Request
|
||||
(*Response)(nil), // 2: commonpb.Response
|
||||
@@ -474,6 +490,80 @@ func file_commonpb_common_proto_init() {
|
||||
if File_commonpb_common_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_commonpb_common_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*Empty); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_commonpb_common_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*Request); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_commonpb_common_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*Response); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_commonpb_common_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*File); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_commonpb_common_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*Process); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_commonpb_common_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*EnvVar); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
|
||||
+37
-18
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.3
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.26.0
|
||||
// protoc v6.33.2
|
||||
// source: dnspb/dns.proto
|
||||
|
||||
package dnspb
|
||||
@@ -87,27 +87,32 @@ func (DNSMessageType) EnumDescriptor() ([]byte, []int) {
|
||||
return file_dnspb_dns_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
// NOTE: DNS is very space sensitive so certain fields are re-purposed
|
||||
// depending on the DNSMessageType as noted below:
|
||||
//
|
||||
// [Type TOTP]: ID field is used for the TOTP code
|
||||
//NOTE: DNS is very space sensitive so certain fields are re-purposed
|
||||
//depending on the DNSMessageType as noted below:
|
||||
//
|
||||
//[Type TOTP]: ID field is used for the TOTP code
|
||||
//
|
||||
type DNSMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Type DNSMessageType `protobuf:"varint,1,opt,name=Type,proto3,enum=dnspb.DNSMessageType" json:"Type,omitempty"`
|
||||
ID uint32 `protobuf:"varint,2,opt,name=ID,proto3" json:"ID,omitempty"` // 8 bit message id + 24 bit dns session ID
|
||||
Start uint32 `protobuf:"varint,3,opt,name=Start,proto3" json:"Start,omitempty"` // Bytes start at
|
||||
Stop uint32 `protobuf:"varint,4,opt,name=Stop,proto3" json:"Stop,omitempty"` // Bytes stop at
|
||||
Size uint32 `protobuf:"varint,5,opt,name=Size,proto3" json:"Size,omitempty"` // Total size
|
||||
Data []byte `protobuf:"bytes,6,opt,name=Data,proto3" json:"Data,omitempty"` // Actual data
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Type DNSMessageType `protobuf:"varint,1,opt,name=Type,proto3,enum=dnspb.DNSMessageType" json:"Type,omitempty"`
|
||||
ID uint32 `protobuf:"varint,2,opt,name=ID,proto3" json:"ID,omitempty"` // 8 bit message id + 24 bit dns session ID
|
||||
Start uint32 `protobuf:"varint,3,opt,name=Start,proto3" json:"Start,omitempty"` // Bytes start at
|
||||
Stop uint32 `protobuf:"varint,4,opt,name=Stop,proto3" json:"Stop,omitempty"` // Bytes stop at
|
||||
Size uint32 `protobuf:"varint,5,opt,name=Size,proto3" json:"Size,omitempty"` // Total size
|
||||
Data []byte `protobuf:"bytes,6,opt,name=Data,proto3" json:"Data,omitempty"` // Actual data
|
||||
}
|
||||
|
||||
func (x *DNSMessage) Reset() {
|
||||
*x = DNSMessage{}
|
||||
mi := &file_dnspb_dns_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_dnspb_dns_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *DNSMessage) String() string {
|
||||
@@ -118,7 +123,7 @@ func (*DNSMessage) ProtoMessage() {}
|
||||
|
||||
func (x *DNSMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_dnspb_dns_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -218,7 +223,7 @@ func file_dnspb_dns_proto_rawDescGZIP() []byte {
|
||||
|
||||
var file_dnspb_dns_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_dnspb_dns_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
|
||||
var file_dnspb_dns_proto_goTypes = []any{
|
||||
var file_dnspb_dns_proto_goTypes = []interface{}{
|
||||
(DNSMessageType)(0), // 0: dnspb.DNSMessageType
|
||||
(*DNSMessage)(nil), // 1: dnspb.DNSMessage
|
||||
}
|
||||
@@ -236,6 +241,20 @@ func file_dnspb_dns_proto_init() {
|
||||
if File_dnspb_dns_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_dnspb_dns_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*DNSMessage); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.3
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.26.0
|
||||
// protoc v6.33.2
|
||||
// source: rpcpb/services.proto
|
||||
|
||||
package rpcpb
|
||||
@@ -724,7 +724,7 @@ var file_rpcpb_services_proto_rawDesc = []byte{
|
||||
0x70, 0x63, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var file_rpcpb_services_proto_goTypes = []any{
|
||||
var file_rpcpb_services_proto_goTypes = []interface{}{
|
||||
(*commonpb.Empty)(nil), // 0: commonpb.Empty
|
||||
(*clientpb.ClientLogData)(nil), // 1: clientpb.ClientLogData
|
||||
(*sliverpb.KillReq)(nil), // 2: sliverpb.KillReq
|
||||
|
||||
+611
-802
File diff suppressed because it is too large.
Load diff
+4459
-1730
File diff suppressed because it is too large.
Load diff
@@ -277,7 +277,7 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
}
|
||||
sliverpbGoSrc = xorPBRawBytes(sliverpbGoSrc)
|
||||
sliverpbGoSrc = stripSliverpb(sliverpbGoSrc)
|
||||
sliverpbDir := filepath.Join(goPathSrc, "github.com", "bishopfox", "sliver", "protobuf", "sliverpb")
|
||||
sliverpbDir := filepath.Join(goPathSrc, "protobuf", "sliverpb")
|
||||
setupLog.Infof("Creating sliverpb directory: %s", sliverpbDir)
|
||||
os.MkdirAll(sliverpbDir, 0700)
|
||||
os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600)
|
||||
@@ -290,7 +290,7 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
return err
|
||||
}
|
||||
commonpbSrc = xorPBRawBytes(commonpbSrc)
|
||||
commonpbDir := filepath.Join(goPathSrc, "github.com", "bishopfox", "sliver", "protobuf", "commonpb")
|
||||
commonpbDir := filepath.Join(goPathSrc, "protobuf", "commonpb")
|
||||
os.MkdirAll(commonpbDir, 0700)
|
||||
os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600)
|
||||
|
||||
@@ -302,7 +302,7 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
return err
|
||||
}
|
||||
dnspbSrc = xorPBRawBytes(dnspbSrc)
|
||||
dnspbDir := filepath.Join(goPathSrc, "github.com", "bishopfox", "sliver", "protobuf", "dnspb")
|
||||
dnspbDir := filepath.Join(goPathSrc, "protobuf", "dnspb")
|
||||
os.MkdirAll(dnspbDir, 0700)
|
||||
os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600)
|
||||
}
|
||||
|
||||
+5
-1
@@ -69,6 +69,7 @@ var (
|
||||
|
||||
const (
|
||||
DefaultMaxBodyLength = 2 * 1024 * 1024 * 1024 // 2Gb
|
||||
DefaultMaxUnauthBodyLength = 8 * 1024 * 1024 // 8Mb
|
||||
DefaultHTTPTimeout = time.Minute
|
||||
DefaultLongPollTimeout = time.Second
|
||||
DefaultLongPollJitter = time.Second
|
||||
@@ -564,7 +565,10 @@ func (s *SliverHTTPC2) anonymousHandler(resp http.ResponseWriter, req *http.Requ
|
||||
func (s *SliverHTTPC2) startSessionHandler(resp http.ResponseWriter, req *http.Request, encoder sliverEncoders.Encoder) {
|
||||
httpLog.Debug("Start http session request")
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
body, err := io.ReadAll(&io.LimitedReader{
|
||||
R: req.Body,
|
||||
N: int64(DefaultMaxUnauthBodyLength),
|
||||
})
|
||||
if err != nil {
|
||||
httpLog.Errorf("Failed to read body %s", err)
|
||||
s.defaultHandler(resp, req)
|
||||
|
||||
+10
-1
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
clientcli "github.com/bishopfox/sliver/client/cli"
|
||||
"github.com/bishopfox/sliver/server/assets"
|
||||
"github.com/bishopfox/sliver/server/c2"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
@@ -72,6 +73,8 @@ func initConsoleLogging(appDir string) *os.File {
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().String(clientcli.RCFlagName, "", "path to rc script file")
|
||||
|
||||
// Unpack
|
||||
unpackCmd.Flags().BoolP(forceFlagStr, "f", false, "Force unpack and overwrite")
|
||||
rootCmd.AddCommand(unpackCmd)
|
||||
@@ -147,8 +150,14 @@ var rootCmd = &cobra.Command{
|
||||
if serverConfig.DaemonMode {
|
||||
daemon.Start(daemon.BlankHost, daemon.BlankPort, serverConfig.DaemonConfig.Tailscale)
|
||||
} else {
|
||||
rcScript, err := clientcli.ReadRCScript(cmd)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read rc script: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
os.Args = os.Args[:1] // Hide cli from grumble console
|
||||
console.Start()
|
||||
console.Start(rcScript)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,12 +28,15 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
"github.com/bishopfox/sliver/server/console/forms"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
@@ -172,14 +175,54 @@ func NewOperatorConfig(operatorName string, lhost string, lport uint16, permissi
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
func kickOperatorCmd(cmd *cobra.Command, _ []string) {
|
||||
func kickOperatorCmd(cmd *cobra.Command, args []string) {
|
||||
operator, _ := cmd.Flags().GetString("name")
|
||||
|
||||
if shouldPromptKickOperator(cmd, args) {
|
||||
names, err := operatorNames()
|
||||
if err != nil {
|
||||
fmt.Printf(Warn+"Failed to list operators: %v\n", err)
|
||||
return
|
||||
}
|
||||
if len(names) == 0 {
|
||||
fmt.Printf(Warn + "No operators found.\n")
|
||||
return
|
||||
}
|
||||
if err := forms.SelectOperator("Select operator to kick", names, &operator); err != nil {
|
||||
if errors.Is(err, forms.ErrUserAborted) {
|
||||
return
|
||||
}
|
||||
fmt.Printf(Warn+"Operator selection failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
if err := cmd.Flags().Set("name", operator); err != nil {
|
||||
fmt.Printf(Warn+"Failed to set operator name: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" {
|
||||
fmt.Printf(Warn + "Operator name required (use --name).\n")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := operatorExists(operator)
|
||||
if err != nil {
|
||||
fmt.Printf(Warn+"Failed to lookup operator %s: %v\n", operator, err)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
fmt.Printf(Warn+"Operator %s does not exist.\n", operator)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(Info+"Removing auth token(s) for %s, please wait ... \n", operator)
|
||||
err := db.Session().Where(&models.Operator{
|
||||
err = db.Session().Where(&models.Operator{
|
||||
Name: operator,
|
||||
}).Delete(&models.Operator{}).Error
|
||||
if err != nil {
|
||||
fmt.Printf(Warn+"Failed to remove operator %s: %v\n", operator, err)
|
||||
return
|
||||
}
|
||||
transport.ClearTokenCache()
|
||||
@@ -192,6 +235,55 @@ func kickOperatorCmd(cmd *cobra.Command, _ []string) {
|
||||
fmt.Printf(Info+"Operator %s has been kicked out.\n", operator)
|
||||
}
|
||||
|
||||
func shouldPromptKickOperator(cmd *cobra.Command, args []string) bool {
|
||||
if len(args) != 0 {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().NFlag() == 0
|
||||
}
|
||||
|
||||
func operatorNames() ([]string, error) {
|
||||
operators, err := db.OperatorAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(operators))
|
||||
seen := make(map[string]struct{}, len(operators))
|
||||
for _, operator := range operators {
|
||||
if operator == nil {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(operator.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func operatorExists(name string) (bool, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return false, nil
|
||||
}
|
||||
err := db.Session().Where(&models.Operator{
|
||||
Name: name,
|
||||
}).First(&models.Operator{}).Error
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, db.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func startMultiplayerModeCmd(cmd *cobra.Command, _ []string) {
|
||||
lhost, _ := cmd.Flags().GetString("lhost")
|
||||
lport, _ := cmd.Flags().GetUint16("lport")
|
||||
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
)
|
||||
|
||||
// Start - Starts the server console
|
||||
func Start() {
|
||||
func Start(rcScript string) {
|
||||
_, ln, _ := transport.LocalListener()
|
||||
ctxDialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return ln.Dial()
|
||||
@@ -57,7 +57,7 @@ func Start() {
|
||||
defer conn.Close()
|
||||
localRPC := rpcpb.NewSliverRPCClient(conn)
|
||||
con := console.NewConsole(false)
|
||||
console.StartClient(con, localRPC, command.ServerCommands(con, serverOnlyCmds), command.SliverCommands(con), true)
|
||||
console.StartClient(con, localRPC, command.ServerCommands(con, serverOnlyCmds), command.SliverCommands(con), true, rcScript)
|
||||
|
||||
con.App.Start()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
const defaultSelectHeight = 10
|
||||
|
||||
// ErrUserAborted exposes the huh user-abort sentinel for callers.
|
||||
var ErrUserAborted = huh.ErrUserAborted
|
||||
|
||||
// SelectOperator prompts for a single operator selection.
|
||||
func SelectOperator(title string, operators []string, value *string) error {
|
||||
if value == nil {
|
||||
return errors.New("operator selection value is required")
|
||||
}
|
||||
if len(operators) == 0 {
|
||||
return errors.New("operator selection options are required")
|
||||
}
|
||||
|
||||
ensureSelectedValue(operators, value)
|
||||
|
||||
field := huh.NewSelect[string]().
|
||||
Title(title).
|
||||
Options(makeStringOptions(operators)...).
|
||||
Height(listHeight(len(operators))).
|
||||
Value(value).
|
||||
Validate(func(val string) error {
|
||||
if strings.TrimSpace(val) == "" {
|
||||
return errors.New("selection required")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(field))
|
||||
return form.Run()
|
||||
}
|
||||
|
||||
func ensureSelectedValue(options []string, value *string) {
|
||||
if value == nil || len(options) == 0 {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(*value) == "" {
|
||||
*value = options[0]
|
||||
return
|
||||
}
|
||||
for _, option := range options {
|
||||
if option == *value {
|
||||
return
|
||||
}
|
||||
}
|
||||
*value = options[0]
|
||||
}
|
||||
|
||||
func makeStringOptions(options []string) []huh.Option[string] {
|
||||
converted := make([]huh.Option[string], 0, len(options))
|
||||
for _, option := range options {
|
||||
converted = append(converted, huh.NewOption(option, option))
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func listHeight(count int) int {
|
||||
if count < 1 {
|
||||
return 1
|
||||
}
|
||||
if count > defaultSelectHeight {
|
||||
return defaultSelectHeight
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -40,9 +40,11 @@ import (
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"github.com/bishopfox/sliver/server/encoders"
|
||||
"github.com/bishopfox/sliver/server/gogo"
|
||||
"github.com/bishopfox/sliver/server/gogo/goname"
|
||||
"github.com/bishopfox/sliver/server/log"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
utilEncoders "github.com/bishopfox/sliver/util/encoders"
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
@@ -360,7 +362,7 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
|
||||
return "", err
|
||||
}
|
||||
|
||||
sliverPkgDir := filepath.Join(srcDir, "github.com", "bishopfox", "sliver") // "main"
|
||||
sliverPkgDir := filepath.Join(srcDir) // "main"
|
||||
err = os.MkdirAll(sliverPkgDir, 0700)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
@@ -380,14 +382,14 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
|
||||
sliverGoCode := string(sliverGoCodeRaw)
|
||||
|
||||
// Skip dllmain files for anything non windows
|
||||
if f.Name() == "sliver.c" || f.Name() == "sliver.h" {
|
||||
if f.Name() == "main.c" || f.Name() == "main.h" {
|
||||
if !config.IsSharedLib && !config.IsShellcode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var sliverCodePath string
|
||||
if f.Name() == "sliver.go" || f.Name() == "sliver.c" || f.Name() == "sliver.h" {
|
||||
if f.Name() == "main.go" || f.Name() == "main.c" || f.Name() == "main.h" {
|
||||
sliverCodePath = filepath.Join(sliverPkgDir, f.Name())
|
||||
} else {
|
||||
sliverCodePath = filepath.Join(sliverPkgDir, "implant", fsPath)
|
||||
@@ -521,6 +523,35 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
|
||||
}
|
||||
buildLog.Debugf("Created %s", goModPath)
|
||||
|
||||
if !config.Debug {
|
||||
goPackage := "github.com/google/gvisor"
|
||||
if config.GoPackage != "" {
|
||||
err := module.CheckPath(config.GoPackage)
|
||||
if err != nil {
|
||||
buildLog.Warnf("Invalid Go package path '%s', using default '%s'", config.GoPackage, goPackage)
|
||||
} else {
|
||||
goPackage = config.GoPackage
|
||||
}
|
||||
}
|
||||
modResult, err := goname.RenameModule(sliverPkgDir, goPackage)
|
||||
if err != nil {
|
||||
buildLog.Errorf("Failed to rename module: %s", err)
|
||||
return "", err
|
||||
}
|
||||
buildLog.Infof("Renamed module: %s -> %s (go.mod updated: %v)", modResult.OldModule, modResult.NewModule, modResult.GoModUpdated)
|
||||
|
||||
// Rename Import Paths
|
||||
buildLog.Infof("Renaming import paths ...")
|
||||
oldImportPrefix := path.Join(modResult.NewModule, "implant", "sliver")
|
||||
newImportPrefix := path.Join(modResult.NewModule, "runc", "cgroup")
|
||||
importResult, err := goname.RenameImport(sliverPkgDir, oldImportPrefix, newImportPrefix)
|
||||
if err != nil {
|
||||
buildLog.Errorf("Failed to rename imports: %s", err)
|
||||
return "", err
|
||||
}
|
||||
buildLog.Infof("Renamed imports (%d files updated)", importResult.FilesUpdated)
|
||||
}
|
||||
|
||||
return sliverPkgDir, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ func GoBuild(config GoConfig, src string, dest string, buildmode string, tags []
|
||||
var goCommand = []string{"build"}
|
||||
|
||||
goCommand = append(goCommand, "-trimpath") // remove absolute paths from any compiled binary
|
||||
goCommand = append(goCommand, "-mod=vendor")
|
||||
|
||||
if 0 < len(tags) {
|
||||
goCommand = append(goCommand, "-tags")
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package goname
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
// Result captures changes made by RenameModule.
|
||||
type Result struct {
|
||||
OldModule string
|
||||
NewModule string
|
||||
GoModUpdated bool
|
||||
FilesUpdated int
|
||||
ImportsUpdated int
|
||||
}
|
||||
|
||||
// RenameModule updates go.mod and rewrites import paths within the module tree.
|
||||
func RenameModule(dir, newModule string) (*Result, error) {
|
||||
newModule = strings.TrimSpace(newModule)
|
||||
if newModule == "" {
|
||||
return nil, errors.New("new module path is required")
|
||||
}
|
||||
|
||||
goModPath := filepath.Join(dir, "go.mod")
|
||||
modBytes, err := os.ReadFile(goModPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read go.mod: %w", err)
|
||||
}
|
||||
|
||||
modFile, err := modfile.Parse(goModPath, modBytes, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse go.mod: %w", err)
|
||||
}
|
||||
if modFile.Module == nil || modFile.Module.Mod.Path == "" {
|
||||
return nil, errors.New("go.mod is missing a module path")
|
||||
}
|
||||
|
||||
oldModule := modFile.Module.Mod.Path
|
||||
result := &Result{
|
||||
OldModule: oldModule,
|
||||
NewModule: newModule,
|
||||
}
|
||||
|
||||
if oldModule == newModule {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := modFile.AddModuleStmt(newModule); err != nil {
|
||||
return result, fmt.Errorf("update module path: %w", err)
|
||||
}
|
||||
formatted, err := modFile.Format()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("format go.mod: %w", err)
|
||||
}
|
||||
if err := writeFilePreservePerm(goModPath, formatted); err != nil {
|
||||
return result, fmt.Errorf("write go.mod: %w", err)
|
||||
}
|
||||
result.GoModUpdated = true
|
||||
|
||||
if err := rewriteImports(dir, oldModule, newModule, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func rewriteImports(dir, oldModule, newModule string, result *Result) error {
|
||||
return filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
name := entry.Name()
|
||||
if name == "vendor" || name == ".git" {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
|
||||
changed, importsUpdated, err := rewriteFileImports(path, oldModule, newModule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
result.FilesUpdated++
|
||||
result.ImportsUpdated += importsUpdated
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func rewriteFileImports(path, oldModule, newModule string) (bool, int, error) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
changed := false
|
||||
importsUpdated := 0
|
||||
for _, imp := range file.Imports {
|
||||
importPath, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("parse import in %s: %w", path, err)
|
||||
}
|
||||
|
||||
if !matchesModule(importPath, oldModule) {
|
||||
continue
|
||||
}
|
||||
|
||||
newPath := newModule + strings.TrimPrefix(importPath, oldModule)
|
||||
if newPath == importPath {
|
||||
continue
|
||||
}
|
||||
|
||||
imp.Path.Value = strconv.Quote(newPath)
|
||||
changed = true
|
||||
importsUpdated++
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := format.Node(&buf, fset, file); err != nil {
|
||||
return false, 0, fmt.Errorf("format %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := writeFilePreservePerm(path, buf.Bytes()); err != nil {
|
||||
return false, 0, fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return true, importsUpdated, nil
|
||||
}
|
||||
|
||||
func matchesModule(importPath, modulePath string) bool {
|
||||
return importPath == modulePath || strings.HasPrefix(importPath, modulePath+"/")
|
||||
}
|
||||
|
||||
func writeFilePreservePerm(path string, data []byte) error {
|
||||
perm := fs.FileMode(0644)
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
perm = info.Mode().Perm()
|
||||
}
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package goname
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
func TestRenameModule(t *testing.T) {
|
||||
fixture := filepath.Join("testdata", "basic")
|
||||
tmp := t.TempDir()
|
||||
if err := copyDir(fixture, tmp); err != nil {
|
||||
t.Fatalf("copy fixture: %v", err)
|
||||
}
|
||||
|
||||
result, err := RenameModule(tmp, "example.net/new")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameModule: %v", err)
|
||||
}
|
||||
|
||||
if result.OldModule != "example.com/old" {
|
||||
t.Fatalf("old module mismatch: %s", result.OldModule)
|
||||
}
|
||||
if result.NewModule != "example.net/new" {
|
||||
t.Fatalf("new module mismatch: %s", result.NewModule)
|
||||
}
|
||||
if !result.GoModUpdated {
|
||||
t.Fatalf("expected go.mod update")
|
||||
}
|
||||
if result.FilesUpdated != 2 {
|
||||
t.Fatalf("files updated mismatch: %d", result.FilesUpdated)
|
||||
}
|
||||
if result.ImportsUpdated != 3 {
|
||||
t.Fatalf("imports updated mismatch: %d", result.ImportsUpdated)
|
||||
}
|
||||
|
||||
modBytes, err := os.ReadFile(filepath.Join(tmp, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatalf("read go.mod: %v", err)
|
||||
}
|
||||
modFile, err := modfile.Parse("go.mod", modBytes, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("parse go.mod: %v", err)
|
||||
}
|
||||
if modFile.Module == nil || modFile.Module.Mod.Path != "example.net/new" {
|
||||
t.Fatalf("module path not updated: %#v", modFile.Module)
|
||||
}
|
||||
|
||||
mainImports := importPaths(t, filepath.Join(tmp, "main.go"))
|
||||
for _, want := range []string{"example.net/new", "example.net/new/pkg/foo", "example.com/oldish/pkg"} {
|
||||
if !mainImports[want] {
|
||||
t.Fatalf("main.go missing import: %s", want)
|
||||
}
|
||||
}
|
||||
if mainImports["example.com/old"] || mainImports["example.com/old/pkg/foo"] {
|
||||
t.Fatalf("main.go still references old module")
|
||||
}
|
||||
|
||||
fooImports := importPaths(t, filepath.Join(tmp, "pkg", "foo", "foo.go"))
|
||||
if !fooImports["example.net/new/pkg/bar"] {
|
||||
t.Fatalf("foo.go import not updated")
|
||||
}
|
||||
|
||||
vendorImports := importPaths(t, filepath.Join(tmp, "vendor", "example.com", "old", "vendor.go"))
|
||||
if !vendorImports["example.com/old/pkg/foo"] {
|
||||
t.Fatalf("vendor import should remain unchanged")
|
||||
}
|
||||
|
||||
mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mainBytes), "const example = \"example.com/old/pkg/foo\"") {
|
||||
t.Fatalf("string literal should remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameModuleMissingGoMod(t *testing.T) {
|
||||
_, err := RenameModule(t.TempDir(), "example.com/new")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing go.mod")
|
||||
}
|
||||
}
|
||||
|
||||
func importPaths(t *testing.T, path string) map[string]bool {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
|
||||
imports := make(map[string]bool)
|
||||
for _, imp := range file.Imports {
|
||||
pathValue, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("unquote import in %s: %v", path, err)
|
||||
}
|
||||
imports[pathValue] = true
|
||||
}
|
||||
return imports
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if entry.IsDir() {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(target, info.Mode().Perm())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package goname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ImportResult captures changes made by RenameImport.
|
||||
type ImportResult struct {
|
||||
OldImportPrefix string
|
||||
NewImportPrefix string
|
||||
FilesUpdated int
|
||||
ImportsUpdated int
|
||||
}
|
||||
|
||||
// RenameImport rewrites import paths that match oldImportPrefix within the directory tree.
|
||||
func RenameImport(dir, oldImportPrefix, newImportPrefix string) (*ImportResult, error) {
|
||||
oldImportPrefix = strings.TrimSpace(oldImportPrefix)
|
||||
newImportPrefix = strings.TrimSpace(newImportPrefix)
|
||||
if oldImportPrefix == "" {
|
||||
return nil, errors.New("old import prefix is required")
|
||||
}
|
||||
if newImportPrefix == "" {
|
||||
return nil, errors.New("new import prefix is required")
|
||||
}
|
||||
|
||||
result := &ImportResult{
|
||||
OldImportPrefix: oldImportPrefix,
|
||||
NewImportPrefix: newImportPrefix,
|
||||
}
|
||||
|
||||
if err := rewriteImportPrefixes(dir, oldImportPrefix, newImportPrefix, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.ImportsUpdated == 0 {
|
||||
return result, errors.New("no imports updated (check old/new import prefixes)")
|
||||
}
|
||||
if err := renameImportDir(dir, oldImportPrefix, newImportPrefix); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func rewriteImportPrefixes(dir, oldImportPrefix, newImportPrefix string, result *ImportResult) error {
|
||||
return filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
name := entry.Name()
|
||||
if name == "vendor" || strings.HasPrefix(name, ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
|
||||
changed, importsUpdated, err := rewriteFileImports(path, oldImportPrefix, newImportPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
result.FilesUpdated++
|
||||
result.ImportsUpdated += importsUpdated
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func renameImportDir(dir, oldImportPrefix, newImportPrefix string) error {
|
||||
oldParts := splitImportPrefix(oldImportPrefix)
|
||||
newParts := splitImportPrefix(newImportPrefix)
|
||||
if len(oldParts) == 0 || len(newParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < len(oldParts); i++ {
|
||||
suffixParts := oldParts[i:]
|
||||
oldDir := filepath.Join(append([]string{dir}, suffixParts...)...)
|
||||
info, err := os.Stat(oldDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(newParts) < len(suffixParts) {
|
||||
return fmt.Errorf("new import prefix has fewer path segments than old import prefix")
|
||||
}
|
||||
newSuffixParts := newParts[len(newParts)-len(suffixParts):]
|
||||
newDir := filepath.Join(append([]string{dir}, newSuffixParts...)...)
|
||||
|
||||
if oldDir == newDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(newDir); err == nil {
|
||||
return fmt.Errorf("rename import dir: target exists: %s", newDir)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(newDir), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(oldDir, newDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitImportPrefix(prefix string) []string {
|
||||
cleaned := path.Clean(strings.TrimSpace(prefix))
|
||||
if cleaned == "." || cleaned == "/" {
|
||||
return nil
|
||||
}
|
||||
cleaned = strings.TrimPrefix(cleaned, "/")
|
||||
if cleaned == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(cleaned, "/")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package goname
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenameImportBasic(t *testing.T) {
|
||||
fixture := filepath.Join("testdata", "rename-import", "basic")
|
||||
tmp := t.TempDir()
|
||||
if err := copyDir(fixture, tmp); err != nil {
|
||||
t.Fatalf("copy fixture: %v", err)
|
||||
}
|
||||
|
||||
result, err := RenameImport(tmp, "foo/bar", "acme/corp")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameImport: %v", err)
|
||||
}
|
||||
|
||||
if result.OldImportPrefix != "foo/bar" {
|
||||
t.Fatalf("old prefix mismatch: %s", result.OldImportPrefix)
|
||||
}
|
||||
if result.NewImportPrefix != "acme/corp" {
|
||||
t.Fatalf("new prefix mismatch: %s", result.NewImportPrefix)
|
||||
}
|
||||
if result.FilesUpdated != 2 {
|
||||
t.Fatalf("files updated mismatch: %d", result.FilesUpdated)
|
||||
}
|
||||
if result.ImportsUpdated != 4 {
|
||||
t.Fatalf("imports updated mismatch: %d", result.ImportsUpdated)
|
||||
}
|
||||
|
||||
mainImports := importPaths(t, filepath.Join(tmp, "main.go"))
|
||||
for _, want := range []string{"acme/corp", "acme/corp/baz", "acme/corp/qux", "foo/barista", "other/pkg"} {
|
||||
if !mainImports[want] {
|
||||
t.Fatalf("main.go missing import: %s", want)
|
||||
}
|
||||
}
|
||||
if mainImports["foo/bar"] || mainImports["foo/bar/baz"] || mainImports["foo/bar/qux"] {
|
||||
t.Fatalf("main.go still references old prefix")
|
||||
}
|
||||
|
||||
barImports := importPaths(t, filepath.Join(tmp, "acme", "corp", "bar.go"))
|
||||
if !barImports["acme/corp/baz"] {
|
||||
t.Fatalf("bar.go import not updated")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(tmp, "foo", "bar")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected foo/bar to be renamed")
|
||||
}
|
||||
|
||||
vendorImports := importPaths(t, filepath.Join(tmp, "vendor", "foo", "bar", "vendor.go"))
|
||||
if !vendorImports["foo/bar/baz"] {
|
||||
t.Fatalf("vendor import should remain unchanged")
|
||||
}
|
||||
|
||||
hiddenImports := importPaths(t, filepath.Join(tmp, ".hidden", "hidden.go"))
|
||||
if !hiddenImports["foo/bar/baz"] {
|
||||
t.Fatalf("hidden import should remain unchanged")
|
||||
}
|
||||
|
||||
mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mainBytes), "const example = \"foo/bar/baz\"") {
|
||||
t.Fatalf("string literal should remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameImportNoChanges(t *testing.T) {
|
||||
fixture := filepath.Join("testdata", "rename-import", "basic")
|
||||
tmp := t.TempDir()
|
||||
if err := copyDir(fixture, tmp); err != nil {
|
||||
t.Fatalf("copy fixture: %v", err)
|
||||
}
|
||||
|
||||
_, err := RenameImport(tmp, "missing/prefix", "acme/corp")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameImportInvalidArgs(t *testing.T) {
|
||||
_, err := RenameImport(t.TempDir(), "", "acme/corp")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty old prefix")
|
||||
}
|
||||
_, err = RenameImport(t.TempDir(), "foo/bar", "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty new prefix")
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
module example.com/old
|
||||
|
||||
go 1.22
|
||||
|
||||
require example.com/other v1.2.3
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"example.com/old"
|
||||
oldpkg "example.com/old/pkg/foo"
|
||||
_ "example.com/oldish/pkg"
|
||||
)
|
||||
|
||||
const example = "example.com/old/pkg/foo"
|
||||
|
||||
func main() {
|
||||
fmt.Println(oldpkg.Name, example)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package bar
|
||||
|
||||
const Name = "bar"
|
||||
@@ -0,0 +1,5 @@
|
||||
package foo
|
||||
|
||||
import "example.com/old/pkg/bar"
|
||||
|
||||
const Name = bar.Name
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package vendor
|
||||
|
||||
import "example.com/old/pkg/foo"
|
||||
|
||||
const Vendor = foo.Name
|
||||
@@ -0,0 +1,3 @@
|
||||
package hidden
|
||||
|
||||
import _ "foo/bar/baz"
|
||||
@@ -0,0 +1,9 @@
|
||||
package bar
|
||||
|
||||
import "foo/bar/baz"
|
||||
|
||||
const Name = "bar"
|
||||
|
||||
func Use() string {
|
||||
return baz.Name
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package baz
|
||||
|
||||
const Name = "baz"
|
||||
@@ -0,0 +1,3 @@
|
||||
package qux
|
||||
|
||||
const Name = "qux"
|
||||
@@ -0,0 +1,3 @@
|
||||
package barista
|
||||
|
||||
const Name = "barista"
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/demo
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
bar "foo/bar"
|
||||
_ "foo/bar/baz"
|
||||
_ "foo/bar/qux"
|
||||
_ "foo/barista"
|
||||
"other/pkg"
|
||||
)
|
||||
|
||||
const example = "foo/bar/baz"
|
||||
|
||||
func main() {
|
||||
fmt.Println(bar.Name, example)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package vendor
|
||||
|
||||
import _ "foo/bar/baz"
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# generic-list-go [](https://github.com/bahlo/generic-list-go/actions/workflows/ci.yml)
|
||||
|
||||
Go [container/list](https://pkg.go.dev/container/list) but with generics.
|
||||
|
||||
The code is based on `container/list` in `go1.18beta2`.
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package list implements a doubly linked list.
|
||||
//
|
||||
// To iterate over a list (where l is a *List):
|
||||
// for e := l.Front(); e != nil; e = e.Next() {
|
||||
// // do something with e.Value
|
||||
// }
|
||||
//
|
||||
package list
|
||||
|
||||
// Element is an element of a linked list.
|
||||
type Element[T any] struct {
|
||||
// Next and previous pointers in the doubly-linked list of elements.
|
||||
// To simplify the implementation, internally a list l is implemented
|
||||
// as a ring, such that &l.root is both the next element of the last
|
||||
// list element (l.Back()) and the previous element of the first list
|
||||
// element (l.Front()).
|
||||
next, prev *Element[T]
|
||||
|
||||
// The list to which this element belongs.
|
||||
list *List[T]
|
||||
|
||||
// The value stored with this element.
|
||||
Value T
|
||||
}
|
||||
|
||||
// Next returns the next list element or nil.
|
||||
func (e *Element[T]) Next() *Element[T] {
|
||||
if p := e.next; e.list != nil && p != &e.list.root {
|
||||
return p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prev returns the previous list element or nil.
|
||||
func (e *Element[T]) Prev() *Element[T] {
|
||||
if p := e.prev; e.list != nil && p != &e.list.root {
|
||||
return p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List represents a doubly linked list.
|
||||
// The zero value for List is an empty list ready to use.
|
||||
type List[T any] struct {
|
||||
root Element[T] // sentinel list element, only &root, root.prev, and root.next are used
|
||||
len int // current list length excluding (this) sentinel element
|
||||
}
|
||||
|
||||
// Init initializes or clears list l.
|
||||
func (l *List[T]) Init() *List[T] {
|
||||
l.root.next = &l.root
|
||||
l.root.prev = &l.root
|
||||
l.len = 0
|
||||
return l
|
||||
}
|
||||
|
||||
// New returns an initialized list.
|
||||
func New[T any]() *List[T] { return new(List[T]).Init() }
|
||||
|
||||
// Len returns the number of elements of list l.
|
||||
// The complexity is O(1).
|
||||
func (l *List[T]) Len() int { return l.len }
|
||||
|
||||
// Front returns the first element of list l or nil if the list is empty.
|
||||
func (l *List[T]) Front() *Element[T] {
|
||||
if l.len == 0 {
|
||||
return nil
|
||||
}
|
||||
return l.root.next
|
||||
}
|
||||
|
||||
// Back returns the last element of list l or nil if the list is empty.
|
||||
func (l *List[T]) Back() *Element[T] {
|
||||
if l.len == 0 {
|
||||
return nil
|
||||
}
|
||||
return l.root.prev
|
||||
}
|
||||
|
||||
// lazyInit lazily initializes a zero List value.
|
||||
func (l *List[T]) lazyInit() {
|
||||
if l.root.next == nil {
|
||||
l.Init()
|
||||
}
|
||||
}
|
||||
|
||||
// insert inserts e after at, increments l.len, and returns e.
|
||||
func (l *List[T]) insert(e, at *Element[T]) *Element[T] {
|
||||
e.prev = at
|
||||
e.next = at.next
|
||||
e.prev.next = e
|
||||
e.next.prev = e
|
||||
e.list = l
|
||||
l.len++
|
||||
return e
|
||||
}
|
||||
|
||||
// insertValue is a convenience wrapper for insert(&Element{Value: v}, at).
|
||||
func (l *List[T]) insertValue(v T, at *Element[T]) *Element[T] {
|
||||
return l.insert(&Element[T]{Value: v}, at)
|
||||
}
|
||||
|
||||
// remove removes e from its list, decrements l.len
|
||||
func (l *List[T]) remove(e *Element[T]) {
|
||||
e.prev.next = e.next
|
||||
e.next.prev = e.prev
|
||||
e.next = nil // avoid memory leaks
|
||||
e.prev = nil // avoid memory leaks
|
||||
e.list = nil
|
||||
l.len--
|
||||
}
|
||||
|
||||
// move moves e to next to at.
|
||||
func (l *List[T]) move(e, at *Element[T]) {
|
||||
if e == at {
|
||||
return
|
||||
}
|
||||
e.prev.next = e.next
|
||||
e.next.prev = e.prev
|
||||
|
||||
e.prev = at
|
||||
e.next = at.next
|
||||
e.prev.next = e
|
||||
e.next.prev = e
|
||||
}
|
||||
|
||||
// Remove removes e from l if e is an element of list l.
|
||||
// It returns the element value e.Value.
|
||||
// The element must not be nil.
|
||||
func (l *List[T]) Remove(e *Element[T]) T {
|
||||
if e.list == l {
|
||||
// if e.list == l, l must have been initialized when e was inserted
|
||||
// in l or l == nil (e is a zero Element) and l.remove will crash
|
||||
l.remove(e)
|
||||
}
|
||||
return e.Value
|
||||
}
|
||||
|
||||
// PushFront inserts a new element e with value v at the front of list l and returns e.
|
||||
func (l *List[T]) PushFront(v T) *Element[T] {
|
||||
l.lazyInit()
|
||||
return l.insertValue(v, &l.root)
|
||||
}
|
||||
|
||||
// PushBack inserts a new element e with value v at the back of list l and returns e.
|
||||
func (l *List[T]) PushBack(v T) *Element[T] {
|
||||
l.lazyInit()
|
||||
return l.insertValue(v, l.root.prev)
|
||||
}
|
||||
|
||||
// InsertBefore inserts a new element e with value v immediately before mark and returns e.
|
||||
// If mark is not an element of l, the list is not modified.
|
||||
// The mark must not be nil.
|
||||
func (l *List[T]) InsertBefore(v T, mark *Element[T]) *Element[T] {
|
||||
if mark.list != l {
|
||||
return nil
|
||||
}
|
||||
// see comment in List.Remove about initialization of l
|
||||
return l.insertValue(v, mark.prev)
|
||||
}
|
||||
|
||||
// InsertAfter inserts a new element e with value v immediately after mark and returns e.
|
||||
// If mark is not an element of l, the list is not modified.
|
||||
// The mark must not be nil.
|
||||
func (l *List[T]) InsertAfter(v T, mark *Element[T]) *Element[T] {
|
||||
if mark.list != l {
|
||||
return nil
|
||||
}
|
||||
// see comment in List.Remove about initialization of l
|
||||
return l.insertValue(v, mark)
|
||||
}
|
||||
|
||||
// MoveToFront moves element e to the front of list l.
|
||||
// If e is not an element of l, the list is not modified.
|
||||
// The element must not be nil.
|
||||
func (l *List[T]) MoveToFront(e *Element[T]) {
|
||||
if e.list != l || l.root.next == e {
|
||||
return
|
||||
}
|
||||
// see comment in List.Remove about initialization of l
|
||||
l.move(e, &l.root)
|
||||
}
|
||||
|
||||
// MoveToBack moves element e to the back of list l.
|
||||
// If e is not an element of l, the list is not modified.
|
||||
// The element must not be nil.
|
||||
func (l *List[T]) MoveToBack(e *Element[T]) {
|
||||
if e.list != l || l.root.prev == e {
|
||||
return
|
||||
}
|
||||
// see comment in List.Remove about initialization of l
|
||||
l.move(e, l.root.prev)
|
||||
}
|
||||
|
||||
// MoveBefore moves element e to its new position before mark.
|
||||
// If e or mark is not an element of l, or e == mark, the list is not modified.
|
||||
// The element and mark must not be nil.
|
||||
func (l *List[T]) MoveBefore(e, mark *Element[T]) {
|
||||
if e.list != l || e == mark || mark.list != l {
|
||||
return
|
||||
}
|
||||
l.move(e, mark.prev)
|
||||
}
|
||||
|
||||
// MoveAfter moves element e to its new position after mark.
|
||||
// If e or mark is not an element of l, or e == mark, the list is not modified.
|
||||
// The element and mark must not be nil.
|
||||
func (l *List[T]) MoveAfter(e, mark *Element[T]) {
|
||||
if e.list != l || e == mark || mark.list != l {
|
||||
return
|
||||
}
|
||||
l.move(e, mark)
|
||||
}
|
||||
|
||||
// PushBackList inserts a copy of another list at the back of list l.
|
||||
// The lists l and other may be the same. They must not be nil.
|
||||
func (l *List[T]) PushBackList(other *List[T]) {
|
||||
l.lazyInit()
|
||||
for i, e := other.Len(), other.Front(); i > 0; i, e = i-1, e.Next() {
|
||||
l.insertValue(e.Value, l.root.prev)
|
||||
}
|
||||
}
|
||||
|
||||
// PushFrontList inserts a copy of another list at the front of list l.
|
||||
// The lists l and other may be the same. They must not be nil.
|
||||
func (l *List[T]) PushFrontList(other *List[T]) {
|
||||
l.lazyInit()
|
||||
for i, e := other.Len(), other.Back(); i > 0; i, e = i-1, e.Prev() {
|
||||
l.insertValue(e.Value, &l.root)
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
|
||||
*.test
|
||||
|
||||
*.out
|
||||
|
||||
*.mprof
|
||||
|
||||
.idea
|
||||
|
||||
vendor/github.com/buger/goterm/
|
||||
prof.cpu
|
||||
prof.mem
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
language: go
|
||||
arch:
|
||||
- amd64
|
||||
- ppc64le
|
||||
go:
|
||||
- 1.7.x
|
||||
- 1.8.x
|
||||
- 1.9.x
|
||||
- 1.10.x
|
||||
- 1.11.x
|
||||
script: go test -v ./.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.6
|
||||
|
||||
RUN go get github.com/Jeffail/gabs
|
||||
RUN go get github.com/bitly/go-simplejson
|
||||
RUN go get github.com/pquerna/ffjson
|
||||
RUN go get github.com/antonholmquist/jason
|
||||
RUN go get github.com/mreiferson/go-ujson
|
||||
RUN go get -tags=unsafe -u github.com/ugorji/go/codec
|
||||
RUN go get github.com/mailru/easyjson
|
||||
|
||||
WORKDIR /go/src/github.com/buger/jsonparser
|
||||
ADD . /go/src/github.com/buger/jsonparser
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 Leonid Bugaev
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
SOURCE = parser.go
|
||||
CONTAINER = jsonparser
|
||||
SOURCE_PATH = /go/src/github.com/buger/jsonparser
|
||||
BENCHMARK = JsonParser
|
||||
BENCHTIME = 5s
|
||||
TEST = .
|
||||
DRUN = docker run -v `pwd`:$(SOURCE_PATH) -i -t $(CONTAINER)
|
||||
|
||||
build:
|
||||
docker build -t $(CONTAINER) .
|
||||
|
||||
race:
|
||||
$(DRUN) --env GORACE="halt_on_error=1" go test ./. $(ARGS) -v -race -timeout 15s
|
||||
|
||||
bench:
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -benchtime $(BENCHTIME) -v
|
||||
|
||||
bench_local:
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench . $(ARGS) -benchtime $(BENCHTIME) -v
|
||||
|
||||
profile:
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -memprofile mem.mprof -v
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -cpuprofile cpu.out -v
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -c
|
||||
|
||||
test:
|
||||
$(DRUN) go test $(LDFLAGS) ./ -run $(TEST) -timeout 10s $(ARGS) -v
|
||||
|
||||
fmt:
|
||||
$(DRUN) go fmt ./...
|
||||
|
||||
vet:
|
||||
$(DRUN) go vet ./.
|
||||
|
||||
bash:
|
||||
$(DRUN) /bin/bash
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
[](https://goreportcard.com/report/github.com/buger/jsonparser) 
|
||||
# Alternative JSON parser for Go (10x times faster standard library)
|
||||
|
||||
It does not require you to know the structure of the payload (eg. create structs), and allows accessing fields by providing the path to them. It is up to **10 times faster** than standard `encoding/json` package (depending on payload size and usage), **allocates no memory**. See benchmarks below.
|
||||
|
||||
## Rationale
|
||||
Originally I made this for a project that relies on a lot of 3rd party APIs that can be unpredictable and complex.
|
||||
I love simplicity and prefer to avoid external dependecies. `encoding/json` requires you to know exactly your data structures, or if you prefer to use `map[string]interface{}` instead, it will be very slow and hard to manage.
|
||||
I investigated what's on the market and found that most libraries are just wrappers around `encoding/json`, there is few options with own parsers (`ffjson`, `easyjson`), but they still requires you to create data structures.
|
||||
|
||||
|
||||
Goal of this project is to push JSON parser to the performance limits and not sacrifice with compliance and developer user experience.
|
||||
|
||||
## Example
|
||||
For the given JSON our goal is to extract the user's full name, number of github followers and avatar.
|
||||
|
||||
```go
|
||||
import "github.com/buger/jsonparser"
|
||||
|
||||
...
|
||||
|
||||
data := []byte(`{
|
||||
"person": {
|
||||
"name": {
|
||||
"first": "Leonid",
|
||||
"last": "Bugaev",
|
||||
"fullName": "Leonid Bugaev"
|
||||
},
|
||||
"github": {
|
||||
"handle": "buger",
|
||||
"followers": 109
|
||||
},
|
||||
"avatars": [
|
||||
{ "url": "https://avatars1.githubusercontent.com/u/14009?v=3&s=460", "type": "thumbnail" }
|
||||
]
|
||||
},
|
||||
"company": {
|
||||
"name": "Acme"
|
||||
}
|
||||
}`)
|
||||
|
||||
// You can specify key path by providing arguments to Get function
|
||||
jsonparser.Get(data, "person", "name", "fullName")
|
||||
|
||||
// There is `GetInt` and `GetBoolean` helpers if you exactly know key data type
|
||||
jsonparser.GetInt(data, "person", "github", "followers")
|
||||
|
||||
// When you try to get object, it will return you []byte slice pointer to data containing it
|
||||
// In `company` it will be `{"name": "Acme"}`
|
||||
jsonparser.Get(data, "company")
|
||||
|
||||
// If the key doesn't exist it will throw an error
|
||||
var size int64
|
||||
if value, err := jsonparser.GetInt(data, "company", "size"); err == nil {
|
||||
size = value
|
||||
}
|
||||
|
||||
// You can use `ArrayEach` helper to iterate items [item1, item2 .... itemN]
|
||||
jsonparser.ArrayEach(data, func(value []byte, dataType jsonparser.ValueType, offset int, err error) {
|
||||
fmt.Println(jsonparser.Get(value, "url"))
|
||||
}, "person", "avatars")
|
||||
|
||||
// Or use can access fields by index!
|
||||
jsonparser.GetString(data, "person", "avatars", "[0]", "url")
|
||||
|
||||
// You can use `ObjectEach` helper to iterate objects { "key1":object1, "key2":object2, .... "keyN":objectN }
|
||||
jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
|
||||
fmt.Printf("Key: '%s'\n Value: '%s'\n Type: %s\n", string(key), string(value), dataType)
|
||||
return nil
|
||||
}, "person", "name")
|
||||
|
||||
// The most efficient way to extract multiple keys is `EachKey`
|
||||
|
||||
paths := [][]string{
|
||||
[]string{"person", "name", "fullName"},
|
||||
[]string{"person", "avatars", "[0]", "url"},
|
||||
[]string{"company", "url"},
|
||||
}
|
||||
jsonparser.EachKey(data, func(idx int, value []byte, vt jsonparser.ValueType, err error){
|
||||
switch idx {
|
||||
case 0: // []string{"person", "name", "fullName"}
|
||||
...
|
||||
case 1: // []string{"person", "avatars", "[0]", "url"}
|
||||
...
|
||||
case 2: // []string{"company", "url"},
|
||||
...
|
||||
}
|
||||
}, paths...)
|
||||
|
||||
// For more information see docs below
|
||||
```
|
||||
|
||||
## Need to speedup your app?
|
||||
|
||||
I'm available for consulting and can help you push your app performance to the limits. Ping me at: leonsbox@gmail.com.
|
||||
|
||||
## Reference
|
||||
|
||||
Library API is really simple. You just need the `Get` method to perform any operation. The rest is just helpers around it.
|
||||
|
||||
You also can view API at [godoc.org](https://godoc.org/github.com/buger/jsonparser)
|
||||
|
||||
|
||||
### **`Get`**
|
||||
```go
|
||||
func Get(data []byte, keys ...string) (value []byte, dataType jsonparser.ValueType, offset int, err error)
|
||||
```
|
||||
Receives data structure, and key path to extract value from.
|
||||
|
||||
Returns:
|
||||
* `value` - Pointer to original data structure containing key value, or just empty slice if nothing found or error
|
||||
* `dataType` - Can be: `NotExist`, `String`, `Number`, `Object`, `Array`, `Boolean` or `Null`
|
||||
* `offset` - Offset from provided data structure where key value ends. Used mostly internally, for example for `ArrayEach` helper.
|
||||
* `err` - If the key is not found or any other parsing issue, it should return error. If key not found it also sets `dataType` to `NotExist`
|
||||
|
||||
Accepts multiple keys to specify path to JSON value (in case of quering nested structures).
|
||||
If no keys are provided it will try to extract the closest JSON value (simple ones or object/array), useful for reading streams or arrays, see `ArrayEach` implementation.
|
||||
|
||||
Note that keys can be an array indexes: `jsonparser.GetInt("person", "avatars", "[0]", "url")`, pretty cool, yeah?
|
||||
|
||||
### **`GetString`**
|
||||
```go
|
||||
func GetString(data []byte, keys ...string) (val string, err error)
|
||||
```
|
||||
Returns strings properly handing escaped and unicode characters. Note that this will cause additional memory allocations.
|
||||
|
||||
### **`GetUnsafeString`**
|
||||
If you need string in your app, and ready to sacrifice with support of escaped symbols in favor of speed. It returns string mapped to existing byte slice memory, without any allocations:
|
||||
```go
|
||||
s, _, := jsonparser.GetUnsafeString(data, "person", "name", "title")
|
||||
switch s {
|
||||
case 'CEO':
|
||||
...
|
||||
case 'Engineer'
|
||||
...
|
||||
...
|
||||
}
|
||||
```
|
||||
Note that `unsafe` here means that your string will exist until GC will free underlying byte slice, for most of cases it means that you can use this string only in current context, and should not pass it anywhere externally: through channels or any other way.
|
||||
|
||||
|
||||
### **`GetBoolean`**, **`GetInt`** and **`GetFloat`**
|
||||
```go
|
||||
func GetBoolean(data []byte, keys ...string) (val bool, err error)
|
||||
|
||||
func GetFloat(data []byte, keys ...string) (val float64, err error)
|
||||
|
||||
func GetInt(data []byte, keys ...string) (val int64, err error)
|
||||
```
|
||||
If you know the key type, you can use the helpers above.
|
||||
If key data type do not match, it will return error.
|
||||
|
||||
### **`ArrayEach`**
|
||||
```go
|
||||
func ArrayEach(data []byte, cb func(value []byte, dataType jsonparser.ValueType, offset int, err error), keys ...string)
|
||||
```
|
||||
Needed for iterating arrays, accepts a callback function with the same return arguments as `Get`.
|
||||
|
||||
### **`ObjectEach`**
|
||||
```go
|
||||
func ObjectEach(data []byte, callback func(key []byte, value []byte, dataType ValueType, offset int) error, keys ...string) (err error)
|
||||
```
|
||||
Needed for iterating object, accepts a callback function. Example:
|
||||
```go
|
||||
var handler func([]byte, []byte, jsonparser.ValueType, int) error
|
||||
handler = func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
|
||||
//do stuff here
|
||||
}
|
||||
jsonparser.ObjectEach(myJson, handler)
|
||||
```
|
||||
|
||||
|
||||
### **`EachKey`**
|
||||
```go
|
||||
func EachKey(data []byte, cb func(idx int, value []byte, dataType jsonparser.ValueType, err error), paths ...[]string)
|
||||
```
|
||||
When you need to read multiple keys, and you do not afraid of low-level API `EachKey` is your friend. It read payload only single time, and calls callback function once path is found. For example when you call multiple times `Get`, it has to process payload multiple times, each time you call it. Depending on payload `EachKey` can be multiple times faster than `Get`. Path can use nested keys as well!
|
||||
|
||||
```go
|
||||
paths := [][]string{
|
||||
[]string{"uuid"},
|
||||
[]string{"tz"},
|
||||
[]string{"ua"},
|
||||
[]string{"st"},
|
||||
}
|
||||
var data SmallPayload
|
||||
|
||||
jsonparser.EachKey(smallFixture, func(idx int, value []byte, vt jsonparser.ValueType, err error){
|
||||
switch idx {
|
||||
case 0:
|
||||
data.Uuid, _ = value
|
||||
case 1:
|
||||
v, _ := jsonparser.ParseInt(value)
|
||||
data.Tz = int(v)
|
||||
case 2:
|
||||
data.Ua, _ = value
|
||||
case 3:
|
||||
v, _ := jsonparser.ParseInt(value)
|
||||
data.St = int(v)
|
||||
}
|
||||
}, paths...)
|
||||
```
|
||||
|
||||
### **`Set`**
|
||||
```go
|
||||
func Set(data []byte, setValue []byte, keys ...string) (value []byte, err error)
|
||||
```
|
||||
Receives existing data structure, key path to set, and value to set at that key. *This functionality is experimental.*
|
||||
|
||||
Returns:
|
||||
* `value` - Pointer to original data structure with updated or added key value.
|
||||
* `err` - If any parsing issue, it should return error.
|
||||
|
||||
Accepts multiple keys to specify path to JSON value (in case of updating or creating nested structures).
|
||||
|
||||
Note that keys can be an array indexes: `jsonparser.Set(data, []byte("http://github.com"), "person", "avatars", "[0]", "url")`
|
||||
|
||||
### **`Delete`**
|
||||
```go
|
||||
func Delete(data []byte, keys ...string) value []byte
|
||||
```
|
||||
Receives existing data structure, and key path to delete. *This functionality is experimental.*
|
||||
|
||||
Returns:
|
||||
* `value` - Pointer to original data structure with key path deleted if it can be found. If there is no key path, then the whole data structure is deleted.
|
||||
|
||||
Accepts multiple keys to specify path to JSON value (in case of updating or creating nested structures).
|
||||
|
||||
Note that keys can be an array indexes: `jsonparser.Delete(data, "person", "avatars", "[0]", "url")`
|
||||
|
||||
|
||||
## What makes it so fast?
|
||||
* It does not rely on `encoding/json`, `reflection` or `interface{}`, the only real package dependency is `bytes`.
|
||||
* Operates with JSON payload on byte level, providing you pointers to the original data structure: no memory allocation.
|
||||
* No automatic type conversions, by default everything is a []byte, but it provides you value type, so you can convert by yourself (there is few helpers included).
|
||||
* Does not parse full record, only keys you specified
|
||||
|
||||
|
||||
## Benchmarks
|
||||
|
||||
There are 3 benchmark types, trying to simulate real-life usage for small, medium and large JSON payloads.
|
||||
For each metric, the lower value is better. Time/op is in nanoseconds. Values better than standard encoding/json marked as bold text.
|
||||
Benchmarks run on standard Linode 1024 box.
|
||||
|
||||
Compared libraries:
|
||||
* https://golang.org/pkg/encoding/json
|
||||
* https://github.com/Jeffail/gabs
|
||||
* https://github.com/a8m/djson
|
||||
* https://github.com/bitly/go-simplejson
|
||||
* https://github.com/antonholmquist/jason
|
||||
* https://github.com/mreiferson/go-ujson
|
||||
* https://github.com/ugorji/go/codec
|
||||
* https://github.com/pquerna/ffjson
|
||||
* https://github.com/mailru/easyjson
|
||||
* https://github.com/buger/jsonparser
|
||||
|
||||
#### TLDR
|
||||
If you want to skip next sections we have 2 winner: `jsonparser` and `easyjson`.
|
||||
`jsonparser` is up to 10 times faster than standard `encoding/json` package (depending on payload size and usage), and almost infinitely (literally) better in memory consumption because it operates with data on byte level, and provide direct slice pointers.
|
||||
`easyjson` wins in CPU in medium tests and frankly i'm impressed with this package: it is remarkable results considering that it is almost drop-in replacement for `encoding/json` (require some code generation).
|
||||
|
||||
It's hard to fully compare `jsonparser` and `easyjson` (or `ffson`), they a true parsers and fully process record, unlike `jsonparser` which parse only keys you specified.
|
||||
|
||||
If you searching for replacement of `encoding/json` while keeping structs, `easyjson` is an amazing choice. If you want to process dynamic JSON, have memory constrains, or more control over your data you should try `jsonparser`.
|
||||
|
||||
`jsonparser` performance heavily depends on usage, and it works best when you do not need to process full record, only some keys. The more calls you need to make, the slower it will be, in contrast `easyjson` (or `ffjson`, `encoding/json`) parser record only 1 time, and then you can make as many calls as you want.
|
||||
|
||||
With great power comes great responsibility! :)
|
||||
|
||||
|
||||
#### Small payload
|
||||
|
||||
Each test processes 190 bytes of http log as a JSON record.
|
||||
It should read multiple fields.
|
||||
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_small_payload_test.go
|
||||
|
||||
Library | time/op | bytes/op | allocs/op
|
||||
------ | ------- | -------- | -------
|
||||
encoding/json struct | 7879 | 880 | 18
|
||||
encoding/json interface{} | 8946 | 1521 | 38
|
||||
Jeffail/gabs | 10053 | 1649 | 46
|
||||
bitly/go-simplejson | 10128 | 2241 | 36
|
||||
antonholmquist/jason | 27152 | 7237 | 101
|
||||
github.com/ugorji/go/codec | 8806 | 2176 | 31
|
||||
mreiferson/go-ujson | **7008** | **1409** | 37
|
||||
a8m/djson | 3862 | 1249 | 30
|
||||
pquerna/ffjson | **3769** | **624** | **15**
|
||||
mailru/easyjson | **2002** | **192** | **9**
|
||||
buger/jsonparser | **1367** | **0** | **0**
|
||||
buger/jsonparser (EachKey API) | **809** | **0** | **0**
|
||||
|
||||
Winners are ffjson, easyjson and jsonparser, where jsonparser is up to 9.8x faster than encoding/json and 4.6x faster than ffjson, and slightly faster than easyjson.
|
||||
If you look at memory allocation, jsonparser has no rivals, as it makes no data copy and operates with raw []byte structures and pointers to it.
|
||||
|
||||
#### Medium payload
|
||||
|
||||
Each test processes a 2.4kb JSON record (based on Clearbit API).
|
||||
It should read multiple nested fields and 1 array.
|
||||
|
||||
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_medium_payload_test.go
|
||||
|
||||
| Library | time/op | bytes/op | allocs/op |
|
||||
| ------- | ------- | -------- | --------- |
|
||||
| encoding/json struct | 57749 | 1336 | 29 |
|
||||
| encoding/json interface{} | 79297 | 10627 | 215 |
|
||||
| Jeffail/gabs | 83807 | 11202 | 235 |
|
||||
| bitly/go-simplejson | 88187 | 17187 | 220 |
|
||||
| antonholmquist/jason | 94099 | 19013 | 247 |
|
||||
| github.com/ugorji/go/codec | 114719 | 6712 | 152 |
|
||||
| mreiferson/go-ujson | **56972** | 11547 | 270 |
|
||||
| a8m/djson | 28525 | 10196 | 198 |
|
||||
| pquerna/ffjson | **20298** | **856** | **20** |
|
||||
| mailru/easyjson | **10512** | **336** | **12** |
|
||||
| buger/jsonparser | **15955** | **0** | **0** |
|
||||
| buger/jsonparser (EachKey API) | **8916** | **0** | **0** |
|
||||
|
||||
The difference between ffjson and jsonparser in CPU usage is smaller, while the memory consumption difference is growing. On the other hand `easyjson` shows remarkable performance for medium payload.
|
||||
|
||||
`gabs`, `go-simplejson` and `jason` are based on encoding/json and map[string]interface{} and actually only helpers for unstructured JSON, their performance correlate with `encoding/json interface{}`, and they will skip next round.
|
||||
`go-ujson` while have its own parser, shows same performance as `encoding/json`, also skips next round. Same situation with `ugorji/go/codec`, but it showed unexpectedly bad performance for complex payloads.
|
||||
|
||||
|
||||
#### Large payload
|
||||
|
||||
Each test processes a 24kb JSON record (based on Discourse API)
|
||||
It should read 2 arrays, and for each item in array get a few fields.
|
||||
Basically it means processing a full JSON file.
|
||||
|
||||
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_large_payload_test.go
|
||||
|
||||
| Library | time/op | bytes/op | allocs/op |
|
||||
| --- | --- | --- | --- |
|
||||
| encoding/json struct | 748336 | 8272 | 307 |
|
||||
| encoding/json interface{} | 1224271 | 215425 | 3395 |
|
||||
| a8m/djson | 510082 | 213682 | 2845 |
|
||||
| pquerna/ffjson | **312271** | **7792** | **298** |
|
||||
| mailru/easyjson | **154186** | **6992** | **288** |
|
||||
| buger/jsonparser | **85308** | **0** | **0** |
|
||||
|
||||
`jsonparser` now is a winner, but do not forget that it is way more lightweight parser than `ffson` or `easyjson`, and they have to parser all the data, while `jsonparser` parse only what you need. All `ffjson`, `easysjon` and `jsonparser` have their own parsing code, and does not depend on `encoding/json` or `interface{}`, thats one of the reasons why they are so fast. `easyjson` also use a bit of `unsafe` package to reduce memory consuption (in theory it can lead to some unexpected GC issue, but i did not tested enough)
|
||||
|
||||
Also last benchmark did not included `EachKey` test, because in this particular case we need to read lot of Array values, and using `ArrayEach` is more efficient.
|
||||
|
||||
## Questions and support
|
||||
|
||||
All bug-reports and suggestions should go though Github Issues.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it
|
||||
2. Create your feature branch (git checkout -b my-new-feature)
|
||||
3. Commit your changes (git commit -am 'Added some feature')
|
||||
4. Push to the branch (git push origin my-new-feature)
|
||||
5. Create new Pull Request
|
||||
|
||||
## Development
|
||||
|
||||
All my development happens using Docker, and repo include some Make tasks to simplify development.
|
||||
|
||||
* `make build` - builds docker image, usually can be called only once
|
||||
* `make test` - run tests
|
||||
* `make fmt` - run go fmt
|
||||
* `make bench` - run benchmarks (if you need to run only single benchmark modify `BENCHMARK` variable in make file)
|
||||
* `make profile` - runs benchmark and generate 3 files- `cpu.out`, `mem.mprof` and `benchmark.test` binary, which can be used for `go tool pprof`
|
||||
* `make bash` - enter container (i use it for running `go tool pprof` above)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
bio "bytes"
|
||||
)
|
||||
|
||||
// minInt64 '-9223372036854775808' is the smallest representable number in int64
|
||||
const minInt64 = `9223372036854775808`
|
||||
|
||||
// About 2x faster then strconv.ParseInt because it only supports base 10, which is enough for JSON
|
||||
func parseInt(bytes []byte) (v int64, ok bool, overflow bool) {
|
||||
if len(bytes) == 0 {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
var neg bool = false
|
||||
if bytes[0] == '-' {
|
||||
neg = true
|
||||
bytes = bytes[1:]
|
||||
}
|
||||
|
||||
var b int64 = 0
|
||||
for _, c := range bytes {
|
||||
if c >= '0' && c <= '9' {
|
||||
b = (10 * v) + int64(c-'0')
|
||||
} else {
|
||||
return 0, false, false
|
||||
}
|
||||
if overflow = (b < v); overflow {
|
||||
break
|
||||
}
|
||||
v = b
|
||||
}
|
||||
|
||||
if overflow {
|
||||
if neg && bio.Equal(bytes, []byte(minInt64)) {
|
||||
return b, true, false
|
||||
}
|
||||
return 0, false, true
|
||||
}
|
||||
|
||||
if neg {
|
||||
return -v, true, false
|
||||
} else {
|
||||
return v, true, false
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// +build appengine appenginevm
|
||||
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// See fastbytes_unsafe.go for explanation on why *[]byte is used (signatures must be consistent with those in that file)
|
||||
|
||||
func equalStr(b *[]byte, s string) bool {
|
||||
return string(*b) == s
|
||||
}
|
||||
|
||||
func parseFloat(b *[]byte) (float64, error) {
|
||||
return strconv.ParseFloat(string(*b), 64)
|
||||
}
|
||||
|
||||
func bytesToString(b *[]byte) string {
|
||||
return string(*b)
|
||||
}
|
||||
|
||||
func StringToBytes(s string) []byte {
|
||||
return []byte(s)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// +build !appengine,!appenginevm
|
||||
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
//
|
||||
// The reason for using *[]byte rather than []byte in parameters is an optimization. As of Go 1.6,
|
||||
// the compiler cannot perfectly inline the function when using a non-pointer slice. That is,
|
||||
// the non-pointer []byte parameter version is slower than if its function body is manually
|
||||
// inlined, whereas the pointer []byte version is equally fast to the manually inlined
|
||||
// version. Instruction count in assembly taken from "go tool compile" confirms this difference.
|
||||
//
|
||||
// TODO: Remove hack after Go 1.7 release
|
||||
//
|
||||
func equalStr(b *[]byte, s string) bool {
|
||||
return *(*string)(unsafe.Pointer(b)) == s
|
||||
}
|
||||
|
||||
func parseFloat(b *[]byte) (float64, error) {
|
||||
return strconv.ParseFloat(*(*string)(unsafe.Pointer(b)), 64)
|
||||
}
|
||||
|
||||
// A hack until issue golang/go#2632 is fixed.
|
||||
// See: https://github.com/golang/go/issues/2632
|
||||
func bytesToString(b *[]byte) string {
|
||||
return *(*string)(unsafe.Pointer(b))
|
||||
}
|
||||
|
||||
func StringToBytes(s string) []byte {
|
||||
b := make([]byte, 0, 0)
|
||||
bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
|
||||
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
|
||||
bh.Data = sh.Data
|
||||
bh.Cap = sh.Len
|
||||
bh.Len = sh.Len
|
||||
runtime.KeepAlive(s)
|
||||
return b
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// JSON Unicode stuff: see https://tools.ietf.org/html/rfc7159#section-7
|
||||
|
||||
const supplementalPlanesOffset = 0x10000
|
||||
const highSurrogateOffset = 0xD800
|
||||
const lowSurrogateOffset = 0xDC00
|
||||
|
||||
const basicMultilingualPlaneReservedOffset = 0xDFFF
|
||||
const basicMultilingualPlaneOffset = 0xFFFF
|
||||
|
||||
func combineUTF16Surrogates(high, low rune) rune {
|
||||
return supplementalPlanesOffset + (high-highSurrogateOffset)<<10 + (low - lowSurrogateOffset)
|
||||
}
|
||||
|
||||
const badHex = -1
|
||||
|
||||
func h2I(c byte) int {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return int(c - '0')
|
||||
case c >= 'A' && c <= 'F':
|
||||
return int(c - 'A' + 10)
|
||||
case c >= 'a' && c <= 'f':
|
||||
return int(c - 'a' + 10)
|
||||
}
|
||||
return badHex
|
||||
}
|
||||
|
||||
// decodeSingleUnicodeEscape decodes a single \uXXXX escape sequence. The prefix \u is assumed to be present and
|
||||
// is not checked.
|
||||
// In JSON, these escapes can either come alone or as part of "UTF16 surrogate pairs" that must be handled together.
|
||||
// This function only handles one; decodeUnicodeEscape handles this more complex case.
|
||||
func decodeSingleUnicodeEscape(in []byte) (rune, bool) {
|
||||
// We need at least 6 characters total
|
||||
if len(in) < 6 {
|
||||
return utf8.RuneError, false
|
||||
}
|
||||
|
||||
// Convert hex to decimal
|
||||
h1, h2, h3, h4 := h2I(in[2]), h2I(in[3]), h2I(in[4]), h2I(in[5])
|
||||
if h1 == badHex || h2 == badHex || h3 == badHex || h4 == badHex {
|
||||
return utf8.RuneError, false
|
||||
}
|
||||
|
||||
// Compose the hex digits
|
||||
return rune(h1<<12 + h2<<8 + h3<<4 + h4), true
|
||||
}
|
||||
|
||||
// isUTF16EncodedRune checks if a rune is in the range for non-BMP characters,
|
||||
// which is used to describe UTF16 chars.
|
||||
// Source: https://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane
|
||||
func isUTF16EncodedRune(r rune) bool {
|
||||
return highSurrogateOffset <= r && r <= basicMultilingualPlaneReservedOffset
|
||||
}
|
||||
|
||||
func decodeUnicodeEscape(in []byte) (rune, int) {
|
||||
if r, ok := decodeSingleUnicodeEscape(in); !ok {
|
||||
// Invalid Unicode escape
|
||||
return utf8.RuneError, -1
|
||||
} else if r <= basicMultilingualPlaneOffset && !isUTF16EncodedRune(r) {
|
||||
// Valid Unicode escape in Basic Multilingual Plane
|
||||
return r, 6
|
||||
} else if r2, ok := decodeSingleUnicodeEscape(in[6:]); !ok { // Note: previous decodeSingleUnicodeEscape success guarantees at least 6 bytes remain
|
||||
// UTF16 "high surrogate" without manditory valid following Unicode escape for the "low surrogate"
|
||||
return utf8.RuneError, -1
|
||||
} else if r2 < lowSurrogateOffset {
|
||||
// Invalid UTF16 "low surrogate"
|
||||
return utf8.RuneError, -1
|
||||
} else {
|
||||
// Valid UTF16 surrogate pair
|
||||
return combineUTF16Surrogates(r, r2), 12
|
||||
}
|
||||
}
|
||||
|
||||
// backslashCharEscapeTable: when '\X' is found for some byte X, it is to be replaced with backslashCharEscapeTable[X]
|
||||
var backslashCharEscapeTable = [...]byte{
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
'/': '/',
|
||||
'b': '\b',
|
||||
'f': '\f',
|
||||
'n': '\n',
|
||||
'r': '\r',
|
||||
't': '\t',
|
||||
}
|
||||
|
||||
// unescapeToUTF8 unescapes the single escape sequence starting at 'in' into 'out' and returns
|
||||
// how many characters were consumed from 'in' and emitted into 'out'.
|
||||
// If a valid escape sequence does not appear as a prefix of 'in', (-1, -1) to signal the error.
|
||||
func unescapeToUTF8(in, out []byte) (inLen int, outLen int) {
|
||||
if len(in) < 2 || in[0] != '\\' {
|
||||
// Invalid escape due to insufficient characters for any escape or no initial backslash
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7159#section-7
|
||||
switch e := in[1]; e {
|
||||
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
|
||||
// Valid basic 2-character escapes (use lookup table)
|
||||
out[0] = backslashCharEscapeTable[e]
|
||||
return 2, 1
|
||||
case 'u':
|
||||
// Unicode escape
|
||||
if r, inLen := decodeUnicodeEscape(in); inLen == -1 {
|
||||
// Invalid Unicode escape
|
||||
return -1, -1
|
||||
} else {
|
||||
// Valid Unicode escape; re-encode as UTF8
|
||||
outLen := utf8.EncodeRune(out, r)
|
||||
return inLen, outLen
|
||||
}
|
||||
}
|
||||
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// unescape unescapes the string contained in 'in' and returns it as a slice.
|
||||
// If 'in' contains no escaped characters:
|
||||
// Returns 'in'.
|
||||
// Else, if 'out' is of sufficient capacity (guaranteed if cap(out) >= len(in)):
|
||||
// 'out' is used to build the unescaped string and is returned with no extra allocation
|
||||
// Else:
|
||||
// A new slice is allocated and returned.
|
||||
func Unescape(in, out []byte) ([]byte, error) {
|
||||
firstBackslash := bytes.IndexByte(in, '\\')
|
||||
if firstBackslash == -1 {
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Get a buffer of sufficient size (allocate if needed)
|
||||
if cap(out) < len(in) {
|
||||
out = make([]byte, len(in))
|
||||
} else {
|
||||
out = out[0:len(in)]
|
||||
}
|
||||
|
||||
// Copy the first sequence of unescaped bytes to the output and obtain a buffer pointer (subslice)
|
||||
copy(out, in[:firstBackslash])
|
||||
in = in[firstBackslash:]
|
||||
buf := out[firstBackslash:]
|
||||
|
||||
for len(in) > 0 {
|
||||
// Unescape the next escaped character
|
||||
inLen, bufLen := unescapeToUTF8(in, buf)
|
||||
if inLen == -1 {
|
||||
return nil, MalformedStringEscapeError
|
||||
}
|
||||
|
||||
in = in[inLen:]
|
||||
buf = buf[bufLen:]
|
||||
|
||||
// Copy everything up until the next backslash
|
||||
nextBackslash := bytes.IndexByte(in, '\\')
|
||||
if nextBackslash == -1 {
|
||||
copy(buf, in)
|
||||
buf = buf[len(in):]
|
||||
break
|
||||
} else {
|
||||
copy(buf, in[:nextBackslash])
|
||||
buf = buf[nextBackslash:]
|
||||
in = in[nextBackslash:]
|
||||
}
|
||||
}
|
||||
|
||||
// Trim the out buffer to the amount that was actually emitted
|
||||
return out[:len(out)-len(buf)], nil
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package jsonparser
|
||||
|
||||
func FuzzParseString(data []byte) int {
|
||||
r, err := ParseString(data)
|
||||
if err != nil || r == "" {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzEachKey(data []byte) int {
|
||||
paths := [][]string{
|
||||
{"name"},
|
||||
{"order"},
|
||||
{"nested", "a"},
|
||||
{"nested", "b"},
|
||||
{"nested2", "a"},
|
||||
{"nested", "nested3", "b"},
|
||||
{"arr", "[1]", "b"},
|
||||
{"arrInt", "[3]"},
|
||||
{"arrInt", "[5]"},
|
||||
{"nested"},
|
||||
{"arr", "["},
|
||||
{"a\n", "b\n"},
|
||||
}
|
||||
EachKey(data, func(idx int, value []byte, vt ValueType, err error) {}, paths...)
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzDelete(data []byte) int {
|
||||
Delete(data, "test")
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzSet(data []byte) int {
|
||||
_, err := Set(data, []byte(`"new value"`), "test")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzObjectEach(data []byte) int {
|
||||
_ = ObjectEach(data, func(key, value []byte, valueType ValueType, off int) error {
|
||||
return nil
|
||||
})
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzParseFloat(data []byte) int {
|
||||
_, err := ParseFloat(data)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzParseInt(data []byte) int {
|
||||
_, err := ParseInt(data)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzParseBool(data []byte) int {
|
||||
_, err := ParseBoolean(data)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzTokenStart(data []byte) int {
|
||||
_ = tokenStart(data)
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzGetString(data []byte) int {
|
||||
_, err := GetString(data, "test")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzGetFloat(data []byte) int {
|
||||
_, err := GetFloat(data, "test")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzGetInt(data []byte) int {
|
||||
_, err := GetInt(data, "test")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzGetBoolean(data []byte) int {
|
||||
_, err := GetBoolean(data, "test")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func FuzzGetUnsafeString(data []byte) int {
|
||||
_, err := GetUnsafeString(data, "test")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash -eu
|
||||
|
||||
git clone https://github.com/dvyukov/go-fuzz-corpus
|
||||
zip corpus.zip go-fuzz-corpus/json/corpus/*
|
||||
|
||||
cp corpus.zip $OUT/fuzzparsestring_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzParseString fuzzparsestring
|
||||
|
||||
cp corpus.zip $OUT/fuzzeachkey_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzEachKey fuzzeachkey
|
||||
|
||||
cp corpus.zip $OUT/fuzzdelete_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzDelete fuzzdelete
|
||||
|
||||
cp corpus.zip $OUT/fuzzset_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzSet fuzzset
|
||||
|
||||
cp corpus.zip $OUT/fuzzobjecteach_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzObjectEach fuzzobjecteach
|
||||
|
||||
cp corpus.zip $OUT/fuzzparsefloat_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzParseFloat fuzzparsefloat
|
||||
|
||||
cp corpus.zip $OUT/fuzzparseint_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzParseInt fuzzparseint
|
||||
|
||||
cp corpus.zip $OUT/fuzzparsebool_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzParseBool fuzzparsebool
|
||||
|
||||
cp corpus.zip $OUT/fuzztokenstart_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzTokenStart fuzztokenstart
|
||||
|
||||
cp corpus.zip $OUT/fuzzgetstring_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzGetString fuzzgetstring
|
||||
|
||||
cp corpus.zip $OUT/fuzzgetfloat_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzGetFloat fuzzgetfloat
|
||||
|
||||
cp corpus.zip $OUT/fuzzgetint_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzGetInt fuzzgetint
|
||||
|
||||
cp corpus.zip $OUT/fuzzgetboolean_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzGetBoolean fuzzgetboolean
|
||||
|
||||
cp corpus.zip $OUT/fuzzgetunsafestring_seed_corpus.zip
|
||||
compile_go_fuzzer github.com/buger/jsonparser FuzzGetUnsafeString fuzzgetunsafestring
|
||||
|
||||
+1283
File diff suppressed because it is too large.
Load diff
+2
@@ -0,0 +1,2 @@
|
||||
vendor/
|
||||
.idea/
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
run:
|
||||
tests: true
|
||||
max-same-issues: 50
|
||||
|
||||
output:
|
||||
print-issued-lines: false
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- gocyclo
|
||||
- gocritic
|
||||
- goconst
|
||||
- dupl
|
||||
- unconvert
|
||||
- goimports
|
||||
- unused
|
||||
- govet
|
||||
- nakedret
|
||||
- errcheck
|
||||
- revive
|
||||
- ineffassign
|
||||
- goconst
|
||||
- unparam
|
||||
- gofmt
|
||||
|
||||
linters-settings:
|
||||
vet:
|
||||
check-shadowing: true
|
||||
use-installed-packages: true
|
||||
dupl:
|
||||
threshold: 100
|
||||
goconst:
|
||||
min-len: 8
|
||||
min-occurrences: 3
|
||||
gocyclo:
|
||||
min-complexity: 20
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- ifElseChain
|
||||
gofmt:
|
||||
rewrite-rules:
|
||||
- pattern: "interface{}"
|
||||
replacement: "any"
|
||||
- pattern: "a[b:len(a)]"
|
||||
replacement: "a[b:]"
|
||||
|
||||
issues:
|
||||
max-per-linter: 0
|
||||
max-same: 0
|
||||
exclude-dirs:
|
||||
- resources
|
||||
- old
|
||||
exclude-files:
|
||||
- cmd/protopkg/main.go
|
||||
exclude-use-default: false
|
||||
exclude:
|
||||
# Captured by errcheck.
|
||||
- "^(G104|G204):"
|
||||
# Very commonly not checked.
|
||||
- 'Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*Print(f|ln|)|os\.(Un)?Setenv). is not checked'
|
||||
# Weird error only seen on Kochiku...
|
||||
- "internal error: no range for"
|
||||
- 'exported method `.*\.(MarshalJSON|UnmarshalJSON|URN|Payload|GoString|Close|Provides|Requires|ExcludeFromHash|MarshalText|UnmarshalText|Description|Check|Poll|Severity)` should have comment or be unexported'
|
||||
- "composite literal uses unkeyed fields"
|
||||
- 'declaration of "err" shadows declaration'
|
||||
- "by other packages, and that stutters"
|
||||
- "Potential file inclusion via variable"
|
||||
- "at least one file in a package should have a package comment"
|
||||
- "bad syntax for struct tag pair"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2014 Alec Thomas
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
# Go JSON Schema Reflection
|
||||
|
||||
[](https://github.com/invopop/jsonschema/actions/workflows/lint.yaml)
|
||||
[](https://github.com/invopop/jsonschema/actions/workflows/test.yaml)
|
||||
[](https://goreportcard.com/report/github.com/invopop/jsonschema)
|
||||
[](https://godoc.org/github.com/invopop/jsonschema)
|
||||
[](https://codecov.io/gh/invopop/jsonschema)
|
||||

|
||||
|
||||
This package can be used to generate [JSON Schemas](http://json-schema.org/latest/json-schema-validation.html) from Go types through reflection.
|
||||
|
||||
- Supports arbitrarily complex types, including `interface{}`, maps, slices, etc.
|
||||
- Supports json-schema features such as minLength, maxLength, pattern, format, etc.
|
||||
- Supports simple string and numeric enums.
|
||||
- Supports custom property fields via the `jsonschema_extras` struct tag.
|
||||
|
||||
This repository is a fork of the original [jsonschema](https://github.com/alecthomas/jsonschema) by [@alecthomas](https://github.com/alecthomas). At [Invopop](https://invopop.com) we use jsonschema as a cornerstone in our [GOBL library](https://github.com/invopop/gobl), and wanted to be able to continue building and adding features without taking up Alec's time. There have been a few significant changes that probably mean this version is a not compatible with with Alec's:
|
||||
|
||||
- The original was stuck on the draft-04 version of JSON Schema, we've now moved to the latest JSON Schema Draft 2020-12.
|
||||
- Schema IDs are added automatically from the current Go package's URL in order to be unique, and can be disabled with the `Anonymous` option.
|
||||
- Support for the `FullyQualifyTypeName` option has been removed. If you have conflicts, you should use multiple schema files with different IDs, set the `DoNotReference` option to true to hide definitions completely, or add your own naming strategy using the `Namer` property.
|
||||
- Support for `yaml` tags and related options has been dropped for the sake of simplification. There were a [few inconsistencies](https://github.com/invopop/jsonschema/pull/21) around this that have now been fixed.
|
||||
|
||||
## Versions
|
||||
|
||||
This project is still under v0 scheme, as per Go convention, breaking changes are likely. Please pin go modules to version tags or branches, and reach out if you think something can be improved.
|
||||
|
||||
Go version >= 1.18 is required as generics are now being used.
|
||||
|
||||
## Example
|
||||
|
||||
The following Go type:
|
||||
|
||||
```go
|
||||
type TestUser struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name" jsonschema:"title=the name,description=The name of a friend,example=joe,example=lucy,default=alex"`
|
||||
Friends []int `json:"friends,omitempty" jsonschema_description:"The list of IDs, omitted when empty"`
|
||||
Tags map[string]interface{} `json:"tags,omitempty" jsonschema_extras:"a=b,foo=bar,foo=bar1"`
|
||||
BirthDate time.Time `json:"birth_date,omitempty" jsonschema:"oneof_required=date"`
|
||||
YearOfBirth string `json:"year_of_birth,omitempty" jsonschema:"oneof_required=year"`
|
||||
Metadata interface{} `json:"metadata,omitempty" jsonschema:"oneof_type=string;array"`
|
||||
FavColor string `json:"fav_color,omitempty" jsonschema:"enum=red,enum=green,enum=blue"`
|
||||
}
|
||||
```
|
||||
|
||||
Results in following JSON Schema:
|
||||
|
||||
```go
|
||||
jsonschema.Reflect(&TestUser{})
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/invopop/jsonschema_test/test-user",
|
||||
"$ref": "#/$defs/TestUser",
|
||||
"$defs": {
|
||||
"TestUser": {
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["birth_date"],
|
||||
"title": "date"
|
||||
},
|
||||
{
|
||||
"required": ["year_of_birth"],
|
||||
"title": "year"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "the name",
|
||||
"description": "The name of a friend",
|
||||
"default": "alex",
|
||||
"examples": ["joe", "lucy"]
|
||||
},
|
||||
"friends": {
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "The list of IDs, omitted when empty"
|
||||
},
|
||||
"tags": {
|
||||
"type": "object",
|
||||
"a": "b",
|
||||
"foo": ["bar", "bar1"]
|
||||
},
|
||||
"birth_date": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"year_of_birth": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fav_color": {
|
||||
"type": "string",
|
||||
"enum": ["red", "green", "blue"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": ["id", "name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## YAML
|
||||
|
||||
Support for `yaml` tags has now been removed. If you feel very strongly about this, we've opened a discussion to hear your comments: https://github.com/invopop/jsonschema/discussions/28
|
||||
|
||||
The recommended approach if you need to deal with YAML data is to first convert to JSON. The [invopop/yaml](https://github.com/invopop/yaml) library will make this trivial.
|
||||
|
||||
## Configurable behaviour
|
||||
|
||||
The behaviour of the schema generator can be altered with parameters when a `jsonschema.Reflector`
|
||||
instance is created.
|
||||
|
||||
### ExpandedStruct
|
||||
|
||||
If set to `true`, makes the top level struct not to reference itself in the definitions. But type passed should be a struct type.
|
||||
|
||||
eg.
|
||||
|
||||
```go
|
||||
type GrandfatherType struct {
|
||||
FamilyName string `json:"family_name" jsonschema:"required"`
|
||||
}
|
||||
|
||||
type SomeBaseType struct {
|
||||
SomeBaseProperty int `json:"some_base_property"`
|
||||
// The jsonschema required tag is nonsensical for private and ignored properties.
|
||||
// Their presence here tests that the fields *will not* be required in the output
|
||||
// schema, even if they are tagged required.
|
||||
somePrivateBaseProperty string `json:"i_am_private" jsonschema:"required"`
|
||||
SomeIgnoredBaseProperty string `json:"-" jsonschema:"required"`
|
||||
SomeSchemaIgnoredProperty string `jsonschema:"-,required"`
|
||||
SomeUntaggedBaseProperty bool `jsonschema:"required"`
|
||||
someUnexportedUntaggedBaseProperty bool
|
||||
Grandfather GrandfatherType `json:"grand"`
|
||||
}
|
||||
```
|
||||
|
||||
will output:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft/2020-12/schema",
|
||||
"required": ["some_base_property", "grand", "SomeUntaggedBaseProperty"],
|
||||
"properties": {
|
||||
"SomeUntaggedBaseProperty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"grand": {
|
||||
"$schema": "http://json-schema.org/draft/2020-12/schema",
|
||||
"$ref": "#/definitions/GrandfatherType"
|
||||
},
|
||||
"some_base_property": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"GrandfatherType": {
|
||||
"required": ["family_name"],
|
||||
"properties": {
|
||||
"family_name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Go Comments
|
||||
|
||||
Writing a good schema with descriptions inside tags can become cumbersome and tedious, especially if you already have some Go comments around your types and field definitions. If you'd like to take advantage of these existing comments, you can use the `AddGoComments(base, path string)` method that forms part of the reflector to parse your go files and automatically generate a dictionary of Go import paths, types, and fields, to individual comments. These will then be used automatically as description fields, and can be overridden with a manual definition if needed.
|
||||
|
||||
Take a simplified example of a User struct which for the sake of simplicity we assume is defined inside this package:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
// User is used as a base to provide tests for comments.
|
||||
type User struct {
|
||||
// Unique sequential identifier.
|
||||
ID int `json:"id" jsonschema:"required"`
|
||||
// Name of the user
|
||||
Name string `json:"name"`
|
||||
}
|
||||
```
|
||||
|
||||
To get the comments provided into your JSON schema, use a regular `Reflector` and add the go code using an import module URL and path. Fully qualified go module paths cannot be determined reliably by the `go/parser` library, so we need to introduce this manually:
|
||||
|
||||
```go
|
||||
r := new(Reflector)
|
||||
if err := r.AddGoComments("github.com/invopop/jsonschema", "./"); err != nil {
|
||||
// deal with error
|
||||
}
|
||||
s := r.Reflect(&User{})
|
||||
// output
|
||||
```
|
||||
|
||||
Expect the results to be similar to:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft/2020-12/schema",
|
||||
"$ref": "#/$defs/User",
|
||||
"$defs": {
|
||||
"User": {
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Unique sequential identifier."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the user"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"description": "User is used as a base to provide tests for comments."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Key Naming
|
||||
|
||||
In some situations, the keys actually used to write files are different from Go structs'.
|
||||
|
||||
This is often the case when writing a configuration file to YAML or JSON from a Go struct, or when returning a JSON response for a Web API: APIs typically use snake_case, while Go uses PascalCase.
|
||||
|
||||
You can pass a `func(string) string` function to `Reflector`'s `KeyNamer` option to map Go field names to JSON key names and reflect the aforementioned transformations, without having to specify `json:"..."` on every struct field.
|
||||
|
||||
For example, consider the following struct
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
GivenName string
|
||||
PasswordSalted []byte `json:"salted_password"`
|
||||
}
|
||||
```
|
||||
|
||||
We can transform field names to snake_case in the generated JSON schema:
|
||||
|
||||
```go
|
||||
r := new(jsonschema.Reflector)
|
||||
r.KeyNamer = strcase.SnakeCase // from package github.com/stoewer/go-strcase
|
||||
|
||||
r.Reflect(&User{})
|
||||
```
|
||||
|
||||
Will yield
|
||||
|
||||
```diff
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft/2020-12/schema",
|
||||
"$ref": "#/$defs/User",
|
||||
"$defs": {
|
||||
"User": {
|
||||
"properties": {
|
||||
- "GivenName": {
|
||||
+ "given_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"salted_password": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
- "required": ["GivenName", "salted_password"]
|
||||
+ "required": ["given_name", "salted_password"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As you can see, if a field name has a `json:""` tag set, the `key` argument to `KeyNamer` will have the value of that tag.
|
||||
|
||||
### Custom Type Definitions
|
||||
|
||||
Sometimes it can be useful to have custom JSON Marshal and Unmarshal methods in your structs that automatically convert for example a string into an object.
|
||||
|
||||
This library will recognize and attempt to call four different methods that help you adjust schemas to your specific needs:
|
||||
|
||||
- `JSONSchema() *Schema` - will prevent auto-generation of the schema so that you can provide your own definition.
|
||||
- `JSONSchemaExtend(schema *jsonschema.Schema)` - will be called _after_ the schema has been generated, allowing you to add or manipulate the fields easily.
|
||||
- `JSONSchemaAlias() any` - is called when reflecting the type of object and allows for an alternative to be used instead.
|
||||
- `JSONSchemaProperty(prop string) any` - will be called for every property inside a struct giving you the chance to provide an alternative object to convert into a schema.
|
||||
|
||||
Note that all of these methods **must** be defined on a non-pointer object for them to be called.
|
||||
|
||||
Take the following simplified example of a `CompactDate` that only includes the Year and Month:
|
||||
|
||||
```go
|
||||
type CompactDate struct {
|
||||
Year int
|
||||
Month int
|
||||
}
|
||||
|
||||
func (d *CompactDate) UnmarshalJSON(data []byte) error {
|
||||
if len(data) != 9 {
|
||||
return errors.New("invalid compact date length")
|
||||
}
|
||||
var err error
|
||||
d.Year, err = strconv.Atoi(string(data[1:5]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Month, err = strconv.Atoi(string(data[7:8]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *CompactDate) MarshalJSON() ([]byte, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
buf.WriteByte('"')
|
||||
buf.WriteString(fmt.Sprintf("%d-%02d", d.Year, d.Month))
|
||||
buf.WriteByte('"')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (CompactDate) JSONSchema() *Schema {
|
||||
return &Schema{
|
||||
Type: "string",
|
||||
Title: "Compact Date",
|
||||
Description: "Short date that only includes year and month",
|
||||
Pattern: "^[0-9]{4}-[0-1][0-9]$",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The resulting schema generated for this struct would look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft/2020-12/schema",
|
||||
"$ref": "#/$defs/CompactDate",
|
||||
"$defs": {
|
||||
"CompactDate": {
|
||||
"pattern": "^[0-9]{4}-[0-1][0-9]$",
|
||||
"type": "string",
|
||||
"title": "Compact Date",
|
||||
"description": "Short date that only includes year and month"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ID represents a Schema ID type which should always be a URI.
|
||||
// See draft-bhutton-json-schema-00 section 8.2.1
|
||||
type ID string
|
||||
|
||||
// EmptyID is used to explicitly define an ID with no value.
|
||||
const EmptyID ID = ""
|
||||
|
||||
// Validate is used to check if the ID looks like a proper schema.
|
||||
// This is done by parsing the ID as a URL and checking it has all the
|
||||
// relevant parts.
|
||||
func (id ID) Validate() error {
|
||||
u, err := url.Parse(id.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return errors.New("missing hostname")
|
||||
}
|
||||
if !strings.Contains(u.Hostname(), ".") {
|
||||
return errors.New("hostname does not look valid")
|
||||
}
|
||||
if u.Path == "" {
|
||||
return errors.New("path is expected")
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return errors.New("unexpected schema")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Anchor sets the anchor part of the schema URI.
|
||||
func (id ID) Anchor(name string) ID {
|
||||
b := id.Base()
|
||||
return ID(b.String() + "#" + name)
|
||||
}
|
||||
|
||||
// Def adds or replaces a definition identifier.
|
||||
func (id ID) Def(name string) ID {
|
||||
b := id.Base()
|
||||
return ID(b.String() + "#/$defs/" + name)
|
||||
}
|
||||
|
||||
// Add appends the provided path to the id, and removes any
|
||||
// anchor data that might be there.
|
||||
func (id ID) Add(path string) ID {
|
||||
b := id.Base()
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return ID(b.String() + path)
|
||||
}
|
||||
|
||||
// Base removes any anchor information from the schema
|
||||
func (id ID) Base() ID {
|
||||
s := id.String()
|
||||
i := strings.LastIndex(s, "#")
|
||||
if i != -1 {
|
||||
s = s[0:i]
|
||||
}
|
||||
s = strings.TrimRight(s, "/")
|
||||
return ID(s)
|
||||
}
|
||||
|
||||
// String provides string version of ID
|
||||
func (id ID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
+1148
File diff suppressed because it is too large.
Load diff
+146
@@ -0,0 +1,146 @@
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
gopath "path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"go/ast"
|
||||
"go/doc"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
)
|
||||
|
||||
type commentOptions struct {
|
||||
fullObjectText bool // use the first sentence only?
|
||||
}
|
||||
|
||||
// CommentOption allows for special configuration options when preparing Go
|
||||
// source files for comment extraction.
|
||||
type CommentOption func(*commentOptions)
|
||||
|
||||
// WithFullComment will configure the comment extraction to process to use an
|
||||
// object type's full comment text instead of just the synopsis.
|
||||
func WithFullComment() CommentOption {
|
||||
return func(o *commentOptions) {
|
||||
o.fullObjectText = true
|
||||
}
|
||||
}
|
||||
|
||||
// AddGoComments will update the reflectors comment map with all the comments
|
||||
// found in the provided source directories including sub-directories, in order to
|
||||
// generate a dictionary of comments associated with Types and Fields. The results
|
||||
// will be added to the `Reflect.CommentMap` ready to use with Schema "description"
|
||||
// fields.
|
||||
//
|
||||
// The `go/parser` library is used to extract all the comments and unfortunately doesn't
|
||||
// have a built-in way to determine the fully qualified name of a package. The `base`
|
||||
// parameter, the URL used to import that package, is thus required to be able to match
|
||||
// reflected types.
|
||||
//
|
||||
// When parsing type comments, by default we use the `go/doc`'s Synopsis method to extract
|
||||
// the first phrase only. Field comments, which tend to be much shorter, will include everything.
|
||||
// This behavior can be changed by using the `WithFullComment` option.
|
||||
func (r *Reflector) AddGoComments(base, path string, opts ...CommentOption) error {
|
||||
if r.CommentMap == nil {
|
||||
r.CommentMap = make(map[string]string)
|
||||
}
|
||||
co := new(commentOptions)
|
||||
for _, opt := range opts {
|
||||
opt(co)
|
||||
}
|
||||
|
||||
return r.extractGoComments(base, path, r.CommentMap, co)
|
||||
}
|
||||
|
||||
func (r *Reflector) extractGoComments(base, path string, commentMap map[string]string, opts *commentOptions) error {
|
||||
fset := token.NewFileSet()
|
||||
dict := make(map[string][]*ast.Package)
|
||||
err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
d, err := parser.ParseDir(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range d {
|
||||
// paths may have multiple packages, like for tests
|
||||
k := gopath.Join(base, path)
|
||||
dict[k] = append(dict[k], v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for pkg, p := range dict {
|
||||
for _, f := range p {
|
||||
gtxt := ""
|
||||
typ := ""
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.TypeSpec:
|
||||
typ = x.Name.String()
|
||||
if !ast.IsExported(typ) {
|
||||
typ = ""
|
||||
} else {
|
||||
txt := x.Doc.Text()
|
||||
if txt == "" && gtxt != "" {
|
||||
txt = gtxt
|
||||
gtxt = ""
|
||||
}
|
||||
if !opts.fullObjectText {
|
||||
txt = doc.Synopsis(txt)
|
||||
}
|
||||
commentMap[fmt.Sprintf("%s.%s", pkg, typ)] = strings.TrimSpace(txt)
|
||||
}
|
||||
case *ast.Field:
|
||||
txt := x.Doc.Text()
|
||||
if txt == "" {
|
||||
txt = x.Comment.Text()
|
||||
}
|
||||
if typ != "" && txt != "" {
|
||||
for _, n := range x.Names {
|
||||
if ast.IsExported(n.String()) {
|
||||
k := fmt.Sprintf("%s.%s.%s", pkg, typ, n)
|
||||
commentMap[k] = strings.TrimSpace(txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
// remember for the next type
|
||||
gtxt = x.Doc.Text()
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reflector) lookupComment(t reflect.Type, name string) string {
|
||||
if r.LookupComment != nil {
|
||||
if comment := r.LookupComment(t, name); comment != "" {
|
||||
return comment
|
||||
}
|
||||
}
|
||||
|
||||
if r.CommentMap == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
n := fullyQualifiedTypeName(t)
|
||||
if name != "" {
|
||||
n = n + "." + name
|
||||
}
|
||||
|
||||
return r.CommentMap[n]
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
// Version is the JSON Schema version.
|
||||
var Version = "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
// Schema represents a JSON Schema object type.
|
||||
// RFC draft-bhutton-json-schema-00 section 4.3
|
||||
type Schema struct {
|
||||
// RFC draft-bhutton-json-schema-00
|
||||
Version string `json:"$schema,omitempty"` // section 8.1.1
|
||||
ID ID `json:"$id,omitempty"` // section 8.2.1
|
||||
Anchor string `json:"$anchor,omitempty"` // section 8.2.2
|
||||
Ref string `json:"$ref,omitempty"` // section 8.2.3.1
|
||||
DynamicRef string `json:"$dynamicRef,omitempty"` // section 8.2.3.2
|
||||
Definitions Definitions `json:"$defs,omitempty"` // section 8.2.4
|
||||
Comments string `json:"$comment,omitempty"` // section 8.3
|
||||
// RFC draft-bhutton-json-schema-00 section 10.2.1 (Sub-schemas with logic)
|
||||
AllOf []*Schema `json:"allOf,omitempty"` // section 10.2.1.1
|
||||
AnyOf []*Schema `json:"anyOf,omitempty"` // section 10.2.1.2
|
||||
OneOf []*Schema `json:"oneOf,omitempty"` // section 10.2.1.3
|
||||
Not *Schema `json:"not,omitempty"` // section 10.2.1.4
|
||||
// RFC draft-bhutton-json-schema-00 section 10.2.2 (Apply sub-schemas conditionally)
|
||||
If *Schema `json:"if,omitempty"` // section 10.2.2.1
|
||||
Then *Schema `json:"then,omitempty"` // section 10.2.2.2
|
||||
Else *Schema `json:"else,omitempty"` // section 10.2.2.3
|
||||
DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"` // section 10.2.2.4
|
||||
// RFC draft-bhutton-json-schema-00 section 10.3.1 (arrays)
|
||||
PrefixItems []*Schema `json:"prefixItems,omitempty"` // section 10.3.1.1
|
||||
Items *Schema `json:"items,omitempty"` // section 10.3.1.2 (replaces additionalItems)
|
||||
Contains *Schema `json:"contains,omitempty"` // section 10.3.1.3
|
||||
// RFC draft-bhutton-json-schema-00 section 10.3.2 (sub-schemas)
|
||||
Properties *orderedmap.OrderedMap[string, *Schema] `json:"properties,omitempty"` // section 10.3.2.1
|
||||
PatternProperties map[string]*Schema `json:"patternProperties,omitempty"` // section 10.3.2.2
|
||||
AdditionalProperties *Schema `json:"additionalProperties,omitempty"` // section 10.3.2.3
|
||||
PropertyNames *Schema `json:"propertyNames,omitempty"` // section 10.3.2.4
|
||||
// RFC draft-bhutton-json-schema-validation-00, section 6
|
||||
Type string `json:"type,omitempty"` // section 6.1.1
|
||||
Enum []any `json:"enum,omitempty"` // section 6.1.2
|
||||
Const any `json:"const,omitempty"` // section 6.1.3
|
||||
MultipleOf json.Number `json:"multipleOf,omitempty"` // section 6.2.1
|
||||
Maximum json.Number `json:"maximum,omitempty"` // section 6.2.2
|
||||
ExclusiveMaximum json.Number `json:"exclusiveMaximum,omitempty"` // section 6.2.3
|
||||
Minimum json.Number `json:"minimum,omitempty"` // section 6.2.4
|
||||
ExclusiveMinimum json.Number `json:"exclusiveMinimum,omitempty"` // section 6.2.5
|
||||
MaxLength *uint64 `json:"maxLength,omitempty"` // section 6.3.1
|
||||
MinLength *uint64 `json:"minLength,omitempty"` // section 6.3.2
|
||||
Pattern string `json:"pattern,omitempty"` // section 6.3.3
|
||||
MaxItems *uint64 `json:"maxItems,omitempty"` // section 6.4.1
|
||||
MinItems *uint64 `json:"minItems,omitempty"` // section 6.4.2
|
||||
UniqueItems bool `json:"uniqueItems,omitempty"` // section 6.4.3
|
||||
MaxContains *uint64 `json:"maxContains,omitempty"` // section 6.4.4
|
||||
MinContains *uint64 `json:"minContains,omitempty"` // section 6.4.5
|
||||
MaxProperties *uint64 `json:"maxProperties,omitempty"` // section 6.5.1
|
||||
MinProperties *uint64 `json:"minProperties,omitempty"` // section 6.5.2
|
||||
Required []string `json:"required,omitempty"` // section 6.5.3
|
||||
DependentRequired map[string][]string `json:"dependentRequired,omitempty"` // section 6.5.4
|
||||
// RFC draft-bhutton-json-schema-validation-00, section 7
|
||||
Format string `json:"format,omitempty"`
|
||||
// RFC draft-bhutton-json-schema-validation-00, section 8
|
||||
ContentEncoding string `json:"contentEncoding,omitempty"` // section 8.3
|
||||
ContentMediaType string `json:"contentMediaType,omitempty"` // section 8.4
|
||||
ContentSchema *Schema `json:"contentSchema,omitempty"` // section 8.5
|
||||
// RFC draft-bhutton-json-schema-validation-00, section 9
|
||||
Title string `json:"title,omitempty"` // section 9.1
|
||||
Description string `json:"description,omitempty"` // section 9.1
|
||||
Default any `json:"default,omitempty"` // section 9.2
|
||||
Deprecated bool `json:"deprecated,omitempty"` // section 9.3
|
||||
ReadOnly bool `json:"readOnly,omitempty"` // section 9.4
|
||||
WriteOnly bool `json:"writeOnly,omitempty"` // section 9.4
|
||||
Examples []any `json:"examples,omitempty"` // section 9.5
|
||||
|
||||
Extras map[string]any `json:"-"`
|
||||
|
||||
// Special boolean representation of the Schema - section 4.3.2
|
||||
boolean *bool
|
||||
}
|
||||
|
||||
var (
|
||||
// TrueSchema defines a schema with a true value
|
||||
TrueSchema = &Schema{boolean: &[]bool{true}[0]}
|
||||
// FalseSchema defines a schema with a false value
|
||||
FalseSchema = &Schema{boolean: &[]bool{false}[0]}
|
||||
)
|
||||
|
||||
// Definitions hold schema definitions.
|
||||
// http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.26
|
||||
// RFC draft-wright-json-schema-validation-00, section 5.26
|
||||
type Definitions map[string]*Schema
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
|
||||
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
|
||||
|
||||
// ToSnakeCase converts the provided string into snake case using dashes.
|
||||
// This is useful for Schema IDs and definitions to be coherent with
|
||||
// common JSON Schema examples.
|
||||
func ToSnakeCase(str string) string {
|
||||
snake := matchFirstCap.ReplaceAllString(str, "${1}-${2}")
|
||||
snake = matchAllCap.ReplaceAllString(snake, "${1}-${2}")
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
// NewProperties is a helper method to instantiate a new properties ordered
|
||||
// map.
|
||||
func NewProperties() *orderedmap.OrderedMap[string, *Schema] {
|
||||
return orderedmap.New[string, *Schema]()
|
||||
}
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
# Examples
|
||||
|
||||
All the examples below are going to start with the following block, although
|
||||
nothing except a single Row is mandatory for the `Render()` function to render
|
||||
something:
|
||||
```golang
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
|
||||
func main() {
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(os.Stdout)
|
||||
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
t.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
|
||||
})
|
||||
t.AppendSeparator()
|
||||
t.AppendRow([]interface{}{300, "Tyrion", "Lannister", 5000})
|
||||
t.AppendFooter(table.Row{"", "", "Total", 10000})
|
||||
t.Render()
|
||||
}
|
||||
```
|
||||
Running the above will result in:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Ready-to-use Styles</strong></summary>
|
||||
|
||||
Table comes with a bunch of ready-to-use Styles that make the table look really
|
||||
good. Set or Change the style using:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
│ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 1 │ Arya │ Stark │ 3000 │ │
|
||||
│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ │ │ TOTAL │ 10000 │ │
|
||||
└─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
Or if you want to use a full-color mode, and don't care for boxes, use:
|
||||
```golang
|
||||
t.SetStyle(table.StyleColoredBright)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
|
||||
<img src="images/table-StyleColoredBright.png" width="640px" alt="Colored Table"/>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Roll your own Style</strong></summary>
|
||||
|
||||
You can also roll your own style:
|
||||
```golang
|
||||
t.SetStyle(table.Style{
|
||||
Name: "myNewStyle",
|
||||
Box: table.BoxStyle{
|
||||
BottomLeft: "\\",
|
||||
BottomRight: "/",
|
||||
BottomSeparator: "v",
|
||||
Left: "[",
|
||||
LeftSeparator: "{",
|
||||
MiddleHorizontal: "-",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: "<",
|
||||
PaddingRight: ">",
|
||||
Right: "]",
|
||||
RightSeparator: "}",
|
||||
TopLeft: "(",
|
||||
TopRight: ")",
|
||||
TopSeparator: "^",
|
||||
UnfinishedRow: " ~~~",
|
||||
},
|
||||
Color: table.ColorOptions{
|
||||
IndexColumn: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Footer: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
},
|
||||
Format: table.FormatOptions{
|
||||
Footer: text.FormatUpper,
|
||||
Header: text.FormatUpper,
|
||||
Row: text.FormatDefault,
|
||||
},
|
||||
Options: table.Options{
|
||||
DrawBorder: true,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or you can use one of the ready-to-use Styles, and just make a few tweaks:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Color.Header = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Color.IndexColumn = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Format.Footer = text.FormatLower
|
||||
t.Style().Options.DrawBorder = false
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Customize Horizontal Separators</strong></summary>
|
||||
|
||||
For more granular control over horizontal lines in your table, you can use `BoxStyleHorizontal` to customize different separator types independently. This allows you to have different horizontal line styles for titles, headers, rows, and footers.
|
||||
|
||||
The `BoxStyleHorizontal` struct provides 10 customizable separator types:
|
||||
- `TitleTop` / `TitleBottom` - Lines above/below the title
|
||||
- `HeaderTop` / `HeaderMiddle` / `HeaderBottom` - Lines in the header section
|
||||
- `RowTop` / `RowMiddle` / `RowBottom` - Lines in the data rows section
|
||||
- `FooterTop` / `FooterMiddle` / `FooterBottom` - Lines in the footer section
|
||||
|
||||
You can customize each separator type using:
|
||||
|
||||
```golang
|
||||
tw := table.NewWriter()
|
||||
tw.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
tw.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
|
||||
{300, "Tyrion", "Lannister", 5000},
|
||||
})
|
||||
tw.AppendFooter(table.Row{"", "", "Total", 10000})
|
||||
tw.SetStyle(table.StyleDefault)
|
||||
tw.Style().Box.Horizontal = &table.BoxStyleHorizontal{
|
||||
HeaderTop: "=", // Thicker line above header
|
||||
HeaderMiddle: "-",
|
||||
HeaderBottom: "~", // Thicker line below header
|
||||
RowTop: "-",
|
||||
RowMiddle: "- ",
|
||||
RowBottom: "-",
|
||||
FooterTop: "~", // Thicker line above footer
|
||||
FooterMiddle: "-",
|
||||
FooterBottom: "=", // Thicker line below footer
|
||||
}
|
||||
tw.Style().Options.SeparateRows = true
|
||||
fmt.Println(tw.Render())
|
||||
```
|
||||
to get something like:
|
||||
```
|
||||
+=====+============+===========+========+=============================+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
+- - -+- - - - - - +- - - - - -+- - - - +- - - - - - - - - - - - - - -+
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+- - -+- - - - - - +- - - - - -+- - - - +- - - - - - - - - - - - - - -+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
|
||||
| | | TOTAL | 10000 | |
|
||||
+=====+============+===========+========+=============================+
|
||||
```
|
||||
|
||||
When `BoxStyle.Horizontal` is set to a non-nil value, it overrides the `MiddleHorizontal` string for all horizontal separators. If `Horizontal` is nil, the table will automatically use `MiddleHorizontal` for all separator types.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Auto-Merge</strong></summary>
|
||||
|
||||
You can auto-merge cells horizontally and vertically, but you have request for
|
||||
it specifically for each row/column using `RowConfig` or `ColumnConfig`.
|
||||
|
||||
```golang
|
||||
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
|
||||
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rowConfigAutoMerge)
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "EXE", "RUN"})
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendFooter(table.Row{"", "", "", 7, 5, 3})
|
||||
t.SetAutoIndex(true)
|
||||
t.SetColumnConfigs([]table.ColumnConfig{
|
||||
{Number: 1, AutoMerge: true},
|
||||
{Number: 2, AutoMerge: true},
|
||||
{Number: 3, AutoMerge: true},
|
||||
{Number: 4, AutoMerge: true},
|
||||
{Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
{Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
})
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Options.SeparateRows = true
|
||||
fmt.Println(t.Render())
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌───┬─────────┬────────┬───────────┬───────────┬───────────┐
|
||||
│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │
|
||||
│ │ │ │ │ ├─────┬─────┤
|
||||
│ │ │ │ │ │ EXE │ RUN │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 2 │ │ │ │ C 2 │ Y │ N │
|
||||
├───┤ │ ├───────────┼───────────┼─────┴─────┤
|
||||
│ 3 │ │ │ NS 1B │ C 3 │ N │
|
||||
├───┤ ├────────┼───────────┼───────────┼───────────┤
|
||||
│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 5 │ │ │ │ C 5 │ Y │ N │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │
|
||||
├───┤ │ │ ├───────────┼───────────┤
|
||||
│ 7 │ │ │ │ C 7 │ Y │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤
|
||||
│ │ │ │ │ 7 │ 5 │ 3 │
|
||||
└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Paging</strong></summary>
|
||||
|
||||
You can limit the number of lines rendered in a single "Page". This logic
|
||||
can handle rows with multiple lines too. The recommended way is to use the
|
||||
`Pager()` interface:
|
||||
|
||||
```golang
|
||||
pager := t.Pager(PageSize(1))
|
||||
pager.Render() // Render first page
|
||||
pager.Next() // Move to next page and render
|
||||
pager.Prev() // Move to previous page and render
|
||||
pager.GoTo(3) // Jump to page 3
|
||||
pager.Location() // Get current page number
|
||||
```
|
||||
|
||||
Or use the deprecated `SetPageSize()` method for simple cases:
|
||||
```golang
|
||||
t.SetPageSize(1)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Filtering</strong></summary>
|
||||
|
||||
Filtering can be done on one or more columns. All filters are applied with AND logic (all must match).
|
||||
Filters are applied before sorting.
|
||||
|
||||
```golang
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{Name: "Salary", Operator: table.GreaterThan, Value: 2000},
|
||||
{Name: "First Name", Operator: table.Contains, Value: "on"},
|
||||
})
|
||||
```
|
||||
|
||||
The `Operator` field in `FilterBy` supports various filtering operators:
|
||||
- `Equal` / `NotEqual` - Exact match
|
||||
- `GreaterThan` / `GreaterThanOrEqual` - Numeric comparisons
|
||||
- `LessThan` / `LessThanOrEqual` - Numeric comparisons
|
||||
- `Contains` / `NotContains` - String search
|
||||
- `StartsWith` / `EndsWith` - String prefix/suffix matching
|
||||
- `RegexMatch` / `RegexNotMatch` - Regular expression matching
|
||||
|
||||
You can make string comparisons case-insensitive by setting `IgnoreCase: true`:
|
||||
```golang
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{Name: "First Name", Operator: table.Equal, Value: "JON", IgnoreCase: true},
|
||||
})
|
||||
```
|
||||
|
||||
For advanced filtering requirements, you can provide a custom filter function:
|
||||
```golang
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{
|
||||
Number: 2,
|
||||
CustomFilter: func(cellValue string) bool {
|
||||
// Custom logic: include rows where first name length > 3
|
||||
return len(cellValue) > 3
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Example: Filter by salary and name
|
||||
```golang
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
t.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000},
|
||||
{300, "Tyrion", "Lannister", 5000},
|
||||
{400, "Sansa", "Stark", 2500},
|
||||
})
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{Number: 4, Operator: table.GreaterThan, Value: 2000},
|
||||
{Number: 3, Operator: table.Contains, Value: "Stark"},
|
||||
})
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY |
|
||||
+-----+------------+-----------+--------+
|
||||
| 1 | Arya | Stark | 3000 |
|
||||
| 400 | Sansa | Stark | 2500 |
|
||||
+-----+------------+-----------+--------+
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Sorting</strong></summary>
|
||||
|
||||
Sorting can be done on one or more columns. The following code will make the
|
||||
rows be sorted first by "First Name" and then by "Last Name" (in case of similar
|
||||
"First Name" entries).
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{Name: "First Name", Mode: table.Asc},
|
||||
{Name: "Last Name", Mode: table.Asc},
|
||||
})
|
||||
```
|
||||
|
||||
The `Mode` field in `SortBy` supports various sorting modes:
|
||||
- `Asc` / `Dsc` - Alphabetical ascending/descending
|
||||
- `AscNumeric` / `DscNumeric` - Numerical ascending/descending
|
||||
- `AscAlphaNumeric` / `DscAlphaNumeric` - Alphabetical first, then numerical
|
||||
- `AscNumericAlpha` / `DscNumericAlpha` - Numerical first, then alphabetical
|
||||
|
||||
You can also make sorting case-insensitive by setting `IgnoreCase: true`:
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{Name: "First Name", Mode: table.Asc, IgnoreCase: true},
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Sorting Customization</strong></summary>
|
||||
|
||||
For advanced sorting requirements, you can provide a custom comparison function
|
||||
using `CustomLess`. This function overrides the `Mode` and `IgnoreCase` settings
|
||||
and gives you full control over the sorting logic.
|
||||
|
||||
The `CustomLess` function receives two string values (the cell contents converted
|
||||
to strings) and must return:
|
||||
- `-1` when the first value should come before the second
|
||||
- `0` when the values are considered equal (sorting continues to the next column)
|
||||
- `1` when the first value should come after the second
|
||||
|
||||
Example: Custom numeric sorting that handles string numbers correctly.
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{
|
||||
Number: 1,
|
||||
CustomLess: func(iStr string, jStr string) int {
|
||||
iNum, iErr := strconv.Atoi(iStr)
|
||||
jNum, jErr := strconv.Atoi(jStr)
|
||||
if iErr != nil || jErr != nil {
|
||||
// Fallback to string comparison if not numeric
|
||||
if iStr < jStr {
|
||||
return -1
|
||||
}
|
||||
if iStr > jStr {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if iNum < jNum {
|
||||
return -1
|
||||
}
|
||||
if iNum > jNum {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Example: Combining custom sorting with default sorting modes.
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{
|
||||
Number: 1,
|
||||
CustomLess: func(iStr string, jStr string) int {
|
||||
// Custom logic: "same" values come first
|
||||
if iStr == "same" && jStr != "same" {
|
||||
return -1
|
||||
}
|
||||
if iStr != "same" && jStr == "same" {
|
||||
return 1
|
||||
}
|
||||
return 0 // Equal, continue to next column
|
||||
},
|
||||
},
|
||||
{Number: 2, Mode: table.Asc}, // Default alphabetical sort
|
||||
{Number: 3, Mode: table.AscNumeric}, // Default numeric sort
|
||||
})
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Wrapping (or) Row/Column Width restrictions</strong></summary>
|
||||
|
||||
You can restrict the maximum (text) width for a Row:
|
||||
```golang
|
||||
t.SetAllowedRowLength(50)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| # | FIRST NAME | LAST NAME | SALARY | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 1 | Arya | Stark | 3000 | ~
|
||||
| 20 | Jon | Snow | 2000 | You kn ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 300 | Tyrion | Lannister | 5000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| | | TOTAL | 10000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Column Control - Alignment, Colors, Width and more</strong></summary>
|
||||
|
||||
You can control a lot of things about individual cells/columns which overrides
|
||||
global properties/styles using the `SetColumnConfig()` interface:
|
||||
- Alignment (horizontal & vertical)
|
||||
- Colorization
|
||||
- Transform individual cells based on the content
|
||||
- Visibility
|
||||
- Width (minimum & maximum)
|
||||
|
||||
```golang
|
||||
nameTransformer := text.Transformer(func(val interface{}) string {
|
||||
return text.Bold.Sprint(val)
|
||||
})
|
||||
|
||||
t.SetColumnConfigs([]ColumnConfig{
|
||||
{
|
||||
Name: "First Name",
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
Colors: text.Colors{text.BgBlack, text.FgRed},
|
||||
ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold},
|
||||
ColorsFooter: text.Colors{text.BgRed, text.FgBlack},
|
||||
Hidden: false,
|
||||
Transformer: nameTransformer,
|
||||
TransformerFooter: nameTransformer,
|
||||
TransformerHeader: nameTransformer,
|
||||
VAlign: text.VAlignMiddle,
|
||||
VAlignFooter: text.VAlignTop,
|
||||
VAlignHeader: text.VAlignBottom,
|
||||
WidthMin: 6,
|
||||
WidthMax: 64,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>CSV</strong></summary>
|
||||
|
||||
```golang
|
||||
t.RenderCSV()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
,First Name,Last Name,Salary,
|
||||
1,Arya,Stark,3000,
|
||||
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
|
||||
300,Tyrion,Lannister,5000,
|
||||
,,Total,10000,
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>HTML Table</strong></summary>
|
||||
|
||||
```golang
|
||||
t.Style().HTML = table.HTMLOptions{
|
||||
CSSClass: "game-of-thrones",
|
||||
EmptyColumn: " ",
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
ConvertColorsToSpans: true, // Convert ANSI escape sequences to HTML <span> tags with CSS classes
|
||||
}
|
||||
t.RenderHTML()
|
||||
```
|
||||
to get:
|
||||
```html
|
||||
<table class="game-of-thrones">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="right">#</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th align="right">Salary</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="right">1</td>
|
||||
<td>Arya</td>
|
||||
<td>Stark</td>
|
||||
<td align="right">3000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">20</td>
|
||||
<td>Jon</td>
|
||||
<td>Snow</td>
|
||||
<td align="right">2000</td>
|
||||
<td>You know nothing, Jon Snow!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">300</td>
|
||||
<td>Tyrion</td>
|
||||
<td>Lannister</td>
|
||||
<td align="right">5000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td align="right"> </td>
|
||||
<td> </td>
|
||||
<td>Total</td>
|
||||
<td align="right">10000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Markdown Table</strong></summary>
|
||||
|
||||
```golang
|
||||
t.RenderMarkdown()
|
||||
```
|
||||
to get:
|
||||
```markdown
|
||||
| # | First Name | Last Name | Salary | |
|
||||
| ---:| --- | --- | ---:| --- |
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
| | | Total | 10000 | |
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
+101
-412
@@ -3,42 +3,7 @@
|
||||
|
||||
Pretty-print tables into ASCII/Unicode strings.
|
||||
|
||||
- Add Rows one-by-one or as a group (`AppendRow`/`AppendRows`)
|
||||
- Add Header(s) and Footer(s) (`AppendHeader`/`AppendFooter`)
|
||||
- Add a Separator manually after any Row (`AppendSeparator`)
|
||||
- Auto Index Rows (1, 2, 3 ...) and Columns (A, B, C, ...) (`SetAutoIndex`)
|
||||
- Auto Merge (_not supported in CSV/Markdown/TSV modes_)
|
||||
- Cells in a Row (`RowConfig.AutoMerge`)
|
||||
- Columns (`ColumnConfig.AutoMerge`) (_not supported in HTML mode_)
|
||||
- Limit the length of
|
||||
- Rows (`SetAllowedRowLength`)
|
||||
- Columns (`ColumnConfig.Width*`)
|
||||
- Auto-size Rows (`Style().Size.WidthMin` and `Style().Size.WidthMax`)
|
||||
- Page results by a specified number of Lines (`SetPageSize`)
|
||||
- Alignment - Horizontal & Vertical
|
||||
- Auto (horizontal) Align (numeric columns aligned Right)
|
||||
- Custom (horizontal) Align per column (`ColumnConfig.Align*`)
|
||||
- Custom (vertical) VAlign per column with multi-line cell support (`ColumnConfig.VAlign*`)
|
||||
- Mirror output to an `io.Writer` (ex. `os.StdOut`) (`SetOutputMirror`)
|
||||
- Sort by one or more Columns (`SortBy`)
|
||||
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
|
||||
- Suppress trailing spaces in the last column (`SupressTrailingSpaces`)
|
||||
- Customizable Cell rendering per Column (`ColumnConfig.Transformer*`)
|
||||
- Hide any columns that you don't want displayed (`ColumnConfig.Hidden`)
|
||||
- Reset Headers/Rows/Footers at will to reuse the same Table Writer (`Reset*`)
|
||||
- Completely customizable styles (`SetStyle`/`Style`)
|
||||
- Many ready-to-use styles: [style.go](style.go)
|
||||
- Colorize Headers/Body/Footers using [../text/color.go](../text/color.go)
|
||||
- Custom text-case for Headers/Body/Footers
|
||||
- Enable separators between each row
|
||||
- Render table without a Border
|
||||
- and a lot more...
|
||||
- Render as:
|
||||
- (ASCII/Unicode) Table
|
||||
- CSV
|
||||
- HTML Table (with custom CSS Class)
|
||||
- Markdown Table
|
||||
- TSV
|
||||
## Sample Table Rendering
|
||||
|
||||
```
|
||||
+---------------------------------------------------------------------+
|
||||
@@ -57,396 +22,120 @@ Pretty-print tables into ASCII/Unicode strings.
|
||||
A demonstration of all the capabilities can be found here:
|
||||
[../cmd/demo-table](../cmd/demo-table)
|
||||
|
||||
If you want very specific examples, read ahead.
|
||||
If you want very specific examples, look at the [EXAMPLES.md](EXAMPLES.md) file.
|
||||
|
||||
**Hint**: I've tried to ensure that almost all supported use-cases are covered
|
||||
by unit-tests and that they print the table rendered. Run
|
||||
`go test -v github.com/jedib0t/go-pretty/v6/table` to see the test outputs and
|
||||
help you figure out how to do something.
|
||||
## Features
|
||||
|
||||
# Examples
|
||||
### Core Table Building
|
||||
|
||||
All the examples below are going to start with the following block, although
|
||||
nothing except a single Row is mandatory for the `Render()` function to render
|
||||
something:
|
||||
```golang
|
||||
package main
|
||||
- Add Rows one-by-one or as a group (`AppendRow`/`AppendRows`)
|
||||
- Add Header(s) and Footer(s) (`AppendHeader`/`AppendFooter`)
|
||||
- Add a Separator manually after any Row (`AppendSeparator`)
|
||||
- Add Title above the table (`SetTitle`)
|
||||
- Add Caption below the table (`SetCaption`)
|
||||
- Import 1D or 2D arrays/grids as rows (`ImportGrid`)
|
||||
- Reset Headers/Rows/Footers at will to reuse the same Table Writer (`Reset*`)
|
||||
|
||||
import (
|
||||
"os"
|
||||
### Indexing & Navigation
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
- Auto Index Rows (1, 2, 3 ...) and Columns (A, B, C, ...) (`SetAutoIndex`)
|
||||
- Set which column is the index column (`SetIndexColumn`)
|
||||
- Pager interface for navigating through paged output (`Pager()`)
|
||||
- `GoTo(pageNum)` - Jump to specific page
|
||||
- `Next()` - Move to next page
|
||||
- `Prev()` - Move to previous page
|
||||
- `Location()` - Get current page number
|
||||
- `Render()` - Render current page
|
||||
- `SetOutputMirror()` - Mirror output to io.Writer
|
||||
|
||||
func main() {
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(os.Stdout)
|
||||
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
t.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
|
||||
})
|
||||
t.AppendSeparator()
|
||||
t.AppendRow([]interface{}{300, "Tyrion", "Lannister", 5000})
|
||||
t.AppendFooter(table.Row{"", "", "Total", 10000})
|
||||
t.Render()
|
||||
}
|
||||
```
|
||||
Running the above will result in:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
### Auto Merge
|
||||
|
||||
## Styles
|
||||
- Auto Merge cells (_not supported in CSV/Markdown/TSV modes_)
|
||||
- Cells in a Row (`RowConfig.AutoMerge`)
|
||||
- Columns (`ColumnConfig.AutoMerge`) (_not supported in HTML mode_)
|
||||
- Custom alignment for merged cells (`RowConfig.AutoMergeAlign`)
|
||||
|
||||
You can customize almost every single thing about the table above. The previous
|
||||
example just defaulted to `StyleDefault` during `Render()`. You can use a
|
||||
ready-to-use style (as in [style.go](style.go)) or customize it as you want.
|
||||
### Size & Width Control
|
||||
|
||||
### Ready-to-use Styles
|
||||
- Limit the length of Rows (`SetAllowedRowLength` or `Style().Size.WidthMax`)
|
||||
- Auto-size Rows (`Style().Size.WidthMin` and `Style().Size.WidthMax`)
|
||||
- Column width control (`ColumnConfig.WidthMin` and `ColumnConfig.WidthMax`)
|
||||
- Custom width enforcement functions (`ColumnConfig.WidthMaxEnforcer`)
|
||||
- Default: `text.WrapText`
|
||||
- Options: `text.WrapSoft`, `text.WrapHard`, `text.Trim`, or custom function
|
||||
|
||||
Table comes with a bunch of ready-to-use Styles that make the table look really
|
||||
good. Set or Change the style using:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
│ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 1 │ Arya │ Stark │ 3000 │ │
|
||||
│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ │ │ TOTAL │ 10000 │ │
|
||||
└─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
```
|
||||
### Alignment
|
||||
|
||||
Or if you want to use a full-color mode, and don't care for boxes, use:
|
||||
```golang
|
||||
t.SetStyle(table.StyleColoredBright)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
- **Horizontal Alignment**
|
||||
- Auto (numeric columns aligned Right, text aligned Left)
|
||||
- Custom per column (`ColumnConfig.Align`, `AlignHeader`, `AlignFooter`)
|
||||
- Options: Left, Center, Right, Justify, Auto
|
||||
- **Vertical Alignment**
|
||||
- Custom per column with multi-line cell support (`ColumnConfig.VAlign`, `VAlignHeader`, `VAlignFooter`)
|
||||
- Options: Top, Middle, Bottom
|
||||
|
||||
<img src="images/table-StyleColoredBright.png" width="640px" alt="Colored Table"/>
|
||||
### Sorting & Filtering
|
||||
|
||||
### Roll your own Style
|
||||
- **Sorting**
|
||||
- Sort by one or more Columns (`SortBy`)
|
||||
- Multiple column sorting support
|
||||
- Various sort modes: Alphabetical, Numeric, Alpha-numeric, Numeric-alpha
|
||||
- Case-insensitive sorting option (`IgnoreCase`)
|
||||
- Custom sorting functions (`CustomLess`) for advanced sorting logic
|
||||
- **Filtering**
|
||||
- Filter by one or more Columns (`FilterBy`)
|
||||
- Multiple filters with AND logic (all must match)
|
||||
- Various filter operators:
|
||||
- Equality: Equal, NotEqual
|
||||
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
|
||||
- String: Contains, NotContains, StartsWith, EndsWith
|
||||
- Regex: RegexMatch, RegexNotMatch
|
||||
- Case-insensitive filtering option (`IgnoreCase`)
|
||||
- Custom filter functions (`CustomFilter`) for advanced filtering logic
|
||||
- Filters are applied before sorting
|
||||
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
|
||||
- Hide specific columns (`ColumnConfig.Hidden`)
|
||||
- Suppress trailing spaces in the last column (`SuppressTrailingSpaces`)
|
||||
|
||||
You can also roll your own style:
|
||||
```golang
|
||||
t.SetStyle(table.Style{
|
||||
Name: "myNewStyle",
|
||||
Box: table.BoxStyle{
|
||||
BottomLeft: "\\",
|
||||
BottomRight: "/",
|
||||
BottomSeparator: "v",
|
||||
Left: "[",
|
||||
LeftSeparator: "{",
|
||||
MiddleHorizontal: "-",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: "<",
|
||||
PaddingRight: ">",
|
||||
Right: "]",
|
||||
RightSeparator: "}",
|
||||
TopLeft: "(",
|
||||
TopRight: ")",
|
||||
TopSeparator: "^",
|
||||
UnfinishedRow: " ~~~",
|
||||
},
|
||||
Color: table.ColorOptions{
|
||||
IndexColumn: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Footer: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
},
|
||||
Format: table.FormatOptions{
|
||||
Footer: text.FormatUpper,
|
||||
Header: text.FormatUpper,
|
||||
Row: text.FormatDefault,
|
||||
},
|
||||
Options: table.Options{
|
||||
DrawBorder: true,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
### Customization & Styling
|
||||
|
||||
Or you can use one of the ready-to-use Styles, and just make a few tweaks:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Color.Header = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Color.IndexColumn = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Format.Footer = text.FormatLower
|
||||
t.Style().Options.DrawBorder = false
|
||||
```
|
||||
- **Row Coloring**
|
||||
- Custom row painter function (`SetRowPainter`)
|
||||
- Row painter with attributes (`RowPainterWithAttributes`)
|
||||
- Access to row number and sorted position
|
||||
- **Cell Transformation**
|
||||
- Customizable Cell rendering per Column (`ColumnConfig.Transformer`, `TransformerHeader`, `TransformerFooter`)
|
||||
- Use built-in transformers from `text` package (Number, JSON, Time, URL, etc.)
|
||||
- **Column Styling**
|
||||
- Per-column colors (`ColumnConfig.Colors`, `ColorsHeader`, `ColorsFooter`)
|
||||
- Per-column alignment (horizontal and vertical)
|
||||
- Per-column width constraints
|
||||
- **Completely customizable styles** (`SetStyle`/`Style`)
|
||||
- Many ready-to-use styles: [style.go](style.go)
|
||||
- `StyleDefault` - Classic ASCII borders
|
||||
- `StyleLight` - Light box-drawing characters
|
||||
- `StyleBold` - Bold box-drawing characters
|
||||
- `StyleDouble` - Double box-drawing characters
|
||||
- `StyleRounded` - Rounded box-drawing characters
|
||||
- `StyleColoredBright` - Bright colors, no borders
|
||||
- `StyleColoredDark` - Dark colors, no borders
|
||||
- Many more colored variants (Blue, Cyan, Green, Magenta, Red, Yellow)
|
||||
- Colorize Headers/Body/Footers using [../text/color.go](../text/color.go)
|
||||
- Custom text-case for Headers/Body/Footers
|
||||
- Enable/disable separators between rows
|
||||
- Render table with or without borders
|
||||
- Customize box-drawing characters
|
||||
- Horizontal separators per section (title, header, rows, footer) using `BoxStyleHorizontal`
|
||||
- Title and caption styling options
|
||||
- HTML rendering options (CSS class, escaping, newlines, color conversion)
|
||||
- Bidirectional text support (`Style().Format.Direction`)
|
||||
|
||||
## Auto-Merge
|
||||
### Output Formats
|
||||
|
||||
You can auto-merge cells horizontally and vertically, but you have request for
|
||||
it specifically for each row/column using `RowConfig` or `ColumnConfig`.
|
||||
|
||||
```golang
|
||||
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
|
||||
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rowConfigAutoMerge)
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "EXE", "RUN"})
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendFooter(table.Row{"", "", "", 7, 5, 3})
|
||||
t.SetAutoIndex(true)
|
||||
t.SetColumnConfigs([]table.ColumnConfig{
|
||||
{Number: 1, AutoMerge: true},
|
||||
{Number: 2, AutoMerge: true},
|
||||
{Number: 3, AutoMerge: true},
|
||||
{Number: 4, AutoMerge: true},
|
||||
{Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
{Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
})
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Options.SeparateRows = true
|
||||
fmt.Println(t.Render())
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌───┬─────────┬────────┬───────────┬───────────┬───────────┐
|
||||
│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │
|
||||
│ │ │ │ │ ├─────┬─────┤
|
||||
│ │ │ │ │ │ EXE │ RUN │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 2 │ │ │ │ C 2 │ Y │ N │
|
||||
├───┤ │ ├───────────┼───────────┼─────┴─────┤
|
||||
│ 3 │ │ │ NS 1B │ C 3 │ N │
|
||||
├───┤ ├────────┼───────────┼───────────┼───────────┤
|
||||
│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 5 │ │ │ │ C 5 │ Y │ N │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │
|
||||
├───┤ │ │ ├───────────┼───────────┤
|
||||
│ 7 │ │ │ │ C 7 │ Y │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤
|
||||
│ │ │ │ │ 7 │ 5 │ 3 │
|
||||
└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘
|
||||
```
|
||||
|
||||
## Paging
|
||||
|
||||
You can limit then number of lines rendered in a single "Page". This logic
|
||||
can handle rows with multiple lines too. Here is a simple example:
|
||||
```golang
|
||||
t.SetPageSize(1)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
Sorting can be done on one or more columns. The following code will make the
|
||||
rows be sorted first by "First Name" and then by "Last Name" (in case of similar
|
||||
"First Name" entries).
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{Name: "First Name", Mode: table.Asc},
|
||||
{Name: "Last Name", Mode: table.Asc},
|
||||
})
|
||||
```
|
||||
|
||||
## Wrapping (or) Row/Column Width restrictions
|
||||
|
||||
You can restrict the maximum (text) width for a Row:
|
||||
```golang
|
||||
t.SetAllowedRowLength(50)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| # | FIRST NAME | LAST NAME | SALARY | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 1 | Arya | Stark | 3000 | ~
|
||||
| 20 | Jon | Snow | 2000 | You kn ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 300 | Tyrion | Lannister | 5000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| | | TOTAL | 10000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
```
|
||||
|
||||
## Column Control - Alignment, Colors, Width and more
|
||||
|
||||
You can control a lot of things about individual cells/columns which overrides
|
||||
global properties/styles using the `SetColumnConfig()` interface:
|
||||
- Alignment (horizontal & vertical)
|
||||
- Colorization
|
||||
- Transform individual cells based on the content
|
||||
- Visibility
|
||||
- Width (minimum & maximum)
|
||||
|
||||
```golang
|
||||
nameTransformer := text.Transformer(func(val interface{}) string {
|
||||
return text.Bold.Sprint(val)
|
||||
})
|
||||
|
||||
t.SetColumnConfigs([]ColumnConfig{
|
||||
{
|
||||
Name: "First Name",
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
Colors: text.Colors{text.BgBlack, text.FgRed},
|
||||
ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold},
|
||||
ColorsFooter: text.Colors{text.BgRed, text.FgBlack},
|
||||
Hidden: false,
|
||||
Transformer: nameTransformer,
|
||||
TransformerFooter: nameTransformer,
|
||||
TransformerHeader: nameTransformer,
|
||||
VAlign: text.VAlignMiddle,
|
||||
VAlignFooter: text.VAlignTop,
|
||||
VAlignHeader: text.VAlignBottom,
|
||||
WidthMin: 6,
|
||||
WidthMax: 64,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Render As ...
|
||||
|
||||
Tables can be rendered in other common formats such as:
|
||||
|
||||
### ... CSV
|
||||
|
||||
```golang
|
||||
t.RenderCSV()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
,First Name,Last Name,Salary,
|
||||
1,Arya,Stark,3000,
|
||||
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
|
||||
300,Tyrion,Lannister,5000,
|
||||
,,Total,10000,
|
||||
```
|
||||
|
||||
### ... HTML Table
|
||||
|
||||
```golang
|
||||
t.Style().HTML = table.HTMLOptions{
|
||||
CSSClass: "game-of-thrones",
|
||||
EmptyColumn: " ",
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
}
|
||||
t.RenderHTML()
|
||||
```
|
||||
to get:
|
||||
```html
|
||||
<table class="game-of-thrones">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="right">#</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th align="right">Salary</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="right">1</td>
|
||||
<td>Arya</td>
|
||||
<td>Stark</td>
|
||||
<td align="right">3000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">20</td>
|
||||
<td>Jon</td>
|
||||
<td>Snow</td>
|
||||
<td align="right">2000</td>
|
||||
<td>You know nothing, Jon Snow!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">300</td>
|
||||
<td>Tyrion</td>
|
||||
<td>Lannister</td>
|
||||
<td align="right">5000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td align="right"> </td>
|
||||
<td> </td>
|
||||
<td>Total</td>
|
||||
<td align="right">10000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
```
|
||||
|
||||
### ... Markdown Table
|
||||
|
||||
```golang
|
||||
t.RenderMarkdown()
|
||||
```
|
||||
to get:
|
||||
```markdown
|
||||
| # | First Name | Last Name | Salary | |
|
||||
| ---:| --- | --- | ---:| --- |
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
| | | Total | 10000 | |
|
||||
```
|
||||
- **Render as:**
|
||||
- (ASCII/Unicode) Table - Human-readable pretty format
|
||||
- CSV - Comma-separated values
|
||||
- HTML Table - With custom CSS Class and options
|
||||
- Markdown Table - Markdown-compatible format
|
||||
- TSV - Tab-separated values
|
||||
- Mirror output to an `io.Writer` (ex. `os.StdOut`) (`SetOutputMirror`)
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FilterBy defines what to filter (Column Name or Number), how to filter (Operator),
|
||||
// and the value to compare against.
|
||||
type FilterBy struct {
|
||||
// Name is the name of the Column as it appears in the first Header row.
|
||||
// If a Header is not provided, or the name is not found in the header, this
|
||||
// will not work.
|
||||
Name string
|
||||
// Number is the Column # from left. When specified, it overrides the Name
|
||||
// property. If you know the exact Column number, use this instead of Name.
|
||||
Number int
|
||||
|
||||
// Operator defines how to compare the column value against the Value.
|
||||
Operator FilterOperator
|
||||
|
||||
// Value is the value to compare against. The type should match the expected
|
||||
// comparison type (string for string operations, numeric for numeric operations).
|
||||
// For Contains, StartsWith, EndsWith, and RegexMatch, Value should be a string.
|
||||
// For numeric comparisons (Equal, NotEqual, GreaterThan, etc.), Value can be
|
||||
// a number (int, float64) or a string representation of a number.
|
||||
Value interface{}
|
||||
|
||||
// IgnoreCase makes string comparisons case-insensitive (only applies to
|
||||
// string-based operators).
|
||||
IgnoreCase bool
|
||||
|
||||
// CustomFilter is a function that can be used to filter rows in a custom
|
||||
// manner. Note that:
|
||||
// * This overrides and ignores the Operator, Value, and IgnoreCase settings
|
||||
// * This is called after the column contents are converted to string form
|
||||
// * This function is expected to return:
|
||||
// * true => include the row
|
||||
// * false => exclude the row
|
||||
//
|
||||
// Use this when the default filtering logic is not sufficient.
|
||||
CustomFilter func(cellValue string) bool
|
||||
}
|
||||
|
||||
// FilterOperator defines how to filter.
|
||||
type FilterOperator int
|
||||
|
||||
const (
|
||||
// Equal filters rows where the column value equals the Value.
|
||||
Equal FilterOperator = iota
|
||||
// NotEqual filters rows where the column value does not equal the Value.
|
||||
NotEqual
|
||||
// GreaterThan filters rows where the column value is greater than the Value.
|
||||
GreaterThan
|
||||
// GreaterThanOrEqual filters rows where the column value is greater than or equal to the Value.
|
||||
GreaterThanOrEqual
|
||||
// LessThan filters rows where the column value is less than the Value.
|
||||
LessThan
|
||||
// LessThanOrEqual filters rows where the column value is less than or equal to the Value.
|
||||
LessThanOrEqual
|
||||
// Contains filters rows where the column value contains the Value (string search).
|
||||
Contains
|
||||
// NotContains filters rows where the column value does not contain the Value (string search).
|
||||
NotContains
|
||||
// StartsWith filters rows where the column value starts with the Value.
|
||||
StartsWith
|
||||
// EndsWith filters rows where the column value ends with the Value.
|
||||
EndsWith
|
||||
// RegexMatch filters rows where the column value matches the Value as a regular expression.
|
||||
RegexMatch
|
||||
// RegexNotMatch filters rows where the column value does not match the Value as a regular expression.
|
||||
RegexNotMatch
|
||||
)
|
||||
|
||||
func (t *Table) parseFilterBy(filterBy []FilterBy) []FilterBy {
|
||||
var resFilterBy []FilterBy
|
||||
for _, filter := range filterBy {
|
||||
colNum := 0
|
||||
if filter.Number > 0 && filter.Number <= t.numColumns {
|
||||
colNum = filter.Number
|
||||
} else if filter.Name != "" && len(t.rowsHeaderRaw) > 0 {
|
||||
// Parse from raw header rows
|
||||
for idx, colName := range t.rowsHeaderRaw[0] {
|
||||
if fmt.Sprint(colName) == filter.Name {
|
||||
colNum = idx + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if colNum > 0 {
|
||||
resFilterBy = append(resFilterBy, FilterBy{
|
||||
Name: filter.Name,
|
||||
Number: colNum,
|
||||
Operator: filter.Operator,
|
||||
Value: filter.Value,
|
||||
IgnoreCase: filter.IgnoreCase,
|
||||
CustomFilter: filter.CustomFilter,
|
||||
})
|
||||
}
|
||||
}
|
||||
return resFilterBy
|
||||
}
|
||||
|
||||
func (t *Table) matchesFiltersRaw(row Row, filters []FilterBy) bool {
|
||||
// All filters must match (AND logic)
|
||||
for _, filter := range filters {
|
||||
if !t.matchesFilterRaw(row, filter) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Table) matchesFilterRaw(row Row, filter FilterBy) bool {
|
||||
colIdx := filter.Number - 1
|
||||
if colIdx < 0 || colIdx >= len(row) {
|
||||
return false
|
||||
}
|
||||
|
||||
cellValue := row[colIdx]
|
||||
cellValueStr := fmt.Sprint(cellValue)
|
||||
|
||||
// Use custom filter if provided
|
||||
if filter.CustomFilter != nil {
|
||||
return filter.CustomFilter(cellValueStr)
|
||||
}
|
||||
|
||||
// Use operator-based filtering
|
||||
return t.matchesOperator(cellValueStr, filter)
|
||||
}
|
||||
|
||||
func (t *Table) matchesOperator(cellValue string, filter FilterBy) bool {
|
||||
switch filter.Operator {
|
||||
case Equal:
|
||||
return t.compareEqual(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case NotEqual:
|
||||
return !t.compareEqual(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case GreaterThan:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a > b })
|
||||
case GreaterThanOrEqual:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a >= b })
|
||||
case LessThan:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a < b })
|
||||
case LessThanOrEqual:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a <= b })
|
||||
case Contains:
|
||||
return t.compareContains(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case NotContains:
|
||||
return !t.compareContains(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case StartsWith:
|
||||
return t.compareStartsWith(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case EndsWith:
|
||||
return t.compareEndsWith(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case RegexMatch:
|
||||
return t.compareRegexMatch(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case RegexNotMatch:
|
||||
return !t.compareRegexMatch(cellValue, filter.Value, filter.IgnoreCase)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) compareEqual(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.EqualFold(cellValue, filterStr)
|
||||
}
|
||||
return cellValue == filterStr
|
||||
}
|
||||
|
||||
func (t *Table) compareNumeric(cellValue string, filterValue interface{}, compareFunc func(float64, float64) bool) bool {
|
||||
cellNum, cellErr := strconv.ParseFloat(cellValue, 64)
|
||||
if cellErr != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var filterNum float64
|
||||
switch v := filterValue.(type) {
|
||||
case int:
|
||||
filterNum = float64(v)
|
||||
case int64:
|
||||
filterNum = float64(v)
|
||||
case float64:
|
||||
filterNum = v
|
||||
case float32:
|
||||
filterNum = float64(v)
|
||||
case string:
|
||||
var err error
|
||||
filterNum, err = strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
// Try to convert to string and parse
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
var err error
|
||||
filterNum, err = strconv.ParseFloat(filterStr, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return compareFunc(cellNum, filterNum)
|
||||
}
|
||||
|
||||
func (t *Table) compareContains(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.Contains(strings.ToLower(cellValue), strings.ToLower(filterStr))
|
||||
}
|
||||
return strings.Contains(cellValue, filterStr)
|
||||
}
|
||||
|
||||
func (t *Table) compareStartsWith(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.HasPrefix(strings.ToLower(cellValue), strings.ToLower(filterStr))
|
||||
}
|
||||
return strings.HasPrefix(cellValue, filterStr)
|
||||
}
|
||||
|
||||
func (t *Table) compareEndsWith(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.HasSuffix(strings.ToLower(cellValue), strings.ToLower(filterStr))
|
||||
}
|
||||
return strings.HasSuffix(cellValue, filterStr)
|
||||
}
|
||||
|
||||
func (t *Table) compareRegexMatch(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
|
||||
// Compile the regex pattern
|
||||
var pattern *regexp.Regexp
|
||||
var err error
|
||||
if ignoreCase {
|
||||
pattern, err = regexp.Compile("(?i)" + filterStr)
|
||||
} else {
|
||||
pattern, err = regexp.Compile(filterStr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// If regex compilation fails, fall back to simple string matching
|
||||
return t.compareEqual(cellValue, filterValue, ignoreCase)
|
||||
}
|
||||
|
||||
return pattern.MatchString(cellValue)
|
||||
}
|
||||
+64
-23
@@ -20,7 +20,7 @@ import (
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
func (t *Table) Render() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeDefault)
|
||||
|
||||
var out strings.Builder
|
||||
if t.numColumns > 0 {
|
||||
@@ -50,6 +50,7 @@ func (t *Table) Render() string {
|
||||
return t.render(&out)
|
||||
}
|
||||
|
||||
//gocyclo:ignore
|
||||
func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxColumnLength int, hint renderHint) int {
|
||||
numColumnsRendered := 1
|
||||
|
||||
@@ -93,11 +94,10 @@ func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxCo
|
||||
numColumnsRendered++
|
||||
}
|
||||
}
|
||||
colStr = align.Apply(colStr, maxColumnLength)
|
||||
|
||||
// pad both sides of the column
|
||||
if !hint.isSeparatorRow || (hint.isSeparatorRow && mergeVertically) {
|
||||
colStr = t.style.Box.PaddingLeft + colStr + t.style.Box.PaddingRight
|
||||
colStr = t.style.Box.PaddingLeft + align.Apply(colStr, maxColumnLength) + t.style.Box.PaddingRight
|
||||
}
|
||||
|
||||
t.renderColumnColorized(out, colIdx, colStr, hint)
|
||||
@@ -112,9 +112,9 @@ func (t *Table) renderColumnAutoIndex(out *strings.Builder, hint renderHint) {
|
||||
if hint.isSeparatorRow {
|
||||
numChars := t.autoIndexVIndexMaxLength + utf8.RuneCountInString(t.style.Box.PaddingLeft) +
|
||||
utf8.RuneCountInString(t.style.Box.PaddingRight)
|
||||
chars := t.style.Box.MiddleHorizontal
|
||||
chars := t.style.Box.middleHorizontal(hint.separatorType)
|
||||
if hint.isAutoIndexColumn && hint.isHeaderOrFooterSeparator() {
|
||||
chars = text.RepeatAndTrim(" ", len(t.style.Box.MiddleHorizontal))
|
||||
chars = text.RepeatAndTrim(" ", len(chars))
|
||||
}
|
||||
outAutoIndex.WriteString(text.RepeatAndTrim(chars, numChars))
|
||||
} else {
|
||||
@@ -239,7 +239,7 @@ func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Bu
|
||||
}
|
||||
|
||||
func (t *Table) renderMarginLeft(out *strings.Builder, hint renderHint) {
|
||||
out.WriteString(t.style.Format.Direction.Modifier())
|
||||
out.WriteString(t.directionModifier)
|
||||
if t.style.Options.DrawBorder {
|
||||
border := t.getBorderLeft(hint)
|
||||
colors := t.getBorderColors(hint)
|
||||
@@ -304,8 +304,10 @@ func (t *Table) renderRowSeparator(out *strings.Builder, hint renderHint) {
|
||||
} else if hint.isFooterRow && !t.style.Options.SeparateFooter {
|
||||
return
|
||||
}
|
||||
|
||||
hint.isSeparatorRow = true
|
||||
t.renderLine(out, t.rowSeparator, hint)
|
||||
separator := t.rowSeparatorStrings[hint.separatorType]
|
||||
t.renderLine(out, t.rowSeparators[separator], hint)
|
||||
}
|
||||
|
||||
func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
|
||||
@@ -316,8 +318,17 @@ func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint)
|
||||
t.renderRow(out, row, hint)
|
||||
|
||||
if t.shouldSeparateRows(rowIdx, len(rows)) {
|
||||
hint.isFirstRow = false
|
||||
t.renderRowSeparator(out, hint)
|
||||
hintSep := hint
|
||||
hintSep.isFirstRow = false
|
||||
hintSep.isSeparatorRow = true
|
||||
if hintSep.isHeaderRow {
|
||||
hintSep.separatorType = separatorTypeHeaderMiddle
|
||||
} else if hintSep.isFooterRow {
|
||||
hintSep.separatorType = separatorTypeFooterMiddle
|
||||
} else {
|
||||
hintSep.separatorType = separatorTypeRowMiddle
|
||||
}
|
||||
t.renderRowSeparator(out, hintSep)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,46 +339,69 @@ func (t *Table) renderRowsBorderBottom(out *strings.Builder) {
|
||||
isBorderBottom: true,
|
||||
isFooterRow: true,
|
||||
rowNumber: len(t.rowsFooter),
|
||||
separatorType: separatorTypeFooterBottom,
|
||||
})
|
||||
} else {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isBorderBottom: true,
|
||||
isFooterRow: false,
|
||||
rowNumber: len(t.rows),
|
||||
separatorType: separatorTypeRowBottom,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) renderRowsBorderTop(out *strings.Builder) {
|
||||
st := separatorTypeHeaderTop
|
||||
if t.title != "" {
|
||||
st = separatorTypeTitleBottom
|
||||
} else if len(t.rowsHeader) == 0 && !t.autoIndex {
|
||||
st = separatorTypeRowTop
|
||||
}
|
||||
|
||||
if len(t.rowsHeader) > 0 || t.autoIndex {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isBorderTop: true,
|
||||
isHeaderRow: true,
|
||||
rowNumber: 0,
|
||||
isBorderTop: true,
|
||||
isHeaderRow: true,
|
||||
isSeparatorRow: true,
|
||||
rowNumber: 0,
|
||||
separatorType: st,
|
||||
})
|
||||
} else {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isBorderTop: true,
|
||||
isHeaderRow: false,
|
||||
rowNumber: 0,
|
||||
isBorderTop: true,
|
||||
isHeaderRow: false,
|
||||
isSeparatorRow: true,
|
||||
rowNumber: 0,
|
||||
separatorType: st,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) renderRowsFooter(out *strings.Builder) {
|
||||
if len(t.rowsFooter) > 0 {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isFooterRow: true,
|
||||
isFirstRow: true,
|
||||
isSeparatorRow: true,
|
||||
})
|
||||
// Only add separator before footer if there are data rows.
|
||||
// Otherwise, renderRowsHeader already added one.
|
||||
if len(t.rows) > 0 {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isFooterRow: true,
|
||||
isFirstRow: true,
|
||||
isSeparatorRow: true,
|
||||
separatorType: separatorTypeFooterTop,
|
||||
})
|
||||
}
|
||||
t.renderRows(out, t.rowsFooter, renderHint{isFooterRow: true})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) renderRowsHeader(out *strings.Builder) {
|
||||
if len(t.rowsHeader) > 0 || t.autoIndex {
|
||||
hintSeparator := renderHint{isHeaderRow: true, isLastRow: true, isSeparatorRow: true}
|
||||
hintSeparator := renderHint{
|
||||
isHeaderRow: true,
|
||||
isLastRow: true,
|
||||
isSeparatorRow: true,
|
||||
separatorType: separatorTypeHeaderMiddle,
|
||||
}
|
||||
|
||||
if len(t.rowsHeader) > 0 {
|
||||
t.renderRows(out, t.rowsHeader, renderHint{isHeaderRow: true})
|
||||
@@ -376,7 +410,13 @@ func (t *Table) renderRowsHeader(out *strings.Builder) {
|
||||
t.renderRow(out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true})
|
||||
hintSeparator.rowNumber = 1
|
||||
}
|
||||
t.renderRowSeparator(out, hintSeparator)
|
||||
|
||||
// Only add separator after header if there are data rows or footer rows.
|
||||
// Otherwise, the bottom border is rendered directly.
|
||||
if len(t.rows) > 0 || len(t.rowsFooter) > 0 || !t.style.Options.DoNotRenderSeparatorWhenEmpty {
|
||||
hintSeparator.separatorType = separatorTypeHeaderBottom
|
||||
t.renderRowSeparator(out, hintSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,8 +433,9 @@ func (t *Table) renderTitle(out *strings.Builder) {
|
||||
}
|
||||
if t.style.Options.DrawBorder {
|
||||
lenBorder := rowLength - text.StringWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight)
|
||||
middleHorizontal := t.style.Box.middleHorizontal(separatorTypeTitleTop)
|
||||
out.WriteString(colorsBorder.Sprint(t.style.Box.TopLeft))
|
||||
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(t.style.Box.MiddleHorizontal, lenBorder)))
|
||||
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(middleHorizontal, lenBorder)))
|
||||
out.WriteString(colorsBorder.Sprint(t.style.Box.TopRight))
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ import (
|
||||
// 300,Tyrion,Lannister,5000,
|
||||
// ,,Total,10000,
|
||||
func (t *Table) RenderCSV() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeCSV)
|
||||
|
||||
var out strings.Builder
|
||||
if t.numColumns > 0 {
|
||||
@@ -36,11 +36,11 @@ func (t *Table) RenderCSV() string {
|
||||
}
|
||||
|
||||
func (t *Table) csvFixCommas(str string) string {
|
||||
return strings.Replace(str, ",", "\\,", -1)
|
||||
return strings.ReplaceAll(str, ",", "\\,")
|
||||
}
|
||||
|
||||
func (t *Table) csvFixDoubleQuotes(str string) string {
|
||||
return strings.Replace(str, "\"", "\\\"", -1)
|
||||
return strings.ReplaceAll(str, "\"", "\\\"")
|
||||
}
|
||||
|
||||
func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint) {
|
||||
@@ -54,7 +54,7 @@ func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
|
||||
// auto-index column
|
||||
if colIdx == 0 && t.autoIndex {
|
||||
if hint.isRegularRow() {
|
||||
out.WriteString(fmt.Sprint(hint.rowNumber))
|
||||
fmt.Fprint(out, hint.rowNumber)
|
||||
}
|
||||
out.WriteRune(',')
|
||||
}
|
||||
|
||||
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user