// Package lldapgql 是唯一负责把状态写入 LLDAP 的包(GraphQL 写通道)。 // // 写走 GraphQL、读走 LDAP:LLDAP 改写的所有操作都落在这里,genqlient/手工客户端 // 只在包内,不给其他包暴露原始 GraphQL。 // // 为什么不用 genqlient 代码生成:genqlient v0.8.1 依赖的 golang.org/x/tools v0.24.0 // 无法用本项目容器内的 Go 1.26 编译(已知不兼容),因此回归到运行时手工客户端。 // 语义保持一致:UpdateUserInput 省略字段 = 不改变,绝不显式传 null 覆盖字段。 // 密码没有 mutation——LLDAP 自己管哈希,程序不碰。 package lldapgql import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" ) // AttributeType 对应 schema 里的 AttributeType 枚举。 type AttributeType string const ( AttributeString AttributeType = "STRING" AttributeInteger AttributeType = "INTEGER" AttributeJpegPhoto AttributeType = "JPEG_PHOTO" AttributeDateTime AttributeType = "DATE_TIME" ) // AttributeValueInput 对应 schema 里的输入,name + 值列表。 type AttributeValueInput struct { Name string `json:"name"` Value []string `json:"value"` } // CreateUserInput 对应 schema 的 CreateUserInput。 // 密码不在其中:LLDAP 不提供创建时设密码,首次登录走 Authelia 忘记密码。 type CreateUserInput struct { ID string `json:"id"` Email *string `json:"email,omitempty"` DisplayName *string `json:"displayName,omitempty"` FirstName *string `json:"firstName,omitempty"` LastName *string `json:"lastName,omitempty"` Attributes []AttributeValueInput `json:"attributes,omitempty"` } // UpdateUserInput 对应 schema 的 UpdateUserInput。 // 关键语义:省略的字段 = 不改变。所有字段都是指针,nil 即不发送。 type UpdateUserInput struct { ID string `json:"id"` Email *string `json:"email,omitempty"` DisplayName *string `json:"displayName,omitempty"` FirstName *string `json:"firstName,omitempty"` LastName *string `json:"lastName,omitempty"` RemoveAttributes []string `json:"removeAttributes,omitempty"` InsertAttributes []AttributeValueInput `json:"insertAttributes,omitempty"` } // Group 是一次查询返回的分组(displayName 即组 CN,用于映射 id)。 type Group struct { ID int `json:"id"` DisplayName string `json:"displayName"` } // User 是一次查询返回的用户。id 即 uid。 type User struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"displayName"` } // Client 是 LLDAP 的 GraphQL 写客户端。 type Client struct { baseURL string // 形如 http://host:17170,不含尾部斜杠 http *http.Client token string log *slog.Logger } // New 构造写客户端。baseURL 是 LLDAP WebUI/GraphQL 的对外地址。 func New(baseURL string, log *slog.Logger) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{Timeout: 30 * time.Second}, log: log, } } // Login 用管理员凭据换取访问令牌。每次物化开始时调用一次。 // // 后端是 `POST /auth/simple/login`,返回 `{"token":""}`;后续 GraphQL // 请求带 `Authorization: Bearer `。 func (c *Client) Login(ctx context.Context, username, password string) error { body, _ := json.Marshal(map[string]string{"username": username, "password": password}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/auth/simple/login", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("登录 LLDAP 失败: %w", err) } defer resp.Body.Close() var out struct { Token string `json:"token"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return fmt.Errorf("解析 LLDAP 登录响应失败 (HTTP %d): %w", resp.StatusCode, err) } if out.Token == "" { return fmt.Errorf("LLDAP 登录失败 (HTTP %d):未返回令牌,请检查管理员用户名/密码", resp.StatusCode) } c.token = out.Token return nil } // gql 发送一次 GraphQL 请求并解析 data 到 out;GraphQL 层错误会转成语义化错误。 func (c *Client) gql(ctx context.Context, query string, vars map[string]any, out any) error { payload := map[string]any{"query": query} if len(vars) > 0 { payload["variables"] = vars } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/graphql", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("GraphQL 请求失败: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return err } // GraphQL 服务端即使 HTTP 200 也可能在 errors 里给出失败,必须两者都查。 var envelope struct { Data json.RawMessage `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(raw, &envelope); err != nil { return fmt.Errorf("解析 GraphQL 响应失败 (HTTP %d): %w", resp.StatusCode, err) } if len(envelope.Errors) > 0 { msgs := make([]string, 0, len(envelope.Errors)) for _, e := range envelope.Errors { msgs = append(msgs, e.Message) } return fmt.Errorf("LLDAP GraphQL 错误: %s", strings.Join(msgs, "; ")) } if out == nil { return nil } if len(envelope.Data) == 0 { return fmt.Errorf("LLDAP GraphQL 未返回 data") } return json.Unmarshal(envelope.Data, out) } // strPtr 生成 *string。 func strPtr(s string) *string { return &s } // CreateUser 创建用户。仅在创建时一次性写入硬字段;密码交给 LLDAP/Authelia。 func (c *Client) CreateUser(ctx context.Context, in *CreateUserInput) error { vars := map[string]any{"user": in} var out struct { CreateUser struct { ID string `json:"id"` } `json:"createUser"` } if err := c.gql(ctx, `mutation($user:CreateUserInput!){createUser(user:$user){id}}`, vars, &out); err != nil { return err } return nil } // UpdateUser 更新用户硬字段。「省略即不改」由调用方通过 nil 指针控制。 func (c *Client) UpdateUser(ctx context.Context, in *UpdateUserInput) error { // 结构性护栏:id 必填,且绝不更新密码(schema 根本没有密码字段,双保险防止误传)。 if in.ID == "" { return fmt.Errorf("UpdateUser 缺少 id") } vars := map[string]any{"user": in} return c.gql(ctx, `mutation($user:UpdateUserInput!){updateUser(user:$user){ok}}`, vars, nil) } // DeleteUser 物理删除用户。级联清理其全部成员关系(LLDAP memberships 外键 CASCADE)。 func (c *Client) DeleteUser(ctx context.Context, userID string) error { vars := map[string]any{"userId": userID} return c.gql(ctx, `mutation($userId:String!){deleteUser(userId:$userId){ok}}`, vars, nil) } // CreateGroup 创建分组,name 即 LLDAP 的 displayName/CN。 func (c *Client) CreateGroup(ctx context.Context, name string) (int, error) { vars := map[string]any{"name": name} var out struct { CreateGroup struct { ID int `json:"id"` DisplayName string `json:"displayName"` } `json:"createGroup"` } if err := c.gql(ctx, `mutation($name:String!){createGroup(name:$name){id displayName}}`, vars, &out); err != nil { return 0, err } return out.CreateGroup.ID, nil } // DeleteGroup 删除分组,级联清理成员关系。 func (c *Client) DeleteGroup(ctx context.Context, groupID int) error { vars := map[string]any{"groupId": groupID} return c.gql(ctx, `mutation($groupId:Int!){deleteGroup(groupId:$groupId){ok}}`, vars, nil) } // AddUserToGroup 把用户加入分组。 func (c *Client) AddUserToGroup(ctx context.Context, userID string, groupID int) error { vars := map[string]any{"userId": userID, "groupId": groupID} return c.gql(ctx, `mutation($userId:String!,$groupId:Int!){addUserToGroup(userId:$userId,groupId:$groupId){ok}}`, vars, nil) } // RemoveUserFromGroup 把用户移出分组。 func (c *Client) RemoveUserFromGroup(ctx context.Context, userID string, groupID int) error { vars := map[string]any{"userId": userID, "groupId": groupID} return c.gql(ctx, `mutation($userId:String!,$groupId:Int!){removeUserFromGroup(userId:$userId,groupId:$groupId){ok}}`, vars, nil) } // Groups 拉全量分组,用于按 displayName(组 CN)解析 id。 func (c *Client) Groups(ctx context.Context) ([]Group, error) { var out struct { Groups []Group `json:"groups"` } if err := c.gql(ctx, `query{groups{id displayName}}`, nil, &out); err != nil { return nil, err } return out.Groups, nil } // Users 拉全量用户,用于按 uid 解析当前状态。读 LDAP 已足够时可不调用。 func (c *Client) Users(ctx context.Context) ([]User, error) { var out struct { Users []User `json:"users"` } if err := c.gql(ctx, `query{users{id email displayName}}`, nil, &out); err != nil { return nil, err } return out.Users, nil } // AddUserAttribute 向 LLDAP 声明一个用户自定义属性(幂等)。 // 默认不调用:Authelia 官方模板不依赖任何自定义属性,仅对接 ssh/pam 时才启用。 func (c *Client) AddUserAttribute(ctx context.Context, name string, typ AttributeType, isList, isVisible, isEditable bool) error { vars := map[string]any{ "name": name, "attributeType": string(typ), "isList": isList, "isVisible": isVisible, "isEditable": isEditable, } return c.gql(ctx, `mutation($name:String!,$attributeType:AttributeType!,$isList:Boolean!,$isVisible:Boolean!,$isEditable:Boolean!){addUserAttribute(name:$name,attributeType:$attributeType,isList:$isList,isVisible:$isVisible,isEditable:$isEditable){ok}}`, vars, nil) }