Skip to content

Commit

Permalink
Implement HTTP connection in the public library
Browse files Browse the repository at this point in the history
Introduce several interfaces which allow a caller to perform API
requests against an SCC-like API. This includes:

* Common options for the connection
* Credentials handling
* Building and performing requests
* Automatically handle system token rotations

Reviewed-by: Parag Jain <[email protected]>
Signed-off-by: Felix Schnizlein <[email protected]>
Signed-off-by: Miquel Sabaté Solà <[email protected]>
  • Loading branch information
mssola committed Jan 9, 2025
1 parent 6c448d8 commit fd80c1c
Show file tree
Hide file tree
Showing 12 changed files with 466 additions and 80 deletions.
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ out:
mkdir -p out

internal/connect/version.txt:
@echo "$(VERSION)" > internal/connect/version.txt
@echo -n "$(VERSION)" > internal/connect/version.txt

build: clean out internal/connect/version.txt
$(GO) build $(GOFLAGS) $(BINFLAGS) $(OUT) github.com/SUSE/connect-ng/cmd/suseconnect
$(GO) build $(GOFLAGS) $(BINFLAGS) $(OUT) github.com/SUSE/connect-ng/cmd/zypper-migration
$(GO) build $(GOFLAGS) $(BINFLAGS) $(OUT) github.com/SUSE/connect-ng/cmd/zypper-search-packages
$(GO) build $(GOFLAGS) $(BINFLAGS) $(OUT) github.com/SUSE/connect-ng/cmd/suse-uptime-tracker
$(GO) build $(GOFLAGS) $(BINFLAGS) $(OUT) github.com/SUSE/connect-ng/cmd/public-api-test
$(GO) build $(GOFLAGS) $(SOFLAGS) $(OUT) github.com/SUSE/connect-ng/third_party/libsuseconnect

# This "arm" means ARM64v8 little endian, the one being delivered currently on
Expand All @@ -68,7 +69,7 @@ build-ppc64le: clean out internal/connect/version.txt
GOOS=linux GOARCH=ppc64le $(GO) build $(GOFLAGS) $(OUT) github.com/SUSE/connect-ng/cmd/suseconnect

