Skip to content

Commit d494a71

Browse files
Feature/control section permissions (#4)
* Update README * Add docker run to README * Add permission based access for the list-container command - controlled via setting.json * Update README with settings section - also reformat for better flow * Update README - labels are necessary * Update control-containers to work with the AllowedRoles section control * Change control-container log statements for view filtering to debug * Change list-containers to be ephemeral w/ 30 sec deletion
1 parent 3abb598 commit d494a71

File tree

6 files changed

+243
-85
lines changed

6 files changed

+243
-85
lines changed

README.md

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ A docker-based discord bot to control other docker containers on the host. Built
66
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/thisismygithubok/DiscContainerController?color=brightgreen&style=for-the-badge">
77
<img alt="GitHub" src="https://img.shields.io/github/license/thisismygithubok/DiscContainerController?style=for-the-badge"></p>
88

9+
## Slash Commands ##
10+
This bot has several slash commands to use:
11+
- /ping - does a simple check to see if the bot is online and responding
12+
- /list-containers - lists the containers on the host system
13+
- /control-container - this is an interative command:
14+
- First: You choose a container
15+
- Second: You choose an action of start, stop, or restart
16+
- Third: The bot will reply to you with a mention message once the action has been completed.
17+
18+
## Docker Run ##
19+
```
20+
docker run -e DISCORD_GUILD_ID=<your_guild_id> -e DISCORD_BOT_TOKEN=<your_bot_token> -e TZ=<your_tz> -v /var/run/docker.sock:/var/run/docker.sock -l section=<section_name> thisismynameok/disc-container-controller:latest
21+
```
22+
23+
## Docker Compose ##
24+
You can find an example in [docker-compose-example.yml](https://github.com/thisismygithubok/DiscContainerController/blob/main/docker-compose-example.yml)
25+
926
## Environment Variables ##
1027
- REQUIRED
1128
- DISCORD_GUILD_ID
@@ -19,27 +36,51 @@ A docker-based discord bot to control other docker containers on the host. Built
1936
- This is optional, but you can specify this for the container/logging output timezone
2037
- Must use IANA standard timezones
2138

39+
```
40+
environment:
41+
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
42+
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID}
43+
TZ: ${TZ}
44+
```
45+
2246
## Volumes ##
23-
You also need to mount the docker sock as a volume to the container to be able to control containers.
24-
- /var/run/docker.sock:/var/run/docker.sock
47+
You need to mount the docker sock as a volume to the container to be able to control containers. You also need to mount a config directory for the setting.json to be generated into
48+
```
49+
volumes:
50+
- /var/run/docker.sock:/var/run/docker.sock
51+
- ./config:config
52+
```
2553

2654
## Labels ##
27-
You can optionally add container labels called 'section' to categorize and list your containers in a more friendly manner.
28-
- Example
29-
- labels:
30-
- section: "Game Servers"
31-
32-
## Slash Commands ##
33-
This bot has several slash commands to use:
34-
- /ping - does a simple check to see if the bot is online and responding
35-
- /list-containers - lists the containers on the host system
36-
- /control-container - this is an interative command:
37-
- First: You choose a container
38-
- Second: You choose an action of start, stop, or restart
39-
- Third: The bot will reply to you with a mention message once the action has been completed.
55+
You need to add container labels called 'section' to categorize and list your containers in a more friendly manner.
56+
```
57+
labels:
58+
section: "Game Servers"
59+
```
4060

41-
## Docker Compose ##
42-
You can find an example in [docker-compose-example.yml](https://github.com/thisismygithubok/DiscContainerController/blob/main/docker-compose-example.yml)
61+
## Settings ##
62+
A settings.json file will be generated in your mounted config folder.
63+
- AdminIDs - this is a discord user ID, and these IDs will have permissions to see/control all containers within all sections.
64+
- AllowedRoles - this is a dict of role IDs and the sections those roles are allowed to see/control
65+
- Sections - these are the sections you've defined via container labels. These are necessary to see/control containers.
66+
- Example:
67+
```
68+
{
69+
"AdminIDs": [
70+
"1234567890101010"
71+
],
72+
"AllowedRoles": {
73+
"0101010987654321": [
74+
"Game Servers"
75+
]
76+
},
77+
"Sections": [
78+
"Backend",
79+
"Frontend",
80+
"Game Servers"
81+
]
82+
}
83+
```
4384

4485
## Setting Up a Discord Bot ##
4586
1. Navigate to the [Discord Developer Portal](https://discord.com/developers/applications)

dockerfile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ RUN pip3 install discord.py && pip3 install prettytable setuptools>=78.1.1
1818
RUN addgroup -S -g 988 docker && \
1919
adduser -S -D -H -h /src -s /sbin/nologin -G docker -u 1000 nonroot && \
2020
adduser nonroot docker && \
21-
chown -R nonroot:docker /src /config /entrypoint.sh && \
22-
chmod -R 755 /src /config /entrypoint.sh
21+
mkdir -p /config && \
22+
chown nonroot:docker /config && \
23+
chmod 2775 /config && \
24+
chown -R nonroot:docker /src /entrypoint.sh && \
25+
chmod -R 755 /src /entrypoint.sh
2326

2427
USER nonroot
2528

src/cogs/control-containers.py

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import asyncio
33
import discord
44
import logging
5+
import json
56
import subprocess
67
from discord import app_commands
78
from discord.ext import commands
@@ -28,38 +29,57 @@ def __init__(self, containers, timeout=30):
2829
super().__init__(timeout=timeout)
2930
self.containers = containers
3031
self.current_page = 0
31-
self.items_per_page = 24 # Due to 25 list item limit
32+
self.items_per_page = 24
3233
self.total_pages = (len(containers) - 1) // self.items_per_page + 1
34+
35+
logger.debug(f"Creating pagination view with {len(containers)} containers")
36+
37+
# Initialize without buttons first
38+
self.prev_button = None
39+
self.next_button = None
40+
3341
self.update_select_menu()
3442

3543
def update_select_menu(self):
36-
for item in self.children[:]:
37-
if isinstance(item, discord.ui.Select):
38-
self.remove_item(item)
44+
self.clear_items()
45+
46+
# Add the select menu
3947
start = self.current_page * self.items_per_page
4048
end = start + self.items_per_page
4149
current_containers = self.containers[start:end]
4250
select_menu = selectContainerName(current_containers)
4351
self.add_item(select_menu)
52+
53+
# Add navigation buttons if needed
54+
if len(self.containers) > self.items_per_page:
55+
self.prev_button = discord.ui.Button(
56+
label="Previous",
57+
style=discord.ButtonStyle.gray,
58+
disabled=(self.current_page == 0),
59+
custom_id="prev"
60+
)
61+
self.next_button = discord.ui.Button(
62+
label="Next",
63+
style=discord.ButtonStyle.gray,
64+
disabled=(self.current_page >= self.total_pages - 1),
65+
custom_id="next"
66+
)
67+
68+
# Bind callback functions
69+
self.prev_button.callback = self.prev_callback
70+
self.next_button.callback = self.next_callback
71+
72+
self.add_item(self.prev_button)
73+
self.add_item(self.next_button)
4474

45-
@discord.ui.button(label="Previous", style=discord.ButtonStyle.gray, disabled=True)
46-
async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button):
75+
async def prev_callback(self, interaction: discord.Interaction):
4776
self.current_page = max(0, self.current_page - 1)
4877
self.update_select_menu()
49-
button.disabled = self.current_page == 0
50-
next_button = [x for x in self.children if isinstance(x, discord.ui.Button) and x.label == "Next"][0]
51-
next_button.disabled = self.current_page >= self.total_pages - 1
52-
5378
await interaction.response.edit_message(view=self)
5479

55-
@discord.ui.button(label="Next", style=discord.ButtonStyle.gray)
56-
async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button):
80+
async def next_callback(self, interaction: discord.Interaction):
5781
self.current_page = min(self.total_pages - 1, self.current_page + 1)
5882
self.update_select_menu()
59-
button.disabled = self.current_page >= self.total_pages - 1
60-
prev_button = [x for x in self.children if isinstance(x, discord.ui.Button) and x.label == "Previous"][0]
61-
prev_button.disabled = self.current_page == 0
62-
6383
await interaction.response.edit_message(view=self)
6484

6585
class selectAction(discord.ui.Select):
@@ -113,17 +133,6 @@ def __init__(self, containers):
113133
]
114134
super().__init__(placeholder="Select a container", max_values=1, min_values=1, options=options)
115135

116-
containers = []
117-
try:
118-
result = subprocess.run(['docker', 'ps', '-a', '--format', '{{.Names}}'], capture_output=True, text=True, check=True)
119-
if result.returncode == 0:
120-
containers = result.stdout.strip().splitlines()
121-
containers.sort()
122-
logger.debug(f'Found containers: {containers}')
123-
124-
except subprocess.CalledProcessError as e:
125-
logger.error(f'Error executing docker command: {e}')
126-
127136
async def callback(self, interaction: discord.Interaction):
128137
selected_container = self.values[0]
129138
await interaction.response.defer(ephemeral=True)
@@ -143,24 +152,85 @@ async def delete_after_delay():
143152
class controlContainers(commands.Cog):
144153
def __init__(self, bot):
145154
self.bot = bot
155+
self.settings = self.load_settings()
156+
157+
def load_settings(self):
158+
try:
159+
with open('/config/settings.json', 'r') as f:
160+
return json.load(f)
161+
except Exception as e:
162+
logger.error(f'Error loading settings: {e}')
163+
return None
164+
165+
def get_allowed_sections(self, user: discord.Member):
166+
# Check if user is admin
167+
if str(user.id) in self.settings.get('AdminIDs', []):
168+
return self.settings.get('Sections', [])
169+
170+
# Get user's roles and their allowed sections
171+
allowed_sections = set()
172+
for role in user.roles:
173+
role_sections = self.settings.get('AllowedRoles', {}).get(str(role.id), [])
174+
allowed_sections.update(role_sections)
175+
176+
return list(allowed_sections)
146177

147178
@app_commands.command(name='control-container', description='Start, Stop, or Restart the selected Docker container.')
148179
@app_commands.guilds(discord.Object(id=DISCORD_GUILD_ID)) # type: ignore
149180

150181
async def controlContainer(self, interaction: discord.Interaction):
151182
try:
183+
if not self.settings:
184+
await interaction.response.send_message('Error: Settings not loaded', ephemeral=True)
185+
return
186+
187+
# Get allowed sections for this user
188+
allowed_sections = self.get_allowed_sections(interaction.user)
189+
if not allowed_sections and str(interaction.user.id) not in self.settings.get('AdminIDs', []):
190+
await interaction.response.send_message('You do not have permission to control any sections', ephemeral=True)
191+
return
192+
193+
# Get all containers and filter by section
152194
result = subprocess.run(['docker', 'ps', '-a', '--format', '{{.Names}}'],
153-
capture_output=True, text=True, check=True)
154-
if result.returncode == 0:
155-
containers = result.stdout.strip().splitlines()
156-
containers.sort()
157-
logger.debug(f'Found containers: {containers}')
158-
159-
view = ContainerPaginationView(containers)
160-
await interaction.response.send_message(view=view, ephemeral=True)
161-
await asyncio.sleep(30)
162-
await interaction.delete_original_response()
163-
195+
capture_output=True, text=True, check=True)
196+
197+
if result.returncode != 0:
198+
await interaction.response.send_message(
199+
'Error retrieving container list - see service logs.',
200+
ephemeral=True
201+
)
202+
return
203+
204+
containers = []
205+
all_containers = result.stdout.strip().splitlines()
206+
207+
# Filter containers by section permissions
208+
for container in all_containers:
209+
if container:
210+
inspect = subprocess.run(
211+
['docker', 'inspect', '--format', '{{.Config.Labels.section}}', container],
212+
capture_output=True, text=True, check=True
213+
)
214+
section = inspect.stdout.strip()
215+
section = section if section else "Uncategorized"
216+
217+
# Only add container if user has permission for this section or is admin
218+
if section in allowed_sections or str(interaction.user.id) in self.settings.get('AdminIDs', []):
219+
containers.append(container)
220+
221+
if not containers:
222+
await interaction.response.send_message('No containers available to control.', ephemeral=True)
223+
return
224+
225+
containers.sort()
226+
logger.debug(f'Creating view with {len(containers)} filtered containers')
227+
view = ContainerPaginationView(containers)
228+
logger.debug(f'Filtered containers by permission: {containers}')
229+
230+
await interaction.response.send_message(view=view, ephemeral=True)
231+
await asyncio.sleep(30)
232+
await interaction.delete_original_response()
233+
164234
except subprocess.CalledProcessError as e:
165235
logger.error(f'Error executing docker command: {e}')
166236
await interaction.response.send_message(

0 commit comments

Comments
 (0)