diff --git a/cli_test.go b/cli_test.go index 80a09c3..1f360cf 100644 --- a/cli_test.go +++ b/cli_test.go @@ -47,7 +47,6 @@ resources: defer ts.Close() yaml, cliErr := exec.Command("./jx", "import", "--resource", ts.URL).Output() - slog.Info("TestCliHTTPSource", "err", cliErr) assert.Nil(t, cliErr) assert.NotEqual(t, "", string(yaml)) assert.Greater(t, len(yaml), 0) diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 34530b4..ad75474 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -29,7 +29,7 @@ var ( ) var GlobalOformat *string -var GlobalOutput *string +var GlobalOutput string var GlobalQuiet *bool var ImportMerge *bool @@ -89,7 +89,6 @@ func LoadSourceURI(uri string) []*resource.Document { if extractErr != nil { log.Fatal(extractErr) } - slog.Info("extract documents", "documents", extractDocuments) return extractDocuments } return []*resource.Document{ resource.NewDocument() } @@ -103,7 +102,6 @@ func ImportSubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { return e } - var encoder resource.Encoder merged := resource.NewDocument() documents := make([]*resource.Document, 0, 100) for _,source := range cmd.Args() { @@ -113,19 +111,19 @@ func ImportSubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { } } +/* switch *GlobalOformat { case FormatYaml: encoder = resource.NewYAMLEncoder(output) case FormatJson: encoder = resource.NewJSONEncoder(output) } +*/ - if *GlobalOutput != "" { - _, err := target.TargetTypes.New(*GlobalOutput) - if err != nil { - log.Fatal(err) - } - //outputTarget.EmitResources( + slog.Info("main.ImportResource", "args", os.Args, "output", GlobalOutput) + outputTarget, err := target.TargetTypes.New(GlobalOutput) + if err != nil { + log.Fatal(err) } if len(documents) == 0 { @@ -151,18 +149,18 @@ func ImportSubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { } else { if *ImportMerge { merged.ResourceDecls = append(merged.ResourceDecls, d.ResourceDecls...) - slog.Info("merging", "doc", merged.ResourceDecls, "src", d.ResourceDecls) } else { - if documentGenerateErr := encoder.Encode(d); documentGenerateErr != nil { - return documentGenerateErr + slog.Info("main.ImportResource", "outputTarget", outputTarget, "type", outputTarget.Type()) + if outputErr := outputTarget.EmitResources([]*resource.Document{d}, nil); outputErr != nil { + return outputErr } } } } } if *ImportMerge { - if documentGenerateErr := encoder.Encode(merged); documentGenerateErr != nil { - return documentGenerateErr + if outputErr := outputTarget.EmitResources([]*resource.Document{merged}, nil); outputErr != nil { + return outputErr } } return err @@ -276,8 +274,8 @@ func main() { for _,subCmd := range jxSubCommands { cmdFlagSet := flag.NewFlagSet(subCmd.Name, flag.ExitOnError) GlobalOformat = cmdFlagSet.String("oformat", "yaml", "Output serialization format") - GlobalOutput = cmdFlagSet.String("output", "-", "Output target (default stdout)") - GlobalOutput = cmdFlagSet.String("o", "-", "Output target (default stdout)") + cmdFlagSet.StringVar(&GlobalOutput, "output", "-", "Output target (default stdout)") + cmdFlagSet.StringVar(&GlobalOutput, "o", "-", "Output target (default stdout)") GlobalQuiet = cmdFlagSet.Bool("quiet", false, "Generate terse output.") switch subCmd.Name { @@ -295,22 +293,19 @@ func main() { } case "import": cmdFlagSet.Usage = func() { - fmt.Println("jx import source [source2]") + fmt.Println("jx import source...") cmdFlagSet.PrintDefaults() VersionUsage() } } - slog.Info("command", "command", subCmd) if os.Args[1] == subCmd.Name { if e := subCmd.Run(cmdFlagSet, os.Stdout); e != nil { log.Fatal(e) } return - } else { - flag.PrintDefaults() - VersionUsage() - os.Exit(1) } } - + flag.PrintDefaults() + VersionUsage() + os.Exit(1) } diff --git a/internal/resource/document.go b/internal/resource/document.go index 8b79ae0..e5b623d 100644 --- a/internal/resource/document.go +++ b/internal/resource/document.go @@ -25,7 +25,7 @@ func (d *Document) Filter(filter ResourceSelector) []*Declaration { resources := make([]*Declaration, 0, len(d.ResourceDecls)) for i := range d.ResourceDecls { filterResource := &d.ResourceDecls[i] - if filter(filterResource) { + if filter == nil || filter(filterResource) { resources = append(resources, &d.ResourceDecls[i]) } } diff --git a/internal/resource/file.go b/internal/resource/file.go index a70bbc6..166ccd2 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -45,6 +45,7 @@ func init() { // Manage the state of file system objects type File struct { + normalizePath bool `json:"-" yaml:"-"` Path string `json:"path" yaml:"path"` Owner string `json:"owner" yaml:"owner"` Group string `json:"group" yaml:"group"` @@ -69,13 +70,20 @@ type ResourceFileInfo struct { func NewFile() *File { currentUser, _ := user.Current() group, _ := user.LookupGroupId(currentUser.Gid) - f := &File{Owner: currentUser.Username, Group: group.Name, Mode: "0644", FileType: RegularFile} + f := &File{ normalizePath: false, Owner: currentUser.Username, Group: group.Name, Mode: "0644", FileType: RegularFile} slog.Info("NewFile()", "file", f) return f } +func NewNormalizedFile() *File { + f := NewFile() + f.normalizePath = true + return f +} + func (f *File) Clone() Resource { return &File { + normalizePath: f.normalizePath, Path: f.Path, Owner: f.Owner, Group: f.Group, @@ -100,10 +108,9 @@ func (f *File) SetURI(uri string) error { resourceUri, e := url.Parse(uri) if e == nil { if resourceUri.Scheme == "file" { - if absFilePath, err := filepath.Abs(filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI())); err != nil { + f.Path = filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI()) + if err := f.NormalizePath(); err != nil { return err - } else { - f.Path = absFilePath } } else { e = fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, uri) @@ -187,26 +194,31 @@ func (f *File) Apply() error { return nil } -func (f *File) LoadDecl(yamlResourceDeclaration string) error { +func (f *File) LoadDecl(yamlResourceDeclaration string) (err error) { d := NewYAMLStringDecoder(yamlResourceDeclaration) - return d.Decode(f) + err = d.Decode(f) + if err == nil { + f.UpdateContentAttributes() + } + return } func (f *File) ResolveId(ctx context.Context) string { - filePath, fileAbsErr := filepath.Abs(f.Path) - if fileAbsErr != nil { - panic(fileAbsErr) + if e := f.NormalizePath(); e != nil { + panic(e) } - f.Path = filePath - return filePath + return f.Path } func (f *File) NormalizePath() error { - filePath, fileAbsErr := filepath.Abs(f.Path) - if fileAbsErr == nil { - f.Path = filePath + if f.normalizePath { + filePath, fileAbsErr := filepath.Abs(f.Path) + if fileAbsErr == nil { + f.Path = filePath + } + return fileAbsErr } - return fileAbsErr + return nil } func (f *File) FileInfo() fs.FileInfo { @@ -214,7 +226,8 @@ func (f *File) FileInfo() fs.FileInfo { } func (f *ResourceFileInfo) Name() string { - return filepath.Base(f.resource.Path) +// return filepath.Base(f.resource.Path) + return f.resource.Path } func (f *ResourceFileInfo) Size() int64 { @@ -246,6 +259,11 @@ func (f *ResourceFileInfo) Sys() any { return nil } +func (f *File) UpdateContentAttributes() { + f.Size = int64(len(f.Content)) + f.Sha256 = fmt.Sprintf("%x", sha256.Sum256([]byte(f.Content))) +} + func (f *File) UpdateAttributesFromFileInfo(info os.FileInfo) error { if info != nil { f.Mtime = info.ModTime() diff --git a/internal/resource/iptables.go b/internal/resource/iptables.go new file mode 100644 index 0000000..3234b72 --- /dev/null +++ b/internal/resource/iptables.go @@ -0,0 +1,307 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" +_ "encoding/hex" + "encoding/json" +_ "errors" + "fmt" + "gopkg.in/yaml.v3" + "io" + "net/url" +_ "os/exec" + "regexp" + "strconv" + "strings" + "log/slog" +) + +func init() { + ResourceTypes.Register("iptable", func(u *url.URL) Resource { + i := NewIptable() + i.Table = IptableName(u.Hostname()) + fields := strings.Split(u.Path, "/") + slog.Info("iptables factory", "iptable", i, "uri", u, "field", fields) + i.Chain = IptableChain(fields[1]) + id, _ := strconv.ParseUint(fields[2], 10, 32) + i.Id = uint(id) + return i + }) +} + +type IptableIPVersion string + +const ( + IptableIPv4 IptableIPVersion = "ipv4" + IPtableIPv6 IptableIPVersion = "ipv6" +) + +type IptableName string + +const ( + IptableNameFilter = "filter" + IptableNameNat = "nat" + IptableNameMangel = "mangle" + IptableNameRaw = "raw" + IptableNameSecurity = "security" +) + +var IptableNumber = regexp.MustCompile(`^[0-9]+$`) + +type IptableChain string + +const ( + IptableChainInput = "INPUT" + IptableChainOutput = "OUTPUT" + IptableChainForward = "FORWARD" + IptableChainPreRouting = "PREROUTING" + IptableChainPostRouting = "POSTROUTING" +) + +type IptableProto string + +const ( + IptableProtoTCP = "tcp" + IptableProtoUDP = "udp" + IptableProtoUDPLite = "udplite" + IptableProtoICMP = "icmp" + IptableProtoICMPv6 = "icmpv6" + IptableProtoESP = "ESP" + IptableProtoAH = "AH" + IptableProtoSCTP = "sctp" + IptableProtoMH = "mh" + IptableProtoAll = "all" +) + +type IptableCIDR string + +type ExtensionFlag struct { + Name string `json:"name" yaml:"name"` + Value string `json:"value" yaml:"value"` +} + +type IptablePort uint16 + +// Manage the state of iptables rules +// iptable://filter/INPUT/0 +type Iptable struct { + Id uint `json:"id" yaml:"id"` + Table IptableName `json:"table" yaml:"table"` + Chain IptableChain `json:"chain" yaml:"chain"` + Destination IptableCIDR `json:"destination,omitempty" yaml:"destination,omitempty"` + Source IptableCIDR `json:"source,omitempty" yaml:"source,omitempty"` + Dport IptablePort `json:"dport,omitempty" yaml:"dport,omitempty"` + Sport IptablePort `json:"sport,omitempty" yaml:"sport,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Out string `json:"out,omitempty" yaml:"out,omitempty"` + Match []string `json:"match,omitempty" yaml:"match,omitempty"` + Flags []ExtensionFlag `json:"extension_flags,omitempty" yaml:"extension_flags,omitempty"` + Proto IptableProto `json:"proto,omitempty" yaml:"proto,omitempty"` + Jump string `json:"jump" yaml:"jump"` + State string `json:"state" yaml:"state"` + + CreateCommand *Command `yaml:"-" json:"-"` + ReadCommand *Command `yaml:"-" json:"-"` + UpdateCommand *Command `yaml:"-" json:"-"` + DeleteCommand *Command `yaml:"-" json:"-"` +} + +func NewIptable() *Iptable { + i := &Iptable{} + i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.NewCRUD() + return i +} + +func (i *Iptable) Clone() Resource { + return &Iptable { + Id: i.Id, + Table: i.Table, + Chain: i.Chain, + Destination: i.Destination, + Source: i.Source, + In: i.In, + Out: i.Out, + Match: i.Match, + Proto: i.Proto, + State: i.State, + } +} + +func (i *Iptable) URI() string { + return fmt.Sprintf("iptable://%s/%s/%d", i.Table, i.Chain, i.Id) +} + +func (i *Iptable) SetURI(uri string) error { + resourceUri, e := url.Parse(uri) + if e == nil { + if resourceUri.Scheme == "iptable" { + i.Table = IptableName(resourceUri.Hostname()) + fields := strings.Split(resourceUri.Path, "/") + i.Chain = IptableChain(fields[1]) + id, _ := strconv.ParseUint(fields[2], 10, 32) + i.Id = uint(id) + } else { + e = fmt.Errorf("%w: %s is not an iptable rule", ErrInvalidResourceURI, uri) + } + } + return e +} + +func (i *Iptable) Validate() error { + s := NewSchema(i.Type()) + jsonDoc, jsonErr := i.JSON() + if jsonErr == nil { + return s.Validate(string(jsonDoc)) + } + return jsonErr +} + +func (i *Iptable) JSON() ([]byte, error) { + return json.Marshal(i) +} + +func (i *Iptable) UnmarshalJSON(data []byte) error { + if unmarshalErr := json.Unmarshal(data, i); unmarshalErr != nil { + return unmarshalErr + } + i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.NewCRUD() + return nil +} + +func (i *Iptable) UnmarshalYAML(value *yaml.Node) error { + type decodeIptable Iptable + if unmarshalErr := value.Decode((*decodeIptable)(i)); unmarshalErr != nil { + return unmarshalErr + } + i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.NewCRUD() + return nil +} + +func (i *Iptable) NewCRUD() (create *Command, read *Command, update *Command, del *Command) { + return NewIptableCreateCommand(), NewIptableReadCommand(), NewIptableUpdateCommand(), NewIptableDeleteCommand() +} + +func (i *Iptable) Apply() error { + + switch i.State { + case "absent": + case "present": + } + return nil +} + +func (i *Iptable) Load(r io.Reader) error { + return NewYAMLDecoder(r).Decode(i) +} + +func (i *Iptable) LoadDecl(yamlResourceDeclaration string) error { + return NewYAMLStringDecoder(yamlResourceDeclaration).Decode(i) +} + + +func (i *Iptable) ResolveId(ctx context.Context) string { +// uri := fmt.Sprintf("%s?gateway=%s&interface=%s&rtid=%s&metric=%d&type=%s&scope=%s", +// n.To, n.Gateway, n.Interface, n.Rtid, n.Metric, n.RouteType, n.Scope) +// n.Id = hex.EncodeToString([]byte(uri)) + return fmt.Sprintf("%d", i.Id) +} + +func (i *Iptable) Read(ctx context.Context) ([]byte, error) { + out, err := i.ReadCommand.Execute(i) + if err != nil { + return nil, err + } + exErr := i.ReadCommand.Extractor(out, i) + if exErr != nil { + return nil, exErr + } + return yaml.Marshal(i) +} + +func (i *Iptable) Type() string { return "iptable" } + +func NewIptableCreateCommand() *Command { + c := NewCommand() + c.Path = "iptables" + c.Args = []CommandArg{ + CommandArg("-t"), + CommandArg("{{ .Table }}"), + CommandArg("-R"), + CommandArg("{{ .Chain }}"), + CommandArg("{{ .Id }}"), + CommandArg("{{ if .In }}-i {{ .In }}{{ else if .Out }}-o {{ .Out }}{{ end }}"), + CommandArg("{{ range .Match }}-m {{ . }} {{ end }}"), + CommandArg("{{ if .Source }}-s {{ .Source }}{{ end }}"), + CommandArg("{{ if .Destination }}-d {{ .Destination }}{{ end }}"), + CommandArg("{{ if .Jump }}-j {{ .Jump }}{{ end }}"), + } + return c +} + +func NewIptableReadCommand() *Command { + c := NewCommand() + c.Path = "iptables" + c.Args = []CommandArg{ + CommandArg("-t"), + CommandArg("{{ .Table }}"), + CommandArg("-S"), + CommandArg("{{ .Chain }}"), + CommandArg("{{ .Id }}"), + } + c.Extractor = func(out []byte, target any) error { + i := target.(*Iptable) + ruleFields := strings.Split(strings.TrimSpace(string(out)), " ") + switch ruleFields[0] { + case "-A": + //chain := ruleFields[1] + flags := ruleFields[2:] + for optind,opt := range flags { + if optind > len(flags) - 2 { + break + } + optValue := flags[optind + 1] + switch opt { + case "-i": + i.In = optValue + case "-o": + i.Out = optValue + case "-m": + i.Match = append(i.Match, optValue) + case "-s": + i.Source = IptableCIDR(optValue) + case "-d": + i.Destination = IptableCIDR(optValue) + case "-p": + i.Proto = IptableProto(optValue) + case "-j": + i.Jump = optValue + case "--dport": + port,_ := strconv.ParseUint(optValue, 10, 16) + i.Dport = IptablePort(port) + case "--sport": + port,_ := strconv.ParseUint(optValue, 10, 16) + i.Sport = IptablePort(port) + default: + if opt[0] == '-' { + i.Flags = append(i.Flags, ExtensionFlag{ Name: strings.Trim(opt, "-"), Value: strings.TrimSpace(optValue)}) + } + } + } + i.State = "present" + default: + i.State = "absent" + } + return nil + } + return c +} + +func NewIptableUpdateCommand() *Command { + return nil +} + +func NewIptableDeleteCommand() *Command { + return nil +} diff --git a/internal/resource/iptables_test.go b/internal/resource/iptables_test.go new file mode 100644 index 0000000..f7f92ae --- /dev/null +++ b/internal/resource/iptables_test.go @@ -0,0 +1,76 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" +_ "encoding/json" +_ "fmt" + "github.com/stretchr/testify/assert" +_ "gopkg.in/yaml.v3" +_ "io" +_ "log" +_ "net/http" +_ "net/http/httptest" +_ "net/url" +_ "os" +_ "path/filepath" +_ "strings" +_ "syscall" + "testing" +_ "time" +) + +func TestNewIptableResource(t *testing.T) { + i := NewIptable() + assert.NotNil(t, i) +} + +func TestIptableApplyResourceTransformation(t *testing.T) { + i := NewIptable() + assert.NotNil(t, i) + + //e := f.Apply() + //assert.Equal(t, nil, e) +} + +func TestReadIptable(t *testing.T) { + ctx := context.Background() + testRule := NewIptable() + assert.NotNil(t, testRule) + + declarationAttributes := ` + id: 0 + table: "filter" + chain: "INPUT" + source: "192.168.0.0/24" + destination: "192.168.0.1" + jump: "ACCEPT" + state: present +` + m := &MockCommand{ + Executor: func(value any) ([]byte, error) { + return nil, nil + }, + Extractor: func(output []byte, target any) error { + testRule.Table = "filter" + testRule.Chain = "INPUT" + testRule.Id = 0 + testRule.In = "eth0" + testRule.Source = "192.168.0.0/24" + testRule.State = "present" + return nil + }, + } + + e := testRule.LoadDecl(declarationAttributes) + assert.Nil(t, e) + testRule.ReadCommand = (*Command)(m) +// testRuleErr := testRule.Apply() +// assert.Nil(t, testRuleErr) + r, e := testRule.Read(ctx) + + assert.Nil(t, e) + assert.NotNil(t, r) + assert.Equal(t, "eth0", testRule.In) +} diff --git a/internal/resource/resource_test.go b/internal/resource/resource_test.go index ef7c039..e58ba58 100644 --- a/internal/resource/resource_test.go +++ b/internal/resource/resource_test.go @@ -38,6 +38,7 @@ func TestResolveId(t *testing.T) { testFile := NewResource("file://../../README.md") assert.NotNil(t, testFile) + testFile.(*File).normalizePath = true absolutePath, e := filepath.Abs("../../README.md") assert.Nil(t, e) diff --git a/internal/resource/schemas/document.jsonschema b/internal/resource/schemas/document.jsonschema index c724d31..f47b856 100644 --- a/internal/resource/schemas/document.jsonschema +++ b/internal/resource/schemas/document.jsonschema @@ -15,7 +15,8 @@ { "$ref": "http-declaration.jsonschema" }, { "$ref": "user-declaration.jsonschema" }, { "$ref": "exec-declaration.jsonschema" }, - { "$ref": "network_route-declaration.jsonschema" } + { "$ref": "network_route-declaration.jsonschema" }, + { "$ref": "iptable-declaration.jsonschema" } ] } } diff --git a/internal/resource/schemas/iptable-declaration.jsonschema b/internal/resource/schemas/iptable-declaration.jsonschema new file mode 100644 index 0000000..65dd25b --- /dev/null +++ b/internal/resource/schemas/iptable-declaration.jsonschema @@ -0,0 +1,17 @@ +{ + "$id": "iptable-declaration.jsonschema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "iptable-declaration", + "type": "object", + "required": [ "type", "attributes" ], + "properties": { + "type": { + "type": "string", + "description": "Resource type name.", + "enum": [ "iptable" ] + }, + "attributes": { + "$ref": "iptable.jsonschema" + } + } +} diff --git a/internal/resource/schemas/iptable.jsonschema b/internal/resource/schemas/iptable.jsonschema new file mode 100644 index 0000000..d0158c2 --- /dev/null +++ b/internal/resource/schemas/iptable.jsonschema @@ -0,0 +1,68 @@ +{ + "$id": "iptable.jsonschema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "iptable", + "type": "object", + "required": [ "chain" ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "table": { + "type": "string", + "description": "Rule table name" + }, + "chain": { + "type": "string", + "description": "Rule chain name" + }, + "destination": { + "type": "string", + "description": "Destination CIDR", + "pattern": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" + }, + "source": { + "type": "string", + "description": "Source CIDR", + "pattern": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" + }, + "in": { + "type": "string", + "description": "Input ethernet device" + }, + "out": { + "type": "string", + "description": "Output ethernet device" + }, + "match": { + "type": "array", + "description": "Rule match extensions", + "items": { + "type": "string" + } + }, + "extension_flags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "proto": { + "type": "string", + "description": "Rule protocol", + "pattern": "^(tcp|udp|udplite|icmp|icmpv6|ESP|AH|sctp|mh|all|[0-9]+)$" + }, + "jump": { + "type": "string" + } + } +}