5301b47f16
Stored credential secrets are encrypted with a key derived from ORCHESTRAD_SECRET_KEY. When that value changes, the secrets are intact but unreadable, and the only symptom was an opaque "decryption failed" surfacing deep inside an unrelated operation: "preview failed: building LDAP client: failed to decrypt credential: decryption failed" Nothing pointed at the real cause, so the error is now self-diagnosing: - ErrDecryptionFailed states that the data was encrypted under a different ORCHESTRAD_SECRET_KEY (or is corrupted). GCM auth failure on a well-formed ciphertext is overwhelmingly a wrong-key case. - The three credential decrypt sites name the credential, so the operator knows which password to restore or re-enter. - New services.CheckSecretKey verifies every stored secret against the current key. It runs at startup (LogSecretKeyCheck) and in `doctor`, so a mismatched key is reported once, loudly, at the moment it is first used rather than during the next rule run. A correctly-sized but *different* key passed doctor's existing length check and still broke every bind. Not fatal: the server still starts, since an operator may be mid-migration or may intend to re-enter the secrets. Verified on the demo instance: starting with a wrong key logs "1 of 1 stored credential secret(s) CANNOT be decrypted ... [OrchestrAD]", and the rule preview error now names both the credential and the key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
605 lines
17 KiB
Go
605 lines
17 KiB
Go
// Package services provides business logic services
|
|
package services
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/types"
|
|
goldap "github.com/go-ldap/ldap/v3"
|
|
)
|
|
|
|
// ConnectionService handles AD connection operations
|
|
type ConnectionService struct {
|
|
connRepo *repository.ConnectionRepository
|
|
credRepo *repository.CredentialRepository
|
|
encryptor *crypto.Encryptor
|
|
logger *logging.Logger
|
|
|
|
// schemaCache memoises the per-(connection, objectType) applicable attribute
|
|
// name set so the class-hierarchy walk is not repeated on every keystroke.
|
|
schemaCache sync.Map // key "connID|objectType" -> *attrSetCache
|
|
}
|
|
|
|
type attrSetCache struct {
|
|
names []string
|
|
expires time.Time
|
|
}
|
|
|
|
// NewConnectionService creates a new ConnectionService
|
|
func NewConnectionService(db *sql.DB, encryptor *crypto.Encryptor, logger *logging.Logger) *ConnectionService {
|
|
return &ConnectionService{
|
|
connRepo: repository.NewConnectionRepository(db),
|
|
credRepo: repository.NewCredentialRepository(db),
|
|
encryptor: encryptor,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// TestResult represents the result of a connection test
|
|
type TestResult struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
TestDetails []TestDetail `json:"testDetails"`
|
|
TestedAt time.Time `json:"testedAt"`
|
|
}
|
|
|
|
// TestDetail represents a single test step result
|
|
type TestDetail struct {
|
|
Step string `json:"step"`
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Latency time.Duration `json:"latency"`
|
|
}
|
|
|
|
// TestConnection tests an AD connection
|
|
func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResult, error) {
|
|
result := &TestResult{
|
|
TestedAt: time.Now().UTC(),
|
|
}
|
|
|
|
// Build LDAP config
|
|
config, err := s.BuildLDAPConfig(conn)
|
|
if err != nil {
|
|
result.Message = fmt.Sprintf("Configuration error: %v", err)
|
|
return result, nil
|
|
}
|
|
|
|
client := ldap.NewClient(config)
|
|
|
|
// Test 1: TCP connectivity
|
|
start := time.Now()
|
|
err = client.Connect()
|
|
connectLatency := time.Since(start)
|
|
|
|
result.TestDetails = append(result.TestDetails, TestDetail{
|
|
Step: "TCP Connection",
|
|
Success: err == nil,
|
|
Message: errorMessage(err),
|
|
Latency: connectLatency,
|
|
})
|
|
|
|
if err != nil {
|
|
result.Message = "Connection failed"
|
|
return result, nil
|
|
}
|
|
defer client.Close()
|
|
|
|
// Test 2: Bind/authentication
|
|
start = time.Now()
|
|
err = client.Bind()
|
|
bindLatency := time.Since(start)
|
|
|
|
result.TestDetails = append(result.TestDetails, TestDetail{
|
|
Step: "LDAP Bind",
|
|
Success: err == nil,
|
|
Message: errorMessage(err),
|
|
Latency: bindLatency,
|
|
})
|
|
|
|
if err != nil {
|
|
result.Message = "Authentication failed"
|
|
return result, nil
|
|
}
|
|
|
|
// Test 3: Root DN accessibility
|
|
start = time.Now()
|
|
err = testRootDN(client, conn.RootDN)
|
|
rootLatency := time.Since(start)
|
|
|
|
result.TestDetails = append(result.TestDetails, TestDetail{
|
|
Step: "Root DN Access",
|
|
Success: err == nil,
|
|
Message: errorMessage(err),
|
|
Latency: rootLatency,
|
|
})
|
|
|
|
if err != nil {
|
|
result.Message = "Root DN not accessible"
|
|
return result, nil
|
|
}
|
|
|
|
result.Success = true
|
|
result.Message = "All tests passed"
|
|
return result, nil
|
|
}
|
|
|
|
// BuildClient returns a connected and bound LDAP client for the given connection.
|
|
// Caller is responsible for calling Close() on the returned client.
|
|
func (s *ConnectionService) BuildClient(conn *models.ADConnection) (*ldap.Client, error) {
|
|
config, err := s.BuildLDAPConfig(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := ldap.NewClient(config)
|
|
if err := client.Connect(); err != nil {
|
|
return nil, fmt.Errorf("connect failed: %w", err)
|
|
}
|
|
if err := client.Bind(); err != nil {
|
|
client.Close()
|
|
return nil, fmt.Errorf("bind failed: %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// BuildLDAPConfig constructs an LDAP config from a connection model and
|
|
// resolves any referenced credential.
|
|
func (s *ConnectionService) BuildLDAPConfig(conn *models.ADConnection) (*ldap.Config, error) {
|
|
hosts := strings.Split(conn.Hosts, ",")
|
|
for i := range hosts {
|
|
hosts[i] = strings.TrimSpace(hosts[i])
|
|
}
|
|
|
|
config := &ldap.Config{
|
|
Hosts: hosts,
|
|
Port: conn.Port,
|
|
UseTLS: conn.UseTLS,
|
|
UseStartTLS: conn.UseStartTLS,
|
|
AllowInvalidCerts: conn.AllowInvalidCerts,
|
|
RootDN: conn.RootDN,
|
|
Timeout: time.Duration(conn.TimeoutSeconds) * time.Second,
|
|
PageSize: conn.PageSize,
|
|
}
|
|
|
|
if conn.BindDN != nil {
|
|
config.BindDN = *conn.BindDN
|
|
}
|
|
|
|
// Get credential if referenced
|
|
if conn.CredentialID != nil {
|
|
cred, err := s.credRepo.GetByID(*conn.CredentialID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get credential: %w", err)
|
|
}
|
|
if cred == nil {
|
|
return nil, fmt.Errorf("credential not found")
|
|
}
|
|
if cred.Username != nil {
|
|
config.BindDN = *cred.Username
|
|
}
|
|
if cred.EncryptedSecret != nil {
|
|
password, err := s.encryptor.Decrypt(*cred.EncryptedSecret)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt credential %q: %w", cred.Name, err)
|
|
}
|
|
config.BindPassword = string(password)
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// QueryPreviewInput describes an ad-hoc LDAP search executed via an existing connection.
|
|
type QueryPreviewInput struct {
|
|
BaseDN string
|
|
Scope string
|
|
Filter string
|
|
Attributes []string
|
|
Limit int
|
|
}
|
|
|
|
// QueryPreviewEntry is a single search result returned by QueryPreview.
|
|
type QueryPreviewEntry struct {
|
|
DN string `json:"dn"`
|
|
Attributes map[string][]string `json:"attributes"`
|
|
}
|
|
|
|
// QueryPreviewResult holds the outcome of a query preview run.
|
|
type QueryPreviewResult struct {
|
|
Entries []QueryPreviewEntry `json:"entries"`
|
|
Truncated bool `json:"truncated"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// QueryPreview runs a user-supplied LDAP filter against an existing AD connection and
|
|
// returns up to input.Limit entries so callers can preview the result set.
|
|
func (s *ConnectionService) QueryPreview(conn *models.ADConnection, input QueryPreviewInput) (*QueryPreviewResult, error) {
|
|
if input.Filter == "" {
|
|
return nil, fmt.Errorf("filter is required")
|
|
}
|
|
|
|
baseDN := input.BaseDN
|
|
if baseDN == "" {
|
|
baseDN = conn.RootDN
|
|
}
|
|
|
|
scope := mapScope(input.Scope)
|
|
limit := input.Limit
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
attrs := input.Attributes
|
|
if len(attrs) == 0 {
|
|
attrs = []string{"cn", "distinguishedName", "objectClass", "sAMAccountName"}
|
|
}
|
|
|
|
client, err := s.BuildClient(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
entries, err := client.Search(baseDN, scope, input.Filter, attrs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &QueryPreviewResult{Entries: make([]QueryPreviewEntry, 0, len(entries))}
|
|
for _, entry := range entries {
|
|
if len(result.Entries) >= limit {
|
|
result.Truncated = true
|
|
break
|
|
}
|
|
preview := QueryPreviewEntry{
|
|
DN: entry.DN,
|
|
Attributes: map[string][]string{},
|
|
}
|
|
for _, attr := range entry.Attributes {
|
|
preview.Attributes[attr.Name] = ldap.FormatAttributeValues(attr)
|
|
}
|
|
result.Entries = append(result.Entries, preview)
|
|
}
|
|
result.Count = len(result.Entries)
|
|
return result, nil
|
|
}
|
|
|
|
// DirectoryObject is a lightweight directory entry returned by SearchDirectory
|
|
// for operator-facing pickers (target groups, base/target OUs).
|
|
type DirectoryObject struct {
|
|
DN string `json:"dn"`
|
|
Name string `json:"name"`
|
|
CanonicalName string `json:"canonicalName"`
|
|
ObjectType string `json:"objectType"`
|
|
}
|
|
|
|
// SearchDirectory finds groups or organizational units (and users/computers)
|
|
// matching an optional substring, for use by the rule editor's pickers.
|
|
// objectType is one of "Group", "OU", "User", "Computer".
|
|
func (s *ConnectionService) SearchDirectory(conn *models.ADConnection, objectType, q string, limit int) ([]DirectoryObject, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
var classFilter, nameAttr string
|
|
switch objectType {
|
|
case "OU", "OrganizationalUnit":
|
|
classFilter, nameAttr = "(objectClass=organizationalUnit)", "ou"
|
|
case "User":
|
|
classFilter, nameAttr = "(&(objectCategory=person)(objectClass=user))", "cn"
|
|
case "Computer":
|
|
classFilter, nameAttr = "(objectClass=computer)", "cn"
|
|
default: // Group
|
|
objectType, classFilter, nameAttr = "Group", "(objectClass=group)", "cn"
|
|
}
|
|
|
|
filter := classFilter
|
|
if q = strings.TrimSpace(q); q != "" {
|
|
esc := ldap.EscapeFilterValue(q)
|
|
if nameAttr == "ou" {
|
|
filter = fmt.Sprintf("(&%s(ou=*%s*))", classFilter, esc)
|
|
} else {
|
|
filter = fmt.Sprintf("(&%s(|(cn=*%s*)(sAMAccountName=*%s*)))", classFilter, esc, esc)
|
|
}
|
|
}
|
|
|
|
client, err := s.BuildClient(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
entries, err := client.Search(conn.RootDN, goldap.ScopeWholeSubtree, filter,
|
|
[]string{"cn", "ou", "distinguishedName", "canonicalName"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]DirectoryObject, 0, limit)
|
|
for _, entry := range entries {
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
name := entry.GetAttributeValue(nameAttr)
|
|
if name == "" {
|
|
name = entry.GetAttributeValue("cn")
|
|
}
|
|
out = append(out, DirectoryObject{
|
|
DN: entry.DN,
|
|
Name: name,
|
|
CanonicalName: ldap.CanonicalName(entry.DN, entry.GetAttributeValue("canonicalName")),
|
|
ObjectType: objectType,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AttributeInfo is a schema attribute offered to the filter builder.
|
|
type AttributeInfo struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// attrNameRe restricts attribute names to the LDAP descriptor charset, so a
|
|
// name can be placed into a filter without escaping / injection risk.
|
|
var attrNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`)
|
|
|
|
// classForObjectType maps an OrchestrAD object type to its AD classSchema
|
|
// lDAPDisplayName, the entry point for the attribute-applicability walk.
|
|
func classForObjectType(objectType string) string {
|
|
switch objectType {
|
|
case "Computer":
|
|
return "computer"
|
|
case "Group":
|
|
return "group"
|
|
default:
|
|
return "user"
|
|
}
|
|
}
|
|
|
|
// SchemaAttributes returns the schema attributes that apply to objectType
|
|
// (User/Computer/Group), optionally narrowed to those whose name contains q.
|
|
// The applicable-attribute set is derived from the class hierarchy and cached.
|
|
func (s *ConnectionService) SchemaAttributes(conn *models.ADConnection, objectType, q string, limit int) ([]AttributeInfo, error) {
|
|
q = strings.TrimSpace(q)
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 50
|
|
}
|
|
|
|
client, err := s.BuildClient(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
schemaNC, err := s.schemaNamingContext(client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
names, err := s.applicableAttributeNames(client, conn.ID, schemaNC, objectType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter by substring, cap.
|
|
ql := strings.ToLower(q)
|
|
matched := make([]string, 0, limit)
|
|
for _, n := range names {
|
|
if ql == "" || strings.Contains(strings.ToLower(n), ql) {
|
|
matched = append(matched, n)
|
|
if len(matched) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(matched) == 0 {
|
|
return []AttributeInfo{}, nil
|
|
}
|
|
|
|
// Fetch descriptions for the matched subset in a single OR-filter search.
|
|
descByName := s.attributeDescriptions(client, schemaNC, matched)
|
|
out := make([]AttributeInfo, 0, len(matched))
|
|
for _, n := range matched {
|
|
out = append(out, AttributeInfo{Name: n, Description: descByName[strings.ToLower(n)]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ConnectionService) schemaNamingContext(client *ldap.Client) (string, error) {
|
|
root, err := client.SearchOne("", goldap.ScopeBaseObject, "(objectClass=*)", []string{"schemaNamingContext"})
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading RootDSE: %w", err)
|
|
}
|
|
if root == nil {
|
|
return "", fmt.Errorf("RootDSE not available")
|
|
}
|
|
nc := root.GetAttributeValue("schemaNamingContext")
|
|
if nc == "" {
|
|
return "", fmt.Errorf("directory does not expose a schema naming context")
|
|
}
|
|
return nc, nil
|
|
}
|
|
|
|
// applicableAttributeNames returns the sorted set of attribute lDAPDisplayNames
|
|
// that may be set on objectType, by walking the classSchema hierarchy
|
|
// (subClassOf up to top, plus auxiliary classes) and unioning each class's
|
|
// may/must-contain attributes. Cached per connection+objectType for 10 minutes.
|
|
func (s *ConnectionService) applicableAttributeNames(client *ldap.Client, connID, schemaNC, objectType string) ([]string, error) {
|
|
cacheKey := connID + "|" + objectType
|
|
if v, ok := s.schemaCache.Load(cacheKey); ok {
|
|
if c := v.(*attrSetCache); time.Now().Before(c.expires) {
|
|
return c.names, nil
|
|
}
|
|
}
|
|
|
|
attrSet := map[string]bool{}
|
|
visited := map[string]bool{}
|
|
queue := []string{classForObjectType(objectType)}
|
|
for len(queue) > 0 {
|
|
cls := queue[0]
|
|
queue = queue[1:]
|
|
if cls == "" || visited[cls] {
|
|
continue
|
|
}
|
|
visited[cls] = true
|
|
|
|
entry, err := client.SearchOne(schemaNC, goldap.ScopeSingleLevel,
|
|
fmt.Sprintf("(&(objectClass=classSchema)(lDAPDisplayName=%s))", ldap.EscapeFilterValue(cls)),
|
|
[]string{"mayContain", "systemMayContain", "mustContain", "systemMustContain",
|
|
"subClassOf", "auxiliaryClass", "systemAuxiliaryClass"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if entry == nil {
|
|
continue
|
|
}
|
|
for _, a := range []string{"mayContain", "systemMayContain", "mustContain", "systemMustContain"} {
|
|
for _, v := range entry.GetAttributeValues(a) {
|
|
attrSet[v] = true
|
|
}
|
|
}
|
|
for _, a := range []string{"subClassOf", "auxiliaryClass", "systemAuxiliaryClass"} {
|
|
for _, v := range entry.GetAttributeValues(a) {
|
|
if v != "" && v != cls {
|
|
queue = append(queue, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
names := make([]string, 0, len(attrSet))
|
|
for n := range attrSet {
|
|
names = append(names, n)
|
|
}
|
|
sort.Slice(names, func(i, j int) bool { return strings.ToLower(names[i]) < strings.ToLower(names[j]) })
|
|
|
|
s.schemaCache.Store(cacheKey, &attrSetCache{names: names, expires: time.Now().Add(10 * time.Minute)})
|
|
return names, nil
|
|
}
|
|
|
|
// attributeDescriptions fetches adminDescription for a set of attribute names
|
|
// in one search, returning a lowercase-name-keyed map.
|
|
func (s *ConnectionService) attributeDescriptions(client *ldap.Client, schemaNC string, names []string) map[string]string {
|
|
if len(names) == 0 {
|
|
return map[string]string{}
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("(&(objectClass=attributeSchema)(|")
|
|
for _, n := range names {
|
|
b.WriteString("(lDAPDisplayName=")
|
|
b.WriteString(ldap.EscapeFilterValue(n))
|
|
b.WriteString(")")
|
|
}
|
|
b.WriteString("))")
|
|
|
|
out := map[string]string{}
|
|
entries, err := client.SearchWithLimit(schemaNC, goldap.ScopeSingleLevel, b.String(),
|
|
[]string{"lDAPDisplayName", "adminDescription"}, len(names))
|
|
if err != nil {
|
|
return out // descriptions are best-effort
|
|
}
|
|
for _, e := range entries {
|
|
name := e.GetAttributeValue("lDAPDisplayName")
|
|
if name != "" {
|
|
out[strings.ToLower(name)] = e.GetAttributeValue("adminDescription")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DistinctAttributeValues samples objects of objectType and returns the
|
|
// distinct values present for a single attribute, optionally narrowed by a
|
|
// substring q and a base DN. Bounded by a scan cap so it stays cheap on large
|
|
// directories.
|
|
func (s *ConnectionService) DistinctAttributeValues(conn *models.ADConnection, objectType, attribute, q, baseDN string, limit int) ([]string, error) {
|
|
attribute = strings.TrimSpace(attribute)
|
|
if !attrNameRe.MatchString(attribute) {
|
|
return nil, fmt.Errorf("invalid attribute name")
|
|
}
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
if baseDN == "" {
|
|
baseDN = conn.RootDN
|
|
}
|
|
|
|
filter := fmt.Sprintf("(&%s(%s=*))", ldap.ObjectFilter(types.ObjectType(objectType)), attribute)
|
|
if q = strings.TrimSpace(q); q != "" {
|
|
filter = fmt.Sprintf("(&%s(%s=*%s*))", ldap.ObjectFilter(types.ObjectType(objectType)), attribute, ldap.EscapeFilterValue(q))
|
|
}
|
|
|
|
client, err := s.BuildClient(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
// Scan up to ~2000 objects; that is plenty to surface the common values
|
|
// without walking an entire large directory.
|
|
entries, err := client.SearchWithLimit(baseDN, goldap.ScopeWholeSubtree, filter, []string{attribute}, 2000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
out := make([]string, 0, limit)
|
|
for _, e := range entries {
|
|
for _, v := range e.GetAttributeValues(attribute) {
|
|
key := strings.ToLower(v)
|
|
if v == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, v)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
|
|
return out, nil
|
|
}
|
|
|
|
func mapScope(scope string) int {
|
|
switch scope {
|
|
case "base", "Base", "BaseObject":
|
|
return goldap.ScopeBaseObject
|
|
case "one", "One", "OneLevel", "SingleLevel":
|
|
return goldap.ScopeSingleLevel
|
|
default:
|
|
return goldap.ScopeWholeSubtree
|
|
}
|
|
}
|
|
|
|
func testRootDN(client *ldap.Client, rootDN string) error {
|
|
exists, err := client.Exists(rootDN)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("root DN does not exist")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func errorMessage(err error) string {
|
|
if err == nil {
|
|
return "OK"
|
|
}
|
|
return err.Error()
|
|
}
|