test: internal/connect/version.txt
$(GO) test ./internal/* ./cmd/suseconnect
$(GO) test ./internal/* ./cmd/suseconnect ./pkg/*

ci-env:
$(CRM) $(MOUNT) --env-file $(ENVFILE) -w $(WORKDIR) $(CONTAINER) bash
Expand Down
39 changes: 39 additions & 0 deletions cmd/public-api-test/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

import "fmt"

type SccCredentials struct {
SystemLogin string
Password string
SystemToken string
}

func (SccCredentials) HasAuthentication() bool {
return true
}

func (creds *SccCredentials) Token() (string, error) {
fmt.Printf("<- fetch token %s\n", creds.SystemToken)
return creds.SystemToken, nil
}

func (creds *SccCredentials) UpdateToken(token string) error {
fmt.Printf("-> update token %s\n", token)
creds.SystemToken = token
return nil
}

func (creds *SccCredentials) Login() (string, string, error) {
if creds.SystemLogin == "" || creds.Password == "" {
return "", "", fmt.Errorf("login credentials not set")
}
fmt.Printf("<- fetch login %s\n", creds.SystemLogin)
return creds.SystemLogin, creds.Password, nil
}

func (creds *SccCredentials) SetLogin(login, password string) error {
fmt.Printf("-> set login %s\n", login)
creds.SystemLogin = login
creds.Password = password
return nil
}
74 changes: 41 additions & 33 deletions cmd/public-api-test/main.go
Original file line number Diff line number Diff line change
@@ -1,57 +1,65 @@
package main

import (
"bytes"
"fmt"
"os"

"github.com/SUSE/connect-ng/pkg/connection"
"github.com/SUSE/connect-ng/pkg/registration"
"github.com/SUSE/connect-ng/pkg/validation"
)

type SccCredentials struct {
Login string `json:"login"`
Password string `json:"password"`
SystemToken string `json:"system_token"`
}
const (
hostname = "public-api-demo"
)

func (SccCredentials) HasAuthentication() bool {
return true
func bold(format string, args ...interface{}) {
fmt.Printf("\033[1m"+format+"\033[0m", args...)
}

func (creds *SccCredentials) Triplet() (string, string, string, error) {
return creds.Login, creds.Password, creds.SystemToken, nil
}
func runDemo(regcode string) error {
opts := connection.DefaultOptions("public-api-demo", "1.0", "DE")

func (creds *SccCredentials) Load() error {
creds = SccCredentials{
Login: "foo",
Password: "bar",
SystemToken: "",
if url := os.Getenv("SCC_URL"); url != "" {
opts.URL = url
}
return nil
}

func (creds *SccCredentials) Update(login, password, token string) error {
creds = SccCredentials{
Login: login,
Password: password,
SystemToken: token,
bold("1) Setup connection and perform an request\n")
conn := connection.New(opts, &SccCredentials{})

request, buildErr := conn.BuildRequest("GET", "/connect/subscriptions/info", nil)
if buildErr != nil {
return buildErr
}

connection.AddRegcodeAuth(request, regcode)

payload, err := conn.Do(request)
if err != nil {
return err
}
fmt.Printf("!! len(payload): %d characters\n", len(payload))
fmt.Printf("!! first 40 characters: %s\n", string(payload[0:40]))

return nil
}

func main() {
fmt.Println("I'm here")
fmt.Println("public-api-demo: A connect client library demo")

opts := connection.SCCOptions()
if len(os.Args) != 4 {
fmt.Println("./public-api-demo IDENTIFIER VERSION ARCH")
return
}

// No authentication
//_ = connection.New(opts, connection.NoCredentials{})
regcode := os.Getenv("REGCODE")
if regcode == "" {
fmt.Printf("ERROR: Requireing REGCODE to set as environment variable\n")
os.Exit(1)
}

// With authentication
conn := connection.New(opts, &SccCredentials{})
err := runDemo(regcode)

_, _ = registration.Status(conn)
_, _, _ = validation.OfflineActivation(bytes.NewReader([]byte{}))
if err != nil {
fmt.Printf("ERROR: %s\n", err)
os.Exit(1)
}
}
16 changes: 16 additions & 0 deletions pkg/connection/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package connection

import (
"fmt"
"net/http"
)

func AddRegcodeAuth(request *http.Request, regcode string) {
tokenAuth := fmt.Sprintf("Token token=%s", regcode)

request.Header.Set("Authorization", tokenAuth)
}

func AddSystemAuth(request *http.Request, login string, password string) {
request.SetBasicAuth(login, password)
}
43 changes: 43 additions & 0 deletions pkg/connection/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package connection

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
)

func testRequest(t *testing.T) *http.Request {
assert := assert.New(t)

opts := DefaultOptions("testApp", "1.0", "en_US")
creds := NoCredentials{}
conn := New(opts, creds)

request, buildErr := conn.BuildRequest("GET", "/test/api", nil)
assert.NoError(buildErr)

return request
}

func TestAuthByRegcode(t *testing.T) {
assert := assert.New(t)
request := testRequest(t)

regcode := "test"
expected := "Token token=test"

AddRegcodeAuth(request, regcode)
assert.Equal(expected, request.Header.Get("Authorization"))
}

func TestAuthBySystemCredentials(t *testing.T) {
assert := assert.New(t)
request := testRequest(t)

login := "login"
password := "password"

AddSystemAuth(request, login, password)
assert.Equal("Basic bG9naW46cGFzc3dvcmQ=", request.Header.Get("Authorization"))
}
100 changes: 77 additions & 23 deletions pkg/connection/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ package connection
import (
"bytes"
"crypto/tls"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const (
DefaultAPIVersion = "application/json,application/vnd.scc.suse.com.v4+json"
)

// Connection is to be implemented by any struct that attempts to perform
// requests against a remote resource that implements the /connect API.
type Connection interface {
// Returns an HTTP request object that can be used by a subsequent `Do`
// call.
GetRequest(string, string, any) (*http.Request, error)
BuildRequest(verb string, path string, body any) (*http.Request, error)

// Performs an HTTP request to the remote API. Returns the response body or
// an error object.
Expand All @@ -38,46 +43,95 @@ func New(opts Options, creds Credentials) *ApiConnection {
return &ApiConnection{Options: opts, Credentials: creds}
}

func (conn ApiConnection) GetRequest(verb string, path string, body any) (*http.Request, error) {
b, err := json.Marshal(body)
func (conn ApiConnection) BuildRequest(verb string, path string, body any) (*http.Request, error) {
bodyData, err := json.Marshal(body)
if err != nil {
return nil, err
}

req, err := http.NewRequest(verb, conn.Options.Url, bytes.NewReader(b))
request, err := http.NewRequest(verb, conn.Options.URL, bytes.NewReader(bodyData))
if err != nil {
return nil, err
}
req.URL.Path = path
request.URL.Path = path

return req, nil
conn.setupGenericHeaders(request)

return request, nil
}

func (conn ApiConnection) Do(req *http.Request) ([]byte, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
// TODO: note that we need to get the RootCAs thingie from the old connection.
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: conn.Options.Secure}
tr.Proxy = conn.Options.Proxy
httpclient := &http.Client{Transport: tr, Timeout: 60 * time.Second}
func (conn ApiConnection) Do(request *http.Request) ([]byte, error) {
token, tokenErr := conn.Credentials.Token()
if tokenErr != nil {
return []byte{}, tokenErr
}
request.Header.Set("System-Token", token)

// TODO: add headers (except auth)
response, doErr := conn.setupHTTPClient().Do(request)
if doErr != nil {
return nil, doErr
}
defer response.Body.Close()

resp, err := httpclient.Do(req)
if err != nil {
// Update the credentials from the new system token.
token = response.Header.Get("System-Token")
if err := conn.Credentials.UpdateToken(token); err != nil {
return nil, err
}
defer resp.Body.Close()

// TODO: handle system token
// TODO: handle success/bad code
if !successCode(response.StatusCode) {
msg := parseError(response.Body)
return nil, fmt.Errorf("API error: %v (code: %v)", msg, response.StatusCode)
}

resBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
data, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
return resBody, nil
return data, nil
}

func (conn ApiConnection) GetCredentials() Credentials {
return conn.Credentials
}

func (conn ApiConnection) setupGenericHeaders(request *http.Request) {
userAgent := fmt.Sprintf("%s/%s", conn.Options.AppName, conn.Options.Version)

request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", DefaultAPIVersion)
request.Header.Set("User-Agent", userAgent)

if conn.Options.PreferedLanguage != "" {
request.Header.Set("Accept-Language", conn.Options.PreferedLanguage)
}
}

func (conn ApiConnection) setupHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: conn.Options.Secure}

if conn.Options.Proxy != nil {
transport.Proxy = conn.Options.Proxy
}

return &http.Client{Transport: transport, Timeout: conn.Options.Timeout}
}

func successCode(code int) bool {
return code >= 200 && code < 300
}

func parseError(body io.Reader) string {
var errResp struct {
Error string `json:"error"`
LocalizedError string `json:"localized_error"`
}
if err := json.NewDecoder(body).Decode(&errResp); err != nil {
return ""
}
if errResp.LocalizedError != "" {
return errResp.LocalizedError
}
return errResp.Error
}
Loading

0 comments on commit fd80c1c

Please sign in to comment.