mirror of
https://github.com/bpg/terraform-provider-proxmox.git
synced 2025-07-02 03:22:59 +00:00
feat: SSH-Agent Support (#306)
* chore: add agent configuration bool * feat: add ssh-agent authentication mechanism for linux * chore: make sure ssh-agent auth is only executed on linux * chore: add ssh user override * chore: add ssh configuration block, check ssh config during VirtualEnvironmentClient creation * fix: handle case of empty ssh config block * chore: add ssh password auth fallback logic * fix: remove not needed runtime * fix linter errors & re-format * allow ssh agent on all POSIX systems * add `agent_socket` parameter * update docs and examples --------- Co-authored-by: zoop <zoop@zoop.li> Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
parent
37494a01b6
commit
9fa92423b5
@ -67,6 +67,35 @@ export PROXMOX_VE_PASSWORD="a-strong-password"
|
|||||||
terraform plan
|
terraform plan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SSH connection
|
||||||
|
|
||||||
|
The Proxmox provider can connect to a Proxmox node via SSH. This is used in
|
||||||
|
the `proxmox_virtual_environment_vm` or `proxmox_virtual_environment_file`
|
||||||
|
resource to execute commands on the node to perform actions that are not
|
||||||
|
supported by Proxmox API. For example, to import VM disks, or to uploading
|
||||||
|
certain type of resources, such as snippets.
|
||||||
|
|
||||||
|
The SSH connection configuration is provided via the optional `ssh` block in
|
||||||
|
the `provider` block:
|
||||||
|
|
||||||
|
```terraform
|
||||||
|
provider "proxmox" {
|
||||||
|
endpoint = "https://10.0.0.2:8006/"
|
||||||
|
username = "username@realm"
|
||||||
|
password = "a-strong-password"
|
||||||
|
insecure = true
|
||||||
|
ssh {
|
||||||
|
agent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no `ssh` block is provided, the provider will attempt to connect to the
|
||||||
|
target node using the credentials provided in the `username` and `password` fields.
|
||||||
|
Note that the target node is identified by the `node` argument in the resource,
|
||||||
|
and may be different from the Proxmox API endpoint. Please refer to the
|
||||||
|
section below for all the available arguments in the `ssh` block.
|
||||||
|
|
||||||
## Argument Reference
|
## Argument Reference
|
||||||
|
|
||||||
In addition
|
In addition
|
||||||
@ -87,3 +116,18 @@ Proxmox `provider` block:
|
|||||||
- `username` - (Required) The username and realm for the Proxmox Virtual
|
- `username` - (Required) The username and realm for the Proxmox Virtual
|
||||||
Environment API (can also be sourced from `PROXMOX_VE_USERNAME`). For
|
Environment API (can also be sourced from `PROXMOX_VE_USERNAME`). For
|
||||||
example, `root@pam`.
|
example, `root@pam`.
|
||||||
|
- `ssh` - (Optional) The SSH connection configuration to a Proxmox node. This is
|
||||||
|
a
|
||||||
|
block, whose fields are documented below.
|
||||||
|
- `username` - (Optional) The username to use for the SSH connection.
|
||||||
|
Defaults to the username used for the Proxmox API connection. Can also be
|
||||||
|
sourced from `PROXMOX_VE_SSH_USERNAME`.
|
||||||
|
- `password` - (Optional) The password to use for the SSH connection.
|
||||||
|
Defaults to the password used for the Proxmox API connection. Can also be
|
||||||
|
sourced from `PROXMOX_VE_SSH_PASSWORD`.
|
||||||
|
- `agent` - (Optional) Whether to use the SSH agent for the SSH
|
||||||
|
authentication. Defaults to `false`. Can also be sourced
|
||||||
|
from `PROXMOX_VE_SSH_AGENT`.
|
||||||
|
- `agent_socket` - (Optional) The path to the SSH agent socket.
|
||||||
|
Defaults to the value of the `SSH_AUTH_SOCK` environment variable. Can
|
||||||
|
also be sourced from `PROXMOX_VE_SSH_AUTH_SOCK`.
|
||||||
|
@ -3,4 +3,7 @@ provider "proxmox" {
|
|||||||
username = var.virtual_environment_username
|
username = var.virtual_environment_username
|
||||||
password = var.virtual_environment_password
|
password = var.virtual_environment_password
|
||||||
insecure = true
|
insecure = true
|
||||||
|
ssh {
|
||||||
|
agent = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
package proxmox
|
package proxmox
|
||||||
|
|
||||||
@ -14,6 +16,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/go-querystring/query"
|
"github.com/google/go-querystring/query"
|
||||||
@ -24,7 +27,7 @@ import (
|
|||||||
// NewVirtualEnvironmentClient creates and initializes a VirtualEnvironmentClient instance.
|
// NewVirtualEnvironmentClient creates and initializes a VirtualEnvironmentClient instance.
|
||||||
func NewVirtualEnvironmentClient(
|
func NewVirtualEnvironmentClient(
|
||||||
endpoint, username, password, otp string,
|
endpoint, username, password, otp string,
|
||||||
insecure bool,
|
insecure bool, sshUsername string, sshPassword string, sshAgent bool, sshAgentSocket string,
|
||||||
) (*VirtualEnvironmentClient, error) {
|
) (*VirtualEnvironmentClient, error) {
|
||||||
u, err := url.ParseRequestURI(endpoint)
|
u, err := url.ParseRequestURI(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -51,6 +54,12 @@ func NewVirtualEnvironmentClient(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(username, "@") {
|
||||||
|
return nil, errors.New(
|
||||||
|
"make sure the username for the Proxmox Virtual Environment API ends in '@pve or @pam'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var pOTP *string
|
var pOTP *string
|
||||||
|
|
||||||
if otp != "" {
|
if otp != "" {
|
||||||
@ -68,12 +77,31 @@ func NewVirtualEnvironmentClient(
|
|||||||
|
|
||||||
httpClient := &http.Client{Transport: transport}
|
httpClient := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
if sshUsername == "" {
|
||||||
|
sshUsername = strings.Split(username, "@")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if sshPassword == "" {
|
||||||
|
sshPassword = password
|
||||||
|
}
|
||||||
|
|
||||||
|
if sshAgent && runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "freebsd" {
|
||||||
|
return nil, errors.New(
|
||||||
|
"the ssh agent flag is only supported on POSIX systems, please set it to 'false'" +
|
||||||
|
" or remove it from your provider configuration",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return &VirtualEnvironmentClient{
|
return &VirtualEnvironmentClient{
|
||||||
Endpoint: strings.TrimRight(u.String(), "/"),
|
Endpoint: strings.TrimRight(u.String(), "/"),
|
||||||
Insecure: insecure,
|
Insecure: insecure,
|
||||||
OTP: pOTP,
|
OTP: pOTP,
|
||||||
Password: password,
|
Password: password,
|
||||||
Username: username,
|
Username: username,
|
||||||
|
SSHUsername: sshUsername,
|
||||||
|
SSHPassword: sshPassword,
|
||||||
|
SSHAgent: sshAgent,
|
||||||
|
SSHAgentSocket: sshAgentSocket,
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
package proxmox
|
package proxmox
|
||||||
|
|
||||||
@ -24,6 +26,10 @@ type VirtualEnvironmentClient struct {
|
|||||||
OTP *string
|
OTP *string
|
||||||
Password string
|
Password string
|
||||||
Username string
|
Username string
|
||||||
|
SSHUsername string
|
||||||
|
SSHPassword string
|
||||||
|
SSHAgent bool
|
||||||
|
SSHAgentSocket string
|
||||||
|
|
||||||
authenticationData *VirtualEnvironmentAuthenticationResponseData
|
authenticationData *VirtualEnvironmentAuthenticationResponseData
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
package proxmox
|
package proxmox
|
||||||
|
|
||||||
@ -21,6 +23,7 @@ import (
|
|||||||
"github.com/skeema/knownhosts"
|
"github.com/skeema/knownhosts"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExecuteNodeCommands executes commands on a given node.
|
// ExecuteNodeCommands executes commands on a given node.
|
||||||
@ -193,8 +196,6 @@ func (c *VirtualEnvironmentClient) OpenNodeShell(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ur := strings.Split(c.Username, "@")
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to determine the home directory: %w", err)
|
return nil, fmt.Errorf("failed to determine the home directory: %w", err)
|
||||||
@ -246,8 +247,61 @@ func (c *VirtualEnvironmentClient) OpenNodeShell(
|
|||||||
})
|
})
|
||||||
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
sshConfig := &ssh.ClientConfig{
|
||||||
User: ur[0],
|
User: c.SSHUsername,
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.Password)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
|
HostKeyCallback: cb,
|
||||||
|
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
|
||||||
|
}
|
||||||
|
|
||||||
|
tflog.Info(ctx, fmt.Sprintf("Agent is set to %t", c.SSHAgent))
|
||||||
|
|
||||||
|
if c.SSHAgent {
|
||||||
|
sshClient, err := c.CreateSSHClientAgent(ctx, cb, kh, sshHost)
|
||||||
|
if err != nil {
|
||||||
|
tflog.Error(ctx, "Failed ssh connection through agent, "+
|
||||||
|
"falling back to password authentication",
|
||||||
|
map[string]interface{}{
|
||||||
|
"error": err,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return sshClient, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sshClient, err := ssh.Dial("tcp", sshHost, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to dial %s: %w", sshHost, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tflog.Debug(ctx, "SSH connection established", map[string]interface{}{
|
||||||
|
"host": sshHost,
|
||||||
|
"user": c.SSHUsername,
|
||||||
|
})
|
||||||
|
return sshClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSSHClientAgent establishes an ssh connection through the agent authentication mechanism
|
||||||
|
func (c *VirtualEnvironmentClient) CreateSSHClientAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
cb ssh.HostKeyCallback,
|
||||||
|
kh knownhosts.HostKeyCallback,
|
||||||
|
sshHost string,
|
||||||
|
) (*ssh.Client, error) {
|
||||||
|
if c.SSHAgentSocket == "" {
|
||||||
|
return nil, errors.New("failed connecting to SSH agent socket: the socket file is not defined, " +
|
||||||
|
"authentication will fall back to password")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.Dial("unix", c.SSHAgentSocket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed connecting to SSH auth socket '%s': %w", c.SSHAgentSocket, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ag := agent.NewClient(conn)
|
||||||
|
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: c.SSHUsername,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(ag.Signers), ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: cb,
|
HostKeyCallback: cb,
|
||||||
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
|
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
|
||||||
}
|
}
|
||||||
@ -259,7 +313,7 @@ func (c *VirtualEnvironmentClient) OpenNodeShell(
|
|||||||
|
|
||||||
tflog.Debug(ctx, "SSH connection established", map[string]interface{}{
|
tflog.Debug(ctx, "SSH connection established", map[string]interface{}{
|
||||||
"host": sshHost,
|
"host": sshHost,
|
||||||
"user": ur[0],
|
"user": c.SSHUsername,
|
||||||
})
|
})
|
||||||
return sshClient, nil
|
return sshClient, nil
|
||||||
}
|
}
|
||||||
|
@ -18,13 +18,17 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
dvProviderOTP = ""
|
dvProviderOTP = ""
|
||||||
|
|
||||||
mkProviderVirtualEnvironment = "virtual_environment"
|
mkProviderVirtualEnvironment = "virtual_environment"
|
||||||
mkProviderEndpoint = "endpoint"
|
mkProviderEndpoint = "endpoint"
|
||||||
mkProviderInsecure = "insecure"
|
mkProviderInsecure = "insecure"
|
||||||
mkProviderOTP = "otp"
|
mkProviderOTP = "otp"
|
||||||
mkProviderPassword = "password"
|
mkProviderPassword = "password"
|
||||||
mkProviderUsername = "username"
|
mkProviderUsername = "username"
|
||||||
|
mkProviderSSH = "ssh"
|
||||||
|
mkProviderSSHUsername = "username"
|
||||||
|
mkProviderSSHPassword = "password"
|
||||||
|
mkProviderSSHAgent = "agent"
|
||||||
|
mkProviderSSHAgentSocket = "agent_socket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProxmoxVirtualEnvironment returns the object for this provider.
|
// ProxmoxVirtualEnvironment returns the object for this provider.
|
||||||
@ -41,26 +45,46 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
|
|||||||
var err error
|
var err error
|
||||||
var veClient *proxmox.VirtualEnvironmentClient
|
var veClient *proxmox.VirtualEnvironmentClient
|
||||||
|
|
||||||
// Initialize the client for the Virtual Environment, if required.
|
// Legacy configuration, wrapped in the deprecated `virtual_environment` block
|
||||||
veConfigBlock := d.Get(mkProviderVirtualEnvironment).([]interface{})
|
veConfigBlock := d.Get(mkProviderVirtualEnvironment).([]interface{})
|
||||||
|
|
||||||
if len(veConfigBlock) > 0 {
|
if len(veConfigBlock) > 0 {
|
||||||
veConfig := veConfigBlock[0].(map[string]interface{})
|
veConfig := veConfigBlock[0].(map[string]interface{})
|
||||||
|
veSSHConfig := veConfig[mkProviderSSH].(map[string]interface{})
|
||||||
|
|
||||||
veClient, err = proxmox.NewVirtualEnvironmentClient(
|
veClient, err = proxmox.NewVirtualEnvironmentClient(
|
||||||
veConfig[mkProviderEndpoint].(string),
|
veConfig[mkProviderEndpoint].(string),
|
||||||
veConfig[mkProviderUsername].(string),
|
veConfig[mkProviderUsername].(string),
|
||||||
|
veConfig[mkProviderSSH].(map[string]interface{})[mkProviderSSHUsername].(string),
|
||||||
veConfig[mkProviderPassword].(string),
|
veConfig[mkProviderPassword].(string),
|
||||||
veConfig[mkProviderOTP].(string),
|
|
||||||
veConfig[mkProviderInsecure].(bool),
|
veConfig[mkProviderInsecure].(bool),
|
||||||
|
veSSHConfig[mkProviderSSHUsername].(string),
|
||||||
|
veSSHConfig[mkProviderSSHPassword].(string),
|
||||||
|
veSSHConfig[mkProviderSSHAgent].(bool),
|
||||||
|
veSSHConfig[mkProviderSSHAgentSocket].(string),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
sshconf := map[string]interface{}{
|
||||||
|
mkProviderSSHUsername: "",
|
||||||
|
mkProviderSSHPassword: "",
|
||||||
|
mkProviderSSHAgent: false,
|
||||||
|
mkProviderSSHAgentSocket: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
sshBlock, sshSet := d.GetOk(mkProviderSSH)
|
||||||
|
if sshSet {
|
||||||
|
sshconf = sshBlock.(*schema.Set).List()[0].(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
veClient, err = proxmox.NewVirtualEnvironmentClient(
|
veClient, err = proxmox.NewVirtualEnvironmentClient(
|
||||||
d.Get(mkProviderEndpoint).(string),
|
d.Get(mkProviderEndpoint).(string),
|
||||||
d.Get(mkProviderUsername).(string),
|
d.Get(mkProviderUsername).(string),
|
||||||
d.Get(mkProviderPassword).(string),
|
d.Get(mkProviderPassword).(string),
|
||||||
d.Get(mkProviderOTP).(string),
|
d.Get(mkProviderOTP).(string),
|
||||||
d.Get(mkProviderInsecure).(bool),
|
d.Get(mkProviderInsecure).(bool),
|
||||||
|
sshconf[mkProviderSSHUsername].(string),
|
||||||
|
sshconf[mkProviderSSHPassword].(string),
|
||||||
|
sshconf[mkProviderSSHAgent].(bool),
|
||||||
|
sshconf[mkProviderSSHAgentSocket].(string),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,5 +98,64 @@ func nestedProviderSchema() map[string]*schema.Schema {
|
|||||||
},
|
},
|
||||||
ValidateFunc: validation.StringIsNotEmpty,
|
ValidateFunc: validation.StringIsNotEmpty,
|
||||||
},
|
},
|
||||||
|
mkProviderSSH: {
|
||||||
|
Type: schema.TypeSet,
|
||||||
|
Optional: true,
|
||||||
|
MaxItems: 1,
|
||||||
|
Description: "The SSH connection configuration to a Proxmox node",
|
||||||
|
Elem: &schema.Resource{
|
||||||
|
Schema: map[string]*schema.Schema{
|
||||||
|
mkProviderSSHUsername: {
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
Description: fmt.Sprintf("The username used for the SSH connection, "+
|
||||||
|
"defaults to the user specified in '%s'", mkProviderUsername),
|
||||||
|
DefaultFunc: schema.MultiEnvDefaultFunc(
|
||||||
|
[]string{"PROXMOX_VE_SSH_USERNAME", "PM_VE_SSH_USERNAME"},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ValidateFunc: validation.StringIsNotEmpty,
|
||||||
|
},
|
||||||
|
mkProviderSSHPassword: {
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
Description: fmt.Sprintf("The password used for the SSH connection, "+
|
||||||
|
"defaults to the password specified in '%s'", mkProviderPassword),
|
||||||
|
DefaultFunc: schema.MultiEnvDefaultFunc(
|
||||||
|
[]string{"PROXMOX_VE_SSH_PASSWORD", "PM_VE_SSH_PASSWORD"},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ValidateFunc: validation.StringIsNotEmpty,
|
||||||
|
},
|
||||||
|
mkProviderSSHAgent: {
|
||||||
|
Type: schema.TypeBool,
|
||||||
|
Optional: true,
|
||||||
|
Description: "Whether to use the SSH agent for the SSH authentication. Defaults to false",
|
||||||
|
DefaultFunc: func() (interface{}, error) {
|
||||||
|
for _, k := range []string{"PROXMOX_VE_SSH_AGENT", "PM_VE_SSH_AGENT"} {
|
||||||
|
v := os.Getenv(k)
|
||||||
|
|
||||||
|
if v == "true" || v == "1" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mkProviderSSHAgentSocket: {
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
Description: "The path to the SSH agent socket. Defaults to the value of the `SSH_AUTH_SOCK` " +
|
||||||
|
"environment variable",
|
||||||
|
DefaultFunc: schema.MultiEnvDefaultFunc(
|
||||||
|
[]string{"SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK", "PM_VE_SSH_AUTH_SOCK"},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ValidateFunc: validation.StringIsNotEmpty,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user