diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c1e803..34fad4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,19 +31,25 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Do NOT use `go-version-file: go.mod` here. go.mod pins a full patch - # version (`go 1.25.0`), so setup-go resolves 1.25.0 EXACTLY and the - # release binaries would ship the original 1.25.0 standard library — which - # has dozens of known vulnerabilities, including crypto/x509 name- - # constraint verification (GO-2025-4175), crypto/tls, encoding/pem - # (GO-2025-4009, on input koc genuinely parses: CA bundles, Vault-supplied - # certs, k8s secrets) and net/http HTTP/2 issues. A `1.25.x` range with - # check-latest resolves to the newest 1.25 patch instead, without raising - # the floor in go.mod (which would force a toolchain download and break the - # offline build for contributors). + # version, so setup-go would resolve it EXACTLY and the release binaries + # would ship that original .0 standard library instead of the newest patch + # — the trap that once shipped go1.25.0's crypto/x509 name-constraint + # verification (GO-2025-4175), crypto/tls and encoding/pem (GO-2025-4009, + # on input koc genuinely parses: CA bundles, Vault-supplied certs, k8s + # secrets) and net/http HTTP/2 bugs. An explicit range + check-latest + # resolves to the newest patch of that minor instead. + # + # This pin is deliberately a minor AHEAD of go.mod's floor. go.mod stays at + # `go 1.26.0` — the lowest the dependency graph allows (golang.org/x/crypto + # v0.56.0 forces it), so a contributor's `GOTOOLCHAIN=local` build stays + # offline on 1.26. The runner uses 1.27 because gofmt changed there: the + # pinned golangci-lint bundles Go 1.27's, and the tree is formatted with it + # (see the lint job). Move the two together or `make fmt` and the lint job + # will disagree about the same files, in both directions. - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false @@ -112,7 +118,7 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false @@ -137,7 +143,7 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false @@ -155,18 +161,18 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false # Pin the exact golangci-lint version used in development and verify the # download against the release's published SHA-256 checksums before # extracting, so a corrupted or swapped asset fails the build. - - name: Install golangci-lint v2.12.2 (checksum-verified) + - name: Install golangci-lint v2.13.2 (checksum-verified) env: GOPROXY: "" # allow the tool download (build/test stay offline) run: | - VER=2.12.2 + VER=2.13.2 TARBALL="golangci-lint-${VER}-linux-amd64.tar.gz" BASE="https://github.com/golangci/golangci-lint/releases/download/v${VER}" curl -sSfL "${BASE}/${TARBALL}" -o "${TARBALL}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aff6c79..004db46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,17 +55,18 @@ jobs: with: fetch-depth: 0 # full history + tags for git describe / release notes - # Explicit 1.25.x + check-latest instead of `go-version-file: go.mod`: - # go.mod pins the full patch `go 1.25.0`, so setup-go would resolve exactly - # 1.25.0 and every released binary would carry the original 1.25.0 standard - # library and its known vulnerabilities (crypto/x509 name-constraint - # verification GO-2025-4175, crypto/tls, encoding/pem GO-2025-4009, - # net/http HTTP/2). The floor stays at 1.25.0 in go.mod so contributors are - # not forced into a toolchain download. + # Explicit 1.27.x + check-latest instead of `go-version-file: go.mod`: + # go.mod pins a full patch, so setup-go would resolve it exactly and every + # released binary would carry that original .0 standard library rather than + # the newest patch's — the trap that once shipped go1.25.0's crypto/x509 + # name-constraint verification (GO-2025-4175), crypto/tls, encoding/pem + # (GO-2025-4009) and net/http HTTP/2 bugs. The floor in go.mod stays lower + # (1.26.0, what golang.org/x/crypto v0.56.0 forces) so contributors need + # the smallest possible toolchain; keep this pin in step with ci.yml's. - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index 5520eb9..5627635 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false @@ -80,7 +80,7 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25.x" + go-version: "1.27.x" check-latest: true cache: false diff --git a/AGENTS.md b/AGENTS.md index 03cad85..a04f2bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,9 @@ the upstream `openstack` client's `noun → verb → flags` syntax and ships as dependency-free binary for air-gapped / FSTEC-regulated deployment. - **Module**: `github.com/ftarasenko/go-openstackclient` (binary name: `koc`) -- **Go**: see `go.mod` (currently `go 1.25`); target ≥ 1.22 +- **Go**: `go.mod` floors the build at `go 1.26.0` (what `golang.org/x/crypto` + forces); **develop on 1.27+** — CI builds and formats there, and gofmt differs + (see "Build / test / lint") - **SDK**: gophercloud **v2** (`github.com/gophercloud/gophercloud/v2`) — never v1 or the dead rackspace fork - **CLI**: cobra + pflag; `golang.org/x/term` for terminal-width detection (rich gauges) - **Deps are vendored** (`vendor/` is committed) — builds must reproduce offline @@ -88,6 +90,17 @@ issues**, `go test ./...` green, the offline static build succeeds, and — if t commit changes the command surface — `docs/coverage.md` is updated (see "Coverage tracking"). +**`gofmt` here means Go 1.27's.** 1.27 changed how a multi-value `return` whose +operands are composite literals is indented, and the two versions disagree in +both directions — 1.26's gofmt reformats what 1.27's wrote and vice versa. The +pinned golangci-lint bundles 1.27's (the whole v2.13 line is built with +go1.27.0) and enforces it as the `gofmt` formatter, so running `make fmt` under +an older toolchain silently reverts the tree and the lint job then rejects the +files it just touched. `go.mod`'s floor stays at 1.26.0 because nothing in the +code needs 1.27 — building and testing on 1.26 is fine, formatting is not. If +the golangci-lint pin ever moves to a build made with a newer Go, re-run +`make fmt` with that Go in the same commit. + ## SonarQube (out-of-band static analysis) SonarQube analysis is **not** part of the per-commit gate above and not part of @@ -409,9 +422,15 @@ weakened by a check that happens to need a proxy. separate job because `-race` needs `CGO_ENABLED=1` and that must never leak into the shipped static binaries; and `lint`, with golangci-lint pinned and its download checksum-verified against the release's published `checksums.txt`. - Go is resolved as `1.25.x` + `check-latest` rather than `go-version-file: - go.mod`, so the binaries get the newest 1.25 patch stdlib instead of the exact - version go.mod pins — see the comment in the workflow before changing it. + Go is resolved as `1.27.x` + `check-latest` rather than `go-version-file: + go.mod`, so the binaries get the newest 1.27 patch stdlib instead of the exact + version go.mod pins — see the comment in the workflow before changing it. The + pin sits a minor above go.mod's floor on purpose: it has to match the gofmt + the pinned golangci-lint bundles (above). Two rules follow. When a dependency + raises the floor in go.mod, these pins move in the same commit if the floor + would overtake them — every offline job runs `GOTOOLCHAIN=local` and fails + outright otherwise. And when the golangci-lint pin moves to a build made with + a newer Go, these pins and `make fmt` move with it. - `.github/workflows/supply-chain.yml` — the **network-allowed** checks, on the same triggers plus a weekly cron and `workflow_dispatch`. Three jobs: `vendor-integrity` re-derives the vendor tree (`go mod download && go mod diff --git a/Makefile b/Makefile index 805b90a..b8117d0 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,10 @@ lint: golangci-lint run ./... ## fmt: format sources +# Needs gofmt from Go 1.27+. Go 1.27 changed the indentation of a multi-value +# return whose operands are composite literals, and the pinned golangci-lint +# bundles that gofmt, so an older one silently reverts the tree and the lint job +# then fails on files `make fmt` just "fixed". fmt: gofmt -w $(shell find . -name '*.go' -not -path './vendor/*') diff --git a/go.mod b/go.mod index b674782..49110a2 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/ftarasenko/go-openstackclient -go 1.25.0 +go 1.26.0 require ( github.com/gophercloud/gophercloud/v2 v2.14.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index b939cf4..1bb5886 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/internal/auth/provider_test.go b/internal/auth/provider_test.go index 1498038..9bf3fe9 100644 --- a/internal/auth/provider_test.go +++ b/internal/auth/provider_test.go @@ -302,15 +302,15 @@ func envOpenrc(t *testing.T) { // cloudsYAMLResult is what clouds.Parse would hand back for the named cloud. func cloudsYAMLResult() (gophercloud.AuthOptions, gophercloud.EndpointOpts) { return gophercloud.AuthOptions{ - IdentityEndpoint: "https://named-cloud.example:5000", - Username: "cloudsuser", - Password: "cloudssecret", - TenantName: "cloudsproject", - DomainName: "CloudsDom", - }, gophercloud.EndpointOpts{ - Region: "cloudsregion", - Availability: gophercloud.AvailabilityPublic, - } + IdentityEndpoint: "https://named-cloud.example:5000", + Username: "cloudsuser", + Password: "cloudssecret", + TenantName: "cloudsproject", + DomainName: "CloudsDom", + }, gophercloud.EndpointOpts{ + Region: "cloudsregion", + Availability: gophercloud.AvailabilityPublic, + } } // A named cloud must be used wholesale. Every auth flag defaults to its OS_* diff --git a/internal/cli/baremetal/allocation.go b/internal/cli/baremetal/allocation.go index 71825cc..6307e62 100644 --- a/internal/cli/baremetal/allocation.go +++ b/internal/cli/baremetal/allocation.go @@ -101,12 +101,12 @@ func extractAllocations(page pagination.Page) ([]allocation, error) { func allocationShowFields(al *allocation) ([]string, []any) { return []string{ - "uuid", "name", "state", "node_uuid", "resource_class", "traits", - "candidate_nodes", "last_error", "extra", "owner", "created_at", "updated_at", - }, []any{ - al.UUID, al.Name, al.State, al.NodeUUID, al.ResourceClass, al.Traits, - al.CandidateNodes, al.LastError, al.Extra, al.Owner, al.CreatedAt, al.UpdatedAt, - } + "uuid", "name", "state", "node_uuid", "resource_class", "traits", + "candidate_nodes", "last_error", "extra", "owner", "created_at", "updated_at", + }, []any{ + al.UUID, al.Name, al.State, al.NodeUUID, al.ResourceClass, al.Traits, + al.CandidateNodes, al.LastError, al.Extra, al.Owner, al.CreatedAt, al.UpdatedAt, + } } // --- list ------------------------------------------------------------------- diff --git a/internal/cli/network/qos.go b/internal/cli/network/qos.go index fef5b61..2641a46 100644 --- a/internal/cli/network/qos.go +++ b/internal/cli/network/qos.go @@ -48,12 +48,12 @@ func newQoSPolicyCommand(a *auth.Options, o *output.Options) *cobra.Command { func qosPolicyShowFields(p *policies.Policy) ([]string, []any) { return []string{ - "id", "name", "description", "shared", "is_default", "project_id", - "revision_number", "tags", "created_at", "updated_at", - }, []any{ - p.ID, p.Name, p.Description, p.Shared, p.IsDefault, p.ProjectID, - p.RevisionNumber, strings.Join(p.Tags, ", "), p.CreatedAt, p.UpdatedAt, - } + "id", "name", "description", "shared", "is_default", "project_id", + "revision_number", "tags", "created_at", "updated_at", + }, []any{ + p.ID, p.Name, p.Description, p.Shared, p.IsDefault, p.ProjectID, + p.RevisionNumber, strings.Join(p.Tags, ", "), p.CreatedAt, p.UpdatedAt, + } } func newQoSPolicyListCommand(a *auth.Options, o *output.Options) *cobra.Command { diff --git a/internal/cli/server/password_test.go b/internal/cli/server/password_test.go index 95cd925..37857c4 100644 --- a/internal/cli/server/password_test.go +++ b/internal/cli/server/password_test.go @@ -33,6 +33,10 @@ func newTestKeypair(t *testing.T, plaintext string) (*rsa.PrivateKey, string) { if err != nil { t.Fatalf("generating a test key: %v", err) } + // PKCS#1 v1.5 is deprecated as of Go 1.26, but it is the padding nova's + // get-password API stores and the one gophercloud's ExtractPassword + // undoes, so the fixture has to speak it. + //nolint:staticcheck // SA1019: nova's wire format, see above ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, &key.PublicKey, []byte(plaintext)) if err != nil { t.Fatalf("encrypting the test password: %v", err) diff --git a/vendor/golang.org/x/crypto/ssh/certs.go b/vendor/golang.org/x/crypto/ssh/certs.go index fa848f5..a3b802e 100644 --- a/vendor/golang.org/x/crypto/ssh/certs.go +++ b/vendor/golang.org/x/crypto/ssh/certs.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net" + "slices" "sort" "time" ) @@ -305,8 +306,11 @@ const sourceAddressCriticalOption = "source-address" // minimally, the IsAuthority callback should be set. type CertChecker struct { // SupportedCriticalOptions lists the CriticalOptions that the - // server application layer understands. These are only used - // for user certificates. + // application layer understands. A certificate carrying a critical + // option that is not listed here is rejected. + // CertChecker.Authenticate additionally accepts the source-address + // option, which the server enforces on the Permissions that + // Authenticate returns. SupportedCriticalOptions []string // IsUserAuthority should return true if the key is recognized as an @@ -369,8 +373,9 @@ func (c *CertChecker) CheckHostKey(addr string, remote net.Addr, key PublicKey) return c.CheckCert(hostname, cert) } -// Authenticate checks a user certificate. Authenticate can be used as -// a value for ServerConfig.PublicKeyCallback. +// Authenticate checks a user certificate. Authenticate can be used as a value +// for ServerConfig.PublicKeyCallback. The source-address critical option is +// allowed, as it will be enforced by the server. func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permissions, error) { cert, ok := pubKey.(*Certificate) if !ok { @@ -389,8 +394,11 @@ func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permis if !c.IsUserAuthority(cert.SignatureKey) { return nil, fmt.Errorf("ssh: certificate signed by unrecognized authority") } - - if err := c.CheckCert(conn.User(), cert); err != nil { + // The source-address critical option is enforced by serverAuthenticate, + // so it is supported regardless of SupportedCriticalOptions + cc := *c + cc.SupportedCriticalOptions = append(slices.Clip(cc.SupportedCriticalOptions), sourceAddressCriticalOption) + if err := cc.CheckCert(conn.User(), cert); err != nil { return nil, err } @@ -398,27 +406,15 @@ func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permis } // CheckCert checks CriticalOptions, ValidPrincipals, revocation, timestamp and -// the signature of the certificate. +// the signature of the certificate. Critical options that are not listed in +// SupportedCriticalOptions are rejected. func (c *CertChecker) CheckCert(principal string, cert *Certificate) error { if c.IsRevoked != nil && c.IsRevoked(cert) { return fmt.Errorf("ssh: certificate serial %d revoked", cert.Serial) } for opt := range cert.CriticalOptions { - // sourceAddressCriticalOption will be enforced by - // serverAuthenticate - if opt == sourceAddressCriticalOption { - continue - } - - found := false - for _, supp := range c.SupportedCriticalOptions { - if supp == opt { - found = true - break - } - } - if !found { + if !slices.Contains(c.SupportedCriticalOptions, opt) { return fmt.Errorf("ssh: unsupported critical option %q in certificate", opt) } } diff --git a/vendor/golang.org/x/crypto/ssh/channel.go b/vendor/golang.org/x/crypto/ssh/channel.go index ba3279e..d6010fd 100644 --- a/vendor/golang.org/x/crypto/ssh/channel.go +++ b/vendor/golang.org/x/crypto/ssh/channel.go @@ -173,6 +173,12 @@ type channel struct { // (for outbound channels) or received (for inbound channels). decided bool + // established is set to true once the channel is open and may carry normal + // channel traffic: for an outbound channel when the peer's open + // confirmation is received, for an inbound channel when the local side + // accepts it. It is set and read from different goroutines. + established atomic.Bool + // direction contains either channelOutbound, for channels created // locally, or channelInbound, for channels created by the peer. direction channelDirection @@ -434,10 +440,20 @@ func (ch *channel) responseMessageReceived() error { return errors.New("ssh: duplicate response received for channel") } ch.decided = true + ch.established.Store(true) return nil } func (ch *channel) handlePacket(packet []byte) error { + // Only the open response is expected before the channel is established. + if !ch.established.Load() { + switch packet[0] { + case msgChannelOpenConfirm, msgChannelOpenFailure: + default: + return nil + } + } + switch packet[0] { case msgChannelData, msgChannelExtendedData: return ch.handleData(packet) @@ -503,7 +519,8 @@ func (ch *channel) handlePacket(packet []byte) error { default: } default: - ch.msg <- msg + // No other message type is expected on an established channel. + return fmt.Errorf("ssh: unexpected message type %d on channel %d", packet[0], ch.localId) } return nil } @@ -554,6 +571,7 @@ func (ch *channel) Accept() (Channel, <-chan *Request, error) { MaxPacketSize: ch.maxIncomingPayload, } ch.decided = true + ch.established.Store(true) if err := ch.sendMessage(confirm); err != nil { return nil, nil, err } diff --git a/vendor/golang.org/x/crypto/ssh/transport.go b/vendor/golang.org/x/crypto/ssh/transport.go index fa3dd6a..540865d 100644 --- a/vendor/golang.org/x/crypto/ssh/transport.go +++ b/vendor/golang.org/x/crypto/ssh/transport.go @@ -331,13 +331,19 @@ func exchangeVersions(rw io.ReadWriter, versionLine []byte) (them []byte, err er // chars const maxVersionStringBytes = 255 +// maxPreVersionLines is the maximum number of lines sent by the peer +// before the version string. Each of these lines is limited to a maximum +// of maxVersionStringBytes chars. Lines sent before the version string +// are silently ignored. +const maxPreVersionLines = 1024 + // Read version string as specified by RFC 4253, section 4.2. func readVersion(r io.Reader) ([]byte, error) { versionString := make([]byte, 0, 64) var ok bool var buf [1]byte - for length := 0; length < maxVersionStringBytes; length++ { + for lines := 0; len(versionString) < maxVersionStringBytes && lines < maxPreVersionLines; { _, err := io.ReadFull(r, buf[:]) if err != nil { return nil, err @@ -347,9 +353,9 @@ func readVersion(r io.Reader) ([]byte, error) { if buf[0] == '\n' { if !bytes.HasPrefix(versionString, []byte("SSH-")) { // RFC 4253 says we need to ignore all version string lines - // except the one containing the SSH version (provided that - // all the lines do not exceed 255 bytes in total). + // except the one containing the SSH version. versionString = versionString[:0] + lines++ continue } ok = true diff --git a/vendor/modules.txt b/vendor/modules.txt index d4013db..47b1cf8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -116,8 +116,8 @@ github.com/spf13/cobra # github.com/spf13/pflag v1.0.10 ## explicit; go 1.12 github.com/spf13/pflag -# golang.org/x/crypto v0.55.0 -## explicit; go 1.25.0 +# golang.org/x/crypto v0.56.0 +## explicit; go 1.26.0 golang.org/x/crypto/blowfish golang.org/x/crypto/chacha20 golang.org/x/crypto/cryptobyte