Projects
home:rottame:vhosts-ng
rubygem-bender-vhng
Log In
Username
Password
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
Expand all
Collapse all
Changes of Revision 10
View file
rubygem-bender-vhng.changes
Added
@@ -0,0 +1,5 @@ +------------------------------------------------------------------- +Mon Sep 11 06:53:13 UTC 2023 - Angelo Grossini <rottame@intercom.it> + +- migrate ftp server to sftpgo +- explicit quotas from backend
View file
rubygem-bender-vhng.spec
Changed
@@ -1,7 +1,7 @@ %define mod_name bender-vhng %define mod_full_name %{mod_name}-%{version} Name: rubygem-bender-vhng -Version: 2.0.2 +Version: 2.1.0 Release: 0 Summary: vhng provisioning tool License: Apache-2.0
View file
bender-vhng-2.0.2.gem/checksums.yaml.gz -> bender-vhng-2.1.0.gem/checksums.yaml.gz
Changed
@@ -1,7 +1,7 @@ --- SHA256: - metadata.gz: 5bb227e1ddbfc0fb0262158ef009b239cc1ad4d8c0334fd9b05fde1088ef9eb1 - data.tar.gz: 6d180988a92b587c5a43731c818fe62fafea5258d4fb79b4707d42f9e67c1058 + metadata.gz: 639596360bbe5fed798a2dd975f0eb4a02ab91b3ae1447f51bf4710eafb59ee7 + data.tar.gz: 4cf475fc713f899ce8042f522102f9ff8249e78813d66e0d766a4547fadbb426 SHA512: - metadata.gz: 44ffc3b1c958be33d56ff1ebacc1dc4f6df30f443366d8f4677483d084033d100e3e3986e38313bd9a65420d8856f579725a8640c5a775202b0e2bc055128008 - data.tar.gz: e19766406de268ebb4209c6543b48307f8e3ee59d56df8e6fa926a8e9ac3a59806296642738ade938d73e728ab46bb400a556182f31e8094b702ca2b94fd07e0 + metadata.gz: d26f4e8f9c27235a5e75d9c39acd102df56d077a5737ea8cfe4e6722ed3f1b9abeb07fd70a65d7feefa999a94d74ce5032d69cb347cc50465270d8c3fdcc6e3d + data.tar.gz: 8f34895ee055fae95d82aafe14c6fecb0258eb46fe1193ab676b6d37bf09a8385b197973ac843f5ceeccfc29b1bfaaf6304d006a2d13917c7e0adca7c13d2f69
View file
bender-vhng-2.0.2.gem/data/bin/bender -> bender-vhng-2.1.0.gem/data/bin/bender
Changed
@@ -1,6 +1,6 @@ #!/usr/bin/ruby - +$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) require 'bender' -Bender.new.do \ No newline at end of file +Bender.new.do
View file
bender-vhng-2.0.2.gem/data/config/baseconfig.xml -> bender-vhng-2.1.0.gem/data/config/baseconfig.xml
Changed
@@ -9,24 +9,34 @@ <LibDir>/usr/lib/bender</LibDir> <VarLibDir>/var/lib/bender</VarLibDir> <VhostsDatabase> - <host>172.17.0.1</host> - <port>3310</port> + <host>mariadb</host> + <port>3306</port> <username>foobar</username> <password>foobar</password> </VhostsDatabase> <VhostsSlaveDatabase> - <host>172.17.0.1</host> - <port>3310</port> + <host>mariadb</host> + <port>3306</port> <username>foobar</username> <password>foobar</password> </VhostsSlaveDatabase> <ServicesDatabase> - <host>172.17.0.1</host> - <port>3310</port> + <host>mariadb</host> + <port>3306</port> <username>foobar</username> <password>foobar</password> <database>services_login</database> </ServicesDatabase> + <sftpgo> + <url>http://admin:admin@sftpgo:8080</url> + </sftpgo> + <NFS> + <ExportsFile>/tmp/exports</ExportsFile> + </NFS> + <XFS> + <ProjectsFile>/tmp/projects</ProjectsFile> + <ProjectIdsFile>/tmp/projid</ProjectIdsFile> + </XFS> <Names> <Name name="main">MAIN_DOMAIN_NAME</Name> <Name name="validation">VALIDATION_DOMAIN_NAME</Name>
View file
bender-vhng-2.0.2.gem/data/config/webserver.xml -> bender-vhng-2.1.0.gem/data/config/webserver.xml
Changed
@@ -277,8 +277,14 @@ </SMTP> </Services> <NFS> - <ACL>*</ACL> - <Options>rw,no_root_squash,async,no_subtree_check</Options> + <Data> + <ACL>*</ACL> + <Options>rw,no_root_squash,async,no_subtree_check</Options> + </Data> + <Logs> + <ACL>*</ACL> + <Options>ro,no_root_squash,async,no_subtree_check</Options> + </Logs> </NFS> <Commands> <NFSReload>systemctl reload nfs-server.service</NFSReload>
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/ICServiceBackend.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/ICServiceBackend.rb
Changed
@@ -180,7 +180,8 @@ hnd = plugin.new(localID, configRoot) @pluginList << hnd rescue - log.error("Error while instancing plugin: #{pluginfile}: #{$!}") + log.error("Error while instancing plugin: #{pluginfile}: #{$!}\n\t#{$!.backtrace.join("\n\t")}") + end }
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/Models/Webserver/WebHosts.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/Models/Webserver/WebHosts.rb
Changed
@@ -158,9 +158,13 @@ docker_image: nil }, quota: { - name: data:quota, + quota_name: data:quota||data:quota_name, enforce: data:enforce_quota, - quotas: nil + quotas: { + cpu: data:quota_cpu, + mem: data:quota_mem, + fs: data:quota_fs, + } }, php: { enabled: data:php_enabled, @@ -236,13 +240,6 @@ } end - #if data:fs_quota_extra && data:fs_quota_extra > 0 - # ret:quota:quotas = { - # type: 'filesystem', - # size: data:fs_quota_extra - # }; - #end - if data:is_docker.present? ret:service:docker_image = data:docker_image ret:php = {enabled: false} @@ -257,7 +254,7 @@ enabled: f:enabled } end - + ret = ConfigObject.new(ret) ret.service.environment = Hashdata:env_vars.map{|e| e:key, e:value} ret
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/Plugins/Webserver/01-Filesystems.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/Plugins/Webserver/01-Filesystems.rb
Changed
@@ -1,16 +1,13 @@ require 'bender/classes/Plugin' require 'bender/classes/Models/Webserver/FTPUser' require 'securerandom' +require 'bender/tools/SftpGo' $pluginClass = :Filesystems class Filesystems < ICPlugin def initialize(*args) super - - conn = baseConfig.get_string_array('/ICSystemConf/ServicesDatabase') - conn = {adapter: 'mysql2'}.merge(conn0) - Webserver::FTPUser.establish_connection conn end def provision(args) @@ -42,6 +39,7 @@ service = args:service env = args:env + init_sftpgo disableNFSExport(host, service, env) removeFTPLogin(host, service, env) end @@ -51,6 +49,7 @@ service = args:service env = args:env + init_sftpgo enableNFSExport(host, service, env) addFTPLogin(host, service, env) end @@ -69,6 +68,11 @@ protected + def init_sftpgo + url = baseConfig.get_string('/ICSystemConf/sftpgo/url') + @sftpgo = SftpGo.new url + end + def setupFilesystem(host, service, env) storagePath = service.host_storagePath(host) mntPath = service.containerVHostRoot(host) @@ -105,6 +109,9 @@ def setupXFSProject(host, service, env) return unless supports_quota? + projects_file = baseConfig.get_string('/ICSystemConf/XFS/ProjectsFile') + prjid_file = baseConfig.get_string('/ICSystemConf/XFS/ProjectIdsFile') + storagePath = service.host_storagePath(host) log.debug ' + setup quota' @@ -112,32 +119,32 @@ prjid = "%s:%d" % host.service.login, idx prj = "%d:%s" % idx, storagePath - _, projects = service.loadConfigFile('/etc/projects') + _, projects = service.loadConfigFile(projects_file) unless projects.include?(prj) projects << prj - service.writeConfigFile('/etc/projects', projects) + service.writeConfigFile(projects_file, projects) end - _, projids = service.loadConfigFile('/etc/projid') + _, projids = service.loadConfigFile(prjid_file) unless projids.include?(prjid) projids << prjid - service.writeConfigFile('/etc/projid', projids) + service.writeConfigFile(prjid_file, projids) end quota = if host.quota.enforce - q = service.mySvcConf("/Quotas/Quota@name='#{host.quota.name}'/Filesystem").to_i - if host.quota.quotas.try(:any?) - host.quota.quotas.each do | qd | - next unless qd.type == 'filesystem' - q += qd.size - end + q = if host.quota.quota_name + service.mySvcConf("/Quotas/Quota@name='#{host.quota.quota_name}'/Filesystem").to_i + elsif host.quota.quotas.fs + host.quota.quotas.fs.try(:to_i) end - "%sM" % q - else - q = service.mySvcConf("/ConfigDefaults/UndefinedQuota") - log.debug " + do not enforce quota, use default of #{q}" - q + "%sM" % q if q + end + unless quota + quota = service.mySvcConf("/ConfigDefaults/UndefinedQuota") + log.debug " + quota undefined, use default of #{q}" unless host.quota.enforce + log.debug " + do not enforce quota, use default of #{q}" if host.quota.enforce end + mountpoint = getMntPointFor(storagePath) log.debug " + quota size: #{quota}" service.run "xfs_quota -x -c 'project -s #{host.service.login}' #{mountpoint}" @@ -167,7 +174,7 @@ # service.mySvcConf('/NFS/Logs/Options'), # host.service.id # - + #addNFSExport(path, export, service, env) end @@ -184,20 +191,22 @@ end def addNFSExport(path, export, service, env) - _, exports = service.loadConfigFile('/etc/exports') + exports_file = baseConfig.get_string('/ICSystemConf/NFS/ExportsFile') + _, exports = service.loadConfigFile(exports_file) unless exports.include?(export) exports.reject!{|e| e =~ /\A#{path}\ /} exports << export - service.writeConfigFile('/etc/exports', exports) + service.writeConfigFile(exports_file, exports) log.debug ' + reload nfs server' service.run service.mySvcConf('/Commands/NFSReload') end end def removeNFSExport(path, service, env) - _, exports = service.loadConfigFile('/etc/exports') + exports_file = baseConfig.get_string('/ICSystemConf/NFS/ExportsFile') + _, exports = service.loadConfigFile(exports_file) exports.reject!{|e| e =~ /\A#{path}\ /} - service.writeConfigFile('/etc/exports', exports) + service.writeConfigFile(exports_file, exports) log.debug ' + reload nfs server' service.run service.mySvcConf('/Commands/NFSReload') end @@ -217,22 +226,29 @@ end def removeFTPLogin(host, service, env) - Webserver::FTPUser.where(vhost: host.service.name).delete_all + @sftpgo.delete_group host.service.name end def addFTPLogin(host, service, env) + group = @sftpgo.get_group host.service.name + group ||= @sftpgo.create_group SftpGo::Group.new('name' => host.service.name) + active = host.ftp.map do | ftp | - if ftp.enabled - record = Webserver::FTPUser.find_or_initialize_by(username: ftp.login) - record.uid = service.svcUID(host) - record.gid = service.mySvcConf('/SystemUsers/DefaultGroupId') - record.password = ftp.password.crypt("$5$rounds=1000$%s$" % SecureRandom.alphanumeric) - record.home = File.join(service.host_storagePath(host), ftp.path) - record.vhost = host.service.name - record.save! - ftp.login - end + user = @sftpgo.get_user ftp.login + user ||= SftpGo::User.new + user.username = ftp.login + user.password = ftp.password + user.uid = service.svcUID(host) + user.gid = service.mySvcConf('/SystemUsers/DefaultGroupId') + user.home_dir = File.join(service.host_storagePath(host), ftp.path) + user.groups = {name: group.name, type: 3} + user.active = ftp.enabled ? 1 : 0 + + if user.id + @sftpgo.update_user user + else + @sftpgo.create_user user + end end - Webserver::FTPUser.where(vhost: host.service.name).where.not(username: active.compact).delete_all end -end \ No newline at end of file +end
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/Plugins/Webserver/05-Mail.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/Plugins/Webserver/05-Mail.rb
Changed
@@ -85,7 +85,9 @@ end def deconfigureAccount(host, service, env) - Webserver::SMTPLogin.where(username: host.smtp.username).first.try(&:destroy!) + if host.smtp + Webserver::SMTPLogin.where(username: host.smtp.username).first.try(&:destroy!) + end end def configureAccount(host, service, env)
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/Plugins/Webserver/05-PHPFPM.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/Plugins/Webserver/05-PHPFPM.rb
Changed
@@ -71,8 +71,18 @@ _instances = host.php.instances _memory_limit = host.php.memory_limit _upload_max_filesize = host.php.upload_max_filesize - _quota_memory = service.mySvcConf("/Quotas/Quota@name='#{host.quota.name}'/Memory").to_i - _quota_memory_hard = service.mySvcConf("/Quotas/Quota@name='#{host.quota.name}'/MemoryHard").to_i + + _quota_memory = nil + _quota_memory_hard = nil + if host.quota.quota_name.present? + _quota_memory = service.mySvcConf("/Quotas/Quota@name='#{host.quota.quota_name}'/Memory").to_i + _quota_memory_hard = service.mySvcConf("/Quotas/Quota@name='#{host.quota.quota_name}'/MemoryHard").to_i + else + _quota_memory = host.quota.quotas.mem.try(:to_i) + _quota_memory_hard = quota_memory + 256 if _quota_memory + end + _quota_memory ||= 256 + _quota_memory_hard ||= 4096 _memory_limit = service.mySvcConf('/ConfigDefaults/PHP/MemoryLimit') if _memory_limit.blank? _upload_max_filesize = service.mySvcConf('/ConfigDefaults/PHP/UploadMaxFilesize') if _upload_max_filesize.blank?
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/Plugins/Webserver/95-Docker.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/Plugins/Webserver/95-Docker.rb
Changed
@@ -60,20 +60,29 @@ docker_image = if host.service.docker_image.present? host.service.docker_image elsif env:settings:php_version.present? - service.mySvcConf("/Docker/Images/Image@name=\"#{env:settings:php_version}\"/Name") + service.mySvcConf("/Docker/Images/Image@name=\"#{env:settings:php_version}\"/Name") else service.mySvcConf("/Docker/Images/Default/Name") end overbooking = service.mySvcConf("/QuotaOverbooking").to_i - quota_memory = service.mySvcConf("/Quotas/Quota@name='#{host.quota.name}'/Memory").to_i - quota_memory_hard = service.mySvcConf("/Quotas/Quota@name='#{host.quota.name}'/MemoryHard").to_i - if overbooking && overbooking > 0 + quota_memory = nil + quota_memory_hard = nil + + if host.quota.quota_name + quota_memory = service.mySvcConf("/Quotas/Quota@name='#{host.quota.quota_name}'/Memory").to_i + quota_memory_hard = service.mySvcConf("/Quotas/Quota@name='#{host.quota.quota_name}'/MemoryHard").to_i + else + quota_memory = host.quota.quotas.mem.try(:to_i) + quota_memory_hard = quota_memory + 256 if quota_memory + end + + if quota_memory && overbooking && overbooking > 0 quota_memory -= quota_memory*overbooking/100 end - unless host.quota.enforce + unless quota_memory quota_memory = 32 quota_memory_hard = 4096 end @@ -83,7 +92,7 @@ env_vars = env:env_vars.map do |k,v| v = v.to_s.gsub(/\$/, '$$') "%s=%s" % k,v - end + end random = SecureRandom.alphanumeric(8) @@ -215,7 +224,7 @@ fname = File.join(basePath, service.mySvcConf("/MandatoryPaths/Path@name='DockerComposeDir'/path"), "vhost-#{host.service.login}.yml") - + if env:dirty service.run "docker service scale vhost-#{host.service.login}_www=0" if exists?(host) && running?(host) service.run "docker stack rm vhost-#{host.service.login}" if exists?(host) && env:dirty
View file
bender-vhng-2.0.2.gem/data/lib/bender/classes/ServicesBackend/Webserver.rb -> bender-vhng-2.1.0.gem/data/lib/bender/classes/ServicesBackend/Webserver.rb
Changed
@@ -152,7 +152,7 @@ ) env:volumes = {} execPlugins('webserver', 'deactivate', {:host => h, :service => self, env: env}) - removeTimestamp(host) + removeTimestamp(h) end end
View file
bender-vhng-2.1.0.gem/data/lib/bender/tools/SftpGo.rb
Added
@@ -0,0 +1,281 @@ +require 'net/http' +require 'json' +require 'ostruct' + +class SftpGo + class ApiError < StandardError + attr_reader :error, :message + def initialize(error) + super error'message', error'error'.reject(&:blank?).join(': ') + @error = error'error' + @message = error'message' + end + + def not_found? + self.error == 'Not Found' + end + end + + class Record < OpenStruct + def self.fields + + end + + def initialize(data = {}) + defaults = self.class.fields.zip(nil * self.class.fields.count).to_h + raise ApiError.new(data) if (data.keys & 'error', 'message').any? + super defaults.merge(data) + end + + def as_json(**params) + data = super **params + data'table' + end + end + + class ApiKey < Record + def self.fields + 'access_token', 'expires_at' + end + + def expires_at + Time.parse(@table:expires_at) if @table:expires_at + end + + def expired? + !expires_at || expires_at <= Time.new + end + end + + class Group < Record + def self.fields + "id", "name", "description", "created_at", "updated_at", "user_settings", "virtual_folders", "users", "admins" + end + + def to_json + data = as_json except: :id, :created_at, :updated_at, :users, :admins + data.delete('description') unless data'description' + data.delete('user_settings') unless data'user_settings' + data.delete('virtual_folders') unless data'virtual_folders' + JSON.dump(data) + end + end + + class User < Record + def self.fields + + "id", "status", "username", "email", "description", "expiration_date", "password", "public_keys", "has_password", + "home_dir", "virtual_folders", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", + "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "upload_data_transfer", "download_data_transfer", + "total_data_transfer", "used_upload_data_transfer", "used_download_data_transfer", "created_at", "updated_at", + "last_login", "first_download", "first_upload", "last_password_change", "filters", "filesystem", "additional_info", + "groups", "oidc_custom_fields", "role" + + end + + def status + @table'status' ||= 1 + end + + def uid + @table'uid' ||= 2000 + end + + def gid + @table'gid' ||= 1000 + end + + def permissions + @table'permission' ||= { + "/": + "*" + , + } + end + + def to_json + data = as_json except: :id, :created_at, :updated_at, :users, :admins + data'status' ||= status + data'permissions' ||= permissions + data.keys.each do | k | + data.delete(k) unless datak.present? + end + data'uid' = data'uid'.to_i if data'uid' + data'gid' = data'gid'.to_i if data'gid' + JSON.dump(data) + end + end + + def initialize(url) + @sftpgo_url = url + login + end + + def get_group(name) + begin + Group.new(get("/api/v2/groups/#{name}")) + rescue ApiError => err + if err.not_found? + debug "Group \"#{name}\" not found" + nil + else + raise err + end + end + end + + def create_group(group) + Group.new post("/api/v2/groups", group) + end + + def update_group(group) + Group.new put("/api/v2/groups/#{group.name}", group) + end + + def delete_group(group) + name = group.name if group.is_a?(Group) + name = group if group.is_a?(String) + group = get_group(name) + if group + if group.users + group.users.each do | u | + delete_user u + end + end + response = delete("/api/v2/groups/#{name}") + raise ApiError.new(response) if response.keys.include? 'error' + end + true + end + + def get_user(name) + begin + User.new(get("/api/v2/users/#{name}")) + rescue ApiError => err + if err.not_found? + debug "User \"#{name}\" not found" + nil + else + raise err + end + end + end + + def create_user(user) + User.new post("/api/v2/users", user) + end + + def update_user(user) + response = put("/api/v2/users/#{user.username}", user) + raise ApiError.new(response) if response.keys.include? 'error' + get_user(user.username) + end + + def delete_user(user) + username = user.username if user.is_a?(User) + username = user if user.is_a?(String) + user = get_user username + if user + response = delete("/api/v2/users/#{username}") + raise ApiError.new(response) if response.keys.include? 'error' + end + true + end + + protected + + def debug(message) + $log.try(:debug, message) + end + + def info(message) + $log.try(:info, message) + end + + def error(message) + $log.try(:error, message) + end + + def api_key + @api_key = nil if @api_key && @api_key.expired? + unless @api_key + @api_key = login + end + @api_key.try(:access_token) + end + + def api_uri + @api_uri ||= URI(@sftpgo_url) + end + + def http + @http ||= begin + http = Net::HTTP.new(api_uri.host, api_uri.port) + logger = Class.new do + def <<(msg) + $log.debug(msg) if @log + end + end.new + http.set_debug_output logger + http + end + end + + def login + req = Net::HTTP::Get.new('/api/v2/token') + req.basic_auth api_uri.user, api_uri.password + + http.start do | http | + response = http.request req + ApiKey.new(JSON.parse(response.body)) + end + end + + def delete(path, params = {}) + req = Net::HTTP::Delete.new(path) + req'Accept' = 'text/json' + req'Authorization' = "Bearer #{api_key}" + + http.start do | http | + response = http.request req + JSON.parse(response.body) + end + end + + def get(path, params = {}) + req = Net::HTTP::Get.new(path) + req'Accept' = 'text/json' + req'Authorization' = "Bearer #{api_key}" + + http.start do | http | + response = http.request req + JSON.parse(response.body) + end + end + + def post(path, data, params = {}) + req = Net::HTTP::Post.new(path) + req'Accept' = 'text/json' + req'Conten-Type' = 'text/json' + req'Authorization' = "Bearer #{api_key}" + req.body = data.try(:to_json) || data.to_s + + http.start do | http | + response = http.request req + JSON.parse(response.body) + end + end + + def put(path, data, params = {}) + req = Net::HTTP::Put.new(path) + req'Accept' = 'text/json' + req'Conten-Type' = 'text/json' + req'Authorization' = "Bearer #{api_key}" + req.body = data.try(:to_json) || data.to_s + + http.start do | http | + response = http.request req + JSON.parse(response.body) + end + end +end
View file
bender-vhng-2.0.2.gem/metadata.gz -> bender-vhng-2.1.0.gem/metadata.gz
Changed
@@ -1,14 +1,14 @@ --- !ruby/object:Gem::Specification name: bender-vhng version: !ruby/object:Gem::Version - version: 2.0.2 + version: 2.1.0 platform: ruby authors: - Angelo Grossini -autorequire: +autorequire: bindir: bin cert_chain: -date: 2023-03-10 00:00:00.000000000 Z +date: 2023-09-11 00:00:00.000000000 Z dependencies: - !ruby/object:Gem::Dependency name: mail @@ -132,6 +132,7 @@ - lib/bender/tools/ErbBindable.rb - lib/bender/tools/Log.rb - lib/bender/tools/SelfSignedCert.rb +- lib/bender/tools/SftpGo.rb - lib/bender/tools/SystemInterface.rb - lib/bender/tools/Templates.rb - lib/bender/tools/Utilities.rb @@ -178,7 +179,7 @@ - MIT metadata: source_code_uri: https://lab.intercom.it -post_install_message: +post_install_message: rdoc_options: require_paths: - lib @@ -193,8 +194,8 @@ - !ruby/object:Gem::Version version: '0' requirements: -rubygems_version: 3.3.5 -signing_key: +rubygems_version: 3.3.15 +signing_key: specification_version: 4 summary: vhng provisioning tool test_files:
Locations
Projects
Search
Status Monitor
Help
Open Build Service
OBS Manuals
API Documentation
OBS Portal
Reporting a Bug
Contact
Mailing List
Forums
Chat (IRC)
Twitter
Open Build Service (OBS)
is an
openSUSE project
